diff --git a/.agents/skills/build-from-issue/SKILL.md b/.agents/skills/build-from-issue/SKILL.md index 2e04e37f52..89d9e04215 100644 --- a/.agents/skills/build-from-issue/SKILL.md +++ b/.agents/skills/build-from-issue/SKILL.md @@ -1,6 +1,8 @@ --- name: build-from-issue description: Given a GitHub issue number, plan and implement the work described in the issue. Supports direct user requests and unattended queue processing through the `agent:*` workflow labels. Includes tests, documentation updates, and PR creation. Trigger keywords - build from issue, implement issue, work on issue, build issue, start issue. +metadata: + internal: true --- # Build From Issue @@ -25,7 +27,9 @@ A direct request authorizes only what it says. A request to review or plan does The two request labels remain human-only queue controls. Under **no circumstances** should this skill or any agent apply them, ask to apply them, or suggest automating their application. -Do not refuse a direct user request merely because its request label is absent. If direct work begins on an issue that was not already in the label-driven workflow, do not introduce `agent:in-progress` or `agent:pr-opened` solely for that invocation. If a matching request label is present, preserve the existing label transitions so unattended agents can track the workflow. +In direct mode, issue lifecycle and `agent:*` workflow labels are advisory rather than gates. Inspect the labels and warn the user about each expected label that is missing or any lifecycle label that indicates the normal workflow is incomplete, then continue with the requested phase. Do not ask the user to fix the labels first. A direct request does not change the issue's disposition or make the labels accurate; it only authorizes the requested work. + +If direct work begins on an issue that was not already in the label-driven workflow, do not introduce `agent:in-progress` or `agent:pr-opened` solely for that invocation. If a matching request label is present, preserve the existing label transitions so unattended agents can track the workflow. ## Agent Comment Markers @@ -59,11 +63,11 @@ Fetch issue + comments ├─ topic:security present? │ → Route to review-security-issue or fix-security-issue; STOP │ - ├─ Triage incomplete, awaiting information, or awaiting human disposition? - │ → Report the blocking state and STOP + ├─ Direct mode + expected lifecycle or agent-workflow labels missing/incomplete? + │ → Warn which labels are missing or incomplete; continue with the requested phase │ - ├─ state:accepted absent? - │ → Human has not accepted the issue; STOP + ├─ Queue mode + triage incomplete, awaiting information, or awaiting human disposition? + │ → Report the blocking state and STOP │ ├─ No plan comment and no direct planning request and agent:plan-requested absent? │ → No request for agent planning; STOP @@ -109,13 +113,15 @@ If the issue is closed, report that and stop. If `topic:security` is present, stop. General build agents must not plan or implement security issues. Route planning/review to `review-security-issue` and authorized remediation to `fix-security-issue`. -Stop before planning in any of these states: +In queue mode, stop before planning on `state:triage-needed` or `state:needs-info`, and stop on `state:validated` without roadmap placement. Require `state:accepted` or roadmap placement before queue work proceeds. If no plan exists, require `agent:plan-requested`; require `agent:implementation-requested` before queue-mode implementation. + +In direct mode, inspect the same expected workflow state but do not stop because a lifecycle or agent-workflow label is absent or incomplete. Before continuing, warn the user with the specific discrepancy, for example: + +> "Issue #42 is missing `state:accepted` or roadmap placement and `agent:implementation-requested`. Those labels are expected in the queued workflow, but your direct request authorizes implementation, so I am continuing without changing them." -- `state:triage-needed`: the issue has not been assessed; use `triage-issue`. -- `state:needs-info`: triage is waiting for evidence from the reporter. -- `state:validated`: triage is complete, but a human has not yet decided whether OpenShell should invest in the work. +If `state:triage-needed`, `state:needs-info`, or `state:validated` is present, name that state in the warning and explain what it normally means. Continue unless the issue lacks information that is actually necessary to perform the requested work; in that case, report the concrete missing information rather than treating the label itself as the blocker. -Next, require `state:accepted`. It records the human decision to pursue the work. If no plan exists, require either a direct user request for planning or the human-applied `agent:plan-requested` label before generating one. Record any roadmap association as sequencing context, but do not require one. Never add or remove `state:accepted`, either human request label, or the `roadmap` label. +Never add or remove `state:accepted`, either human request label, or the `roadmap` label. ## Step 2: Fetch and Classify Comments @@ -140,7 +146,7 @@ Using the state machine above, determine what to do based on: 1. Whether a plan comment exists 2. Whether there are human comments newer than the last agent comment (plan or conversation) 3. Whether this is direct mode and which phase the user requested -4. Which disposition, roadmap, and agent-workflow labels are present (`state:accepted`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, `agent:pr-opened`, and the `roadmap` label) +4. Which lifecycle and agent-workflow labels are present (`state:*`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, and `agent:pr-opened`) and which discrepancies require a direct-mode warning Follow the appropriate branch below. @@ -160,7 +166,7 @@ Task tool with subagent_type="principal-engineer-reviewer" In the prompt, instruct the reviewer to: -1. Read the issue description thoroughly and identify what needs to change in the codebase. +1. Read the issue's user story and identify what needs to change in the codebase. Treat reporter diagnostics or solution ideas as optional context, not as authoritative or current analysis. 2. Map the requirements to existing code — read the relevant source files. 3. Determine the **issue type** — one of: `feat` (new feature), `fix` (bug fix), `refactor`, `chore`, `perf`, `docs`. 4. Propose the minimal set of changes that satisfies the requirements. @@ -174,6 +180,8 @@ In the prompt, instruct the reviewer to: 9. Assess **gateway config documentation impact** — if the change adds, removes, renames, or changes defaults for gateway TOML keys or driver-specific config options, the plan must include an update to `docs/reference/gateway-config.mdx`. If the change is surfaced through Helm or a compute-driver overview, also include `docs/reference/sandbox-compute-drivers.mdx` or the relevant deployment docs. 10. Assess **LSM compatibility** — if the change touches process identity, `/proc` filesystem access, binary execution, or inter-process visibility, flag whether it will behave differently on hosts running SELinux (enforcing) or AppArmor. In particular, tests that fork+exec into system binaries will fail on SELinux-enforcing hosts due to cross-label `/proc//exe` access restrictions. +Perform this investigation against the current branch and current product behavior. If the issue contains earlier diagnostics, verify them rather than relying on them. + ### A2: Post the Plan Comment Post the plan as a comment on the issue. This is the **canonical plan comment** that will be edited in place as the plan evolves. @@ -679,7 +687,7 @@ If the `agent:in-progress` label is present, the skill was previously started bu User says: "Plan issue #42" 1. Fetch issue #42 — title: "Add pagination to dataset list endpoint" -2. Confirm `state:accepted` with no blocking triage state; the user's direct request authorizes planning even if `agent:plan-requested` is absent +2. Notice that `state:accepted` and `agent:plan-requested` are absent; warn that the issue does not match the queued workflow, then continue because the user directly requested planning 3. Fetch comments — no `🏗️ build-plan` marker found 4. Pass issue to `principal-engineer-reviewer` for analysis 5. Reviewer produces a plan: feat type, Medium complexity, 3 implementation steps, unit + integration tests needed @@ -711,7 +719,7 @@ User says: "Check issue #42" User says: "Build issue #42" -1. Fetch issue #42 — `state:accepted` is present; the user's direct request authorizes implementation +1. Fetch issue #42 — `state:accepted` is present but `agent:implementation-requested` is absent; warn about the missing queue label and continue because the user directly requested implementation 2. Plan exists (Revision 2), complexity: Medium, confidence: High 3. No conflicting branches or PRs 4. Create branch `feat/42-add-pagination/jmyers` @@ -725,6 +733,15 @@ User says: "Build issue #42" 12. No agent-workflow label transition is needed 13. Report PR URL and workflow run status to user +### Run directly on an issue outside the workflow state machine + +User says: "Build issue #42" + +1. Fetch issue #42 — it has `state:triage-needed`; neither `state:accepted` nor `agent:implementation-requested` is present +2. Warn that triage and acceptance are incomplete and name the missing implementation request label +3. Continue through planning and implementation because the user directly requested the work +4. Do not add, remove, or reinterpret lifecycle or agent-workflow labels + ### Run on issue with existing PR User says: "Build issue #42" @@ -737,7 +754,7 @@ User says: "Build issue #42" User says: "Build issue #99" -1. Fetch issue #99 — `state:accepted` is present; the user's direct request authorizes implementation +1. Fetch issue #99 — warn about any missing expected workflow labels, then continue because the user directly requested implementation 2. Plan exists: complexity High, confidence Low, has open questions 3. Warn user: "Issue #99 is rated High complexity / Low confidence. Proceeding but flagging for your awareness." 4. Continue with build diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md new file mode 100644 index 0000000000..5f0d617b08 --- /dev/null +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -0,0 +1,343 @@ +--- +name: build-openshell-mxc-windows +description: Maintain and validate OpenShell's build-only Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MXC driver, policy translation, MSI, service, or supervisor runtime support on Windows. +metadata: + internal: true +--- + +# Build OpenShell-MXC for Windows + +This skill maintains the existing native Windows MSVC build lane in the +OpenShell repository. The Windows lane is already present in `main`; do not +treat this skill as a first-time porting recipe unless the user explicitly asks +for a new fork or a from-scratch bring-up. + +The lane is build-only. It validates that OpenShell can compile and test on +Windows MSVC for the supported deliverables: + +- `openshell-gateway.exe` +- `openshell.exe` + +It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM +runtime host. + +## Current Repository Shape + +The Windows build lane is implemented by these tracked files: + +| Path | Purpose | +|---|---| +| `tasks/windows.toml` | Mise task entry points for `windows:*` commands. | +| `tasks/rust.toml`, `tasks/test.toml`, and `tasks/markdown.toml` | Windows routing for compiler-bearing checks, explicit Unix-only test skips, and Markdown dependency setup. | +| `tasks/scripts/windows-msvc.ps1` | PowerShell wrapper that enters the Visual Studio developer environment and invokes Cargo. | +| `.github/workflows/windows-msvc.yml` | Manually dispatched GitHub Actions jobs with architecture-specific Rust caches for x64 and future ARM64 Windows validation. | +| `architecture/windows-msvc-build.md` | Design notes and validation contract. | +| `.agents/skills/build-openshell-mxc-windows/` | This skill and companion reference material. | + +Use the code that is already in the repo. Do not generate a parallel Windows +build system, duplicate the wrapper, or add repository automation that the user +did not request. + +## Scope + +In scope: + +- Refreshing a local checkout to the latest upstream GitHub `main`. +- Maintaining `tasks/windows.toml` and `tasks/scripts/windows-msvc.ps1`. +- Running x64 and ARM64 MSVC checks. +- Building x64 and ARM64 release binaries for `openshell-gateway` and + `openshell`. +- Running workspace tests on a native x64 or ARM64 host. +- Running focused unsupported-driver contract tests. +- Reporting test counts, skipped/gated areas, warnings, artifacts, and logs. +- Keeping Linux and macOS build paths unchanged. +- Keeping unsupported Windows compute drivers explicit and testable. + +Out of scope: + +- Docker Desktop support on Windows. +- Kubernetes support on Windows. +- Podman, Podman machine, or Podman Desktop support on Windows. +- VM, Hyper-V, WSL, libkrun, or VM-backed sandbox execution on Windows. +- New MXC compute driver crate. +- OpenShell to MXC policy translation. +- Windows named-pipe driver IPC. +- Windows Credential Manager or DPAPI integration. +- MSI, WinGet, Windows service registration, or installer work. +- Windows supervisor runtime port. + +## Hard Rules + +- Do not enable Docker, Kubernetes, Podman, or VM runtimes on Windows. +- Do not build, package, ship, or smoke-test standalone Windows binaries for + unsupported compute drivers. +- Exclude unsupported Windows runtime crates from the Windows gateway dependency graph. +- Unsupported Windows runtime entry points must return a clear unsupported + error. +- Keep Windows-specific code behind `#[cfg(target_os = "windows")]`. +- Keep Unix/Linux-only code behind `#[cfg(unix)]` or + `#[cfg(target_os = "linux")]`. +- Do not modify the default Linux `mise run ci` path unless the user explicitly + asks for it. +- Use `mise run --skip-tools windows:*` for Windows validation. The Windows + toolchain is rustup plus Visual Studio Build Tools, not mise-provisioned Rust. +- Prefer one cross-platform `run` command when the underlying tool supports it + (for example, `npm --prefix`). Add `run_windows` only when the Windows shell + or validation contract genuinely differs. + +## Recommended Checkout Flow + +From a fork checkout where `upstream` points to the official +`NVIDIA/OpenShell` GitHub repository, use: + +```powershell +git fetch upstream main +git switch main +git merge --ff-only upstream/main +git branch --set-upstream-to=upstream/main main +git status --short --branch +``` + +For a direct checkout of the official repository, use `origin` instead of +`upstream`. Confirm the remote URLs with `git remote -v` before refreshing. + +If there are local changes, preserve or resolve them before refreshing. Do not +discard user work unless the user explicitly asks to clean the checkout. + +## Prerequisites + +The lane targets a Windows host with Visual Studio Build Tools and rustup. + +| Requirement | Check | Notes | +|---|---|---| +| Windows 11 | `[System.Environment]::OSVersion.Version` | Build 26100+ is recommended for MXC-adjacent validation, but compilation can still surface useful errors on older hosts. | +| Visual Studio 2022 or newer | `where.exe cl.exe` from a Developer PowerShell | Build Tools, Community, Professional, and Enterprise editions work when the target C++ components are installed. The wrapper discovers `VsDevCmd.bat` through `OPENSHELL_VSDEVCMD`, `vswhere`, or installed release directories such as `18` and `2022`. | +| Visual C++ ARM64 tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 -property installationPath` | Required for native ARM64 check, build, and tests and for x64-to-ARM64 check/build. Tests always require a native runner. | +| Visual C++ ARM64 Spectre-mitigated libraries | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Runtimes.ARM64.Spectre -property installationPath` | Required by `regorus` through `msvc_spectre_libs`; the build fails when the selected MSVC toolset lacks `lib\spectre\arm64`. | +| Visual C++ Clang tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath` | Provides host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for ARM64 crypto dependencies such as `ring` and `aws-lc-sys`. On ARM64, the wrapper uses `VC\Tools\Llvm\Arm64\bin`. | +| Visual C++ CMake tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath` | Provides CMake and Ninja. The x64-to-ARM64 path adds Ninja to `PATH` for native dependencies but keeps bundled Z3 on CMake's Visual Studio ARM64 generator with native MSVC `cl.exe`. | +| Windows SDK | `where.exe rc.exe` from a Developer PowerShell | Install an SDK containing target libraries and ARM64 tools. | +| Rust via rustup | `rustc --version` | Add each target being validated: `x86_64-pc-windows-msvc` and/or `aarch64-pc-windows-msvc`. The wrapper also adds the selected target. | +| mise | `mise --version` | Used as a task runner only. | +| Git | `git --version` | Needed for checkout and sync work. | +| PowerShell | `$PSVersionTable.PSVersion` | Windows PowerShell 5.1 works; PowerShell 7 is quieter with mise shell hooks. | + +Do not install Visual Studio, Rust, Docker, Kubernetes, Podman, WSL, or Hyper-V +from this skill. + +## Environment Variables + +| Variable | Default | Purpose | +|---|---|---| +| `OPENSHELL_VSDEVCMD` | unset | Optional explicit path to `VsDevCmd.bat`. | +| `OPENSHELL_MXC_SKIP_ARM64` | `0` | Set to `1` to skip ARM64 when using `all` tasks. | +| `OPENSHELL_WINDOWS_BUILD_JOBS` | `CARGO_BUILD_JOBS`, then `4` | Positive Cargo job limit used by the wrapper. | +| `CARGO_TARGET_DIR` | `target` under repo root | Override Cargo output location. Use a short absolute path when x64-to-ARM64 builds approach Windows path-length limits. | +| `Z3_LIBRARY_PATH_OVERRIDE` | unset | Directory containing an x64 system `libz3.lib`; not valid for ARM64. | +| `Z3_SYS_Z3_HEADER` | unset | Full `z3.h` path required with a system Z3 library. | +| `Z3_SYS_BUNDLED_DIR_OVERRIDE` | pinned source cached under `CARGO_TARGET_DIR` when explicit, otherwise `%LOCALAPPDATA%\OpenShell\cache\z3` | Use an existing Z3 source tree containing `src/api/z3.h`; otherwise the wrapper fetches the pinned revision through Git and sets this automatically. | +| `RUSTC_WRAPPER` | cleared by wrapper | The wrapper clears inherited values because `--skip-tools` does not provision `sccache`. | + +Legacy fork variables such as `OPENSHELL_UPSTREAM`, +`OPENSHELL_MXC_FORK_DIR`, and `OPENSHELL_MXC_FORK_BRANCH` are no longer part +of the normal maintenance workflow. Use them only if the user explicitly asks +for a new disposable fork. + +## Validation Workflow + +Run the smallest useful slice first, then broaden: + +```powershell +mise run --skip-tools windows:check:x64 +mise run --skip-tools windows:check:arm64 +mise run --skip-tools windows:build:x64 +mise run --skip-tools windows:build:arm64 +mise run --skip-tools windows:test:x64 +mise run --skip-tools windows:test:unsupported:x64 +``` + +For full validation, detect the Windows host architecture first and choose the +native lane dynamically: + +```powershell +$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +switch ($arch.ToString()) { + "X64" { + mise run --skip-tools windows:ci + } + "Arm64" { + mise run --skip-tools windows:check:arm64 + mise run --skip-tools windows:build:arm64 + mise run --skip-tools windows:test:arm64 + mise run --skip-tools windows:test:unsupported:arm64 + mise run --skip-tools windows:artifacts + } + default { + throw "Unsupported Windows host architecture for OpenShell MSVC validation: $arch" + } +} +``` + +On x64 hosts, `windows:ci` is the full current CI contract and runs in this +order: + +1. x64 check. +2. ARM64 check, unless `OPENSHELL_MXC_SKIP_ARM64=1`. +3. x64 release build. +4. ARM64 release build, unless skipped. +5. Native x64 workspace tests. +6. Focused unsupported-driver contract tests. +7. Artifact reporting. + +The GitHub Actions jobs use architecture-specific `Swatinem/rust-cache` +entries for the Cargo registry and dependency target artifacts. Failed runs +also save their usable dependency artifacts. The workflow remains manually +dispatched until cache-hit runtimes justify restoring automatic triggers. + +The ARM64 check/build steps in this x64-host contract are cross-builds. The +wrapper discovers and adds host-native LLVM and Ninja to `PATH`, requires the +ARM64 compiler and Spectre-mitigated libraries, lets ARM64 crypto crates select +`clang-cl`, and keeps bundled Z3 on native MSVC `cl.exe` with CMake's Visual +Studio ARM64 generator. Z3 does not use Ninja because `z3-sys 0.10.9` passes +the MSBuild-only `-m` argument. + +On ARM64 hosts, validate the native ARM64 check, build, and test path. The +wrapper rejects test targets that do not match the host architecture, so x64 +compatibility under emulation is not part of these tasks. The aggregate +`windows:ci` task remains the x64-host CI contract; run the explicit ARM64 +commands above on an ARM64 host. + +The repository-wide `mise run pre-commit` task is also supported on Windows. +Its Rust check, Clippy, and test dependencies enter the same MSVC environment +for the native host target and clear inherited `RUSTC_WRAPPER`. Linux glibc +installer tests and Linux service/RPM packaging-asset tests skip explicitly; +the Linux build-environment shell-helper test also skips; cross-platform checks +continue to run. The blocking Windows Clippy pass excludes unsupported +Windows runtime packages as top-level targets. It allows only unused imports, +dead code, and unused async functions that result from cfg-gated Windows stubs; +other warnings remain errors. + +The wrapper limits Cargo to four jobs by default and serializes wrapper-owned +Cargo commands with a host-local mutex. It deliberately does not set `CL` or +`_CL_`: those variables are also consumed by `clang-cl`, where a global MSVC +option such as `/MP4` can be interpreted as an input file and break ARM64 +crypto dependency builds. + +## Expected Task Behavior + +| Task | Expected behavior | +|---|---| +| `windows:check:x64` | `cargo check --workspace` for `x86_64-pc-windows-msvc`, excluding unsupported Windows packages as top-level workspace targets. | +| `windows:check:arm64` | `cargo check --workspace` for `aarch64-pc-windows-msvc`, with the same top-level exclusions. | +| `windows:build:x64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for x64. | +| `windows:build:arm64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for ARM64. | +| `windows:test:x64` | Runs native x64 workspace tests with `--no-fail-fast`, excluding unsupported Windows packages as top-level workspace targets. | +| `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | +| `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | +| `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | +| `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | +| `windows:ci` | Runs the full ordered x64-host Windows CI lane, plus ARM64 check/build when not skipped. | + +The unsupported driver package excludes are intentional. They prevent standalone +driver crates from being top-level Windows check/test targets while allowing +required libraries and Windows contracts to compile through gateway dependencies. +This includes the Kubernetes Secrets and Vault packages: their libraries remain +in the gateway build graph, but their Unix-socket standalone binaries do not. + +## Unsupported Driver Contract + +Windows must continue to reject unsupported compute drivers clearly. + +| Driver | Windows build behavior | Runtime behavior | +|---|---|---| +| Docker | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | +| Kubernetes | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | +| Podman | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | +| VM | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | + +The focused contract tasks for either native architecture run: + +```text +windows_builtin_compute_drivers_report_unsupported +``` + +These tests are also included in the full x64 workspace test run; the focused +task intentionally re-runs them so unsupported Windows behavior is visible in +the CI report. + +## Test Accounting Guidance + +When reporting `windows:ci`, distinguish these categories: + +- Passed tests from the full x64 workspace test log. +- Passed tests from the full ARM64 workspace test log when run on a native + ARM64 host. +- The focused unsupported-contract re-run. +- Explicit Cargo ignored tests, usually ignored doc examples. +- Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as + `running 0 tests`, not as ignored tests. +- Test-name `filtered out` counts from focused `cargo test` invocations. +- Package-level exclusions for unsupported Windows crates; Cargo does not report + those as ignored tests. + +Useful log files: + +| Log | Meaning | +|---|---| +| `build-x86_64-pc-windows-msvc-check.log` | x64 check output. | +| `build-aarch64-pc-windows-msvc-check.log` | ARM64 check output. | +| `build-x86_64-pc-windows-msvc-release.log` | x64 release build output. | +| `build-aarch64-pc-windows-msvc-release.log` | ARM64 release build output. | +| `test-x86_64-pc-windows-msvc.log` | Full native x64 workspace test output. | +| `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test output. | +| `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | +| `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 contract output. | + +The first bundled-Z3 check or test can spend several minutes in CMake/MSBuild +without much console output because Cargo output is redirected to the log. Look +for native `MSBuild.exe` workers before treating the process as stalled. The +wrapper fetches the pinned Z3 source through Git before Cargo starts. It caches +under an explicitly configured `CARGO_TARGET_DIR`, or under the current user's +local application data directory when Cargo uses its default target tree. +Concurrent commands publish the validated source through an atomic directory +rename, so x64 and ARM64 validation can share the cache safely. The wrapper does +not rely on the rate-limited GitHub Contents API used by `z3-sys`. A failed +fetch reports the partial checkout path for diagnosis. The artifact report +computes SHA256 through .NET directly and does not rely on the +`Get-FileHash` module being available inside the mise-launched Windows +PowerShell process. + +## Common Fix Patterns + +When Windows validation fails: + +1. Identify whether the error is from a top-level Windows deliverable, a + gateway dependency stub, or a Unix-only module leaking into the Windows build. +2. Prefer existing local patterns in the same crate. +3. Gate Unix imports and modules with `#[cfg(unix)]` or + `#[cfg(target_os = "linux")]`. +4. Add or preserve Windows stubs that return unsupported errors. +5. Keep Linux behavior unchanged. +6. Run `cargo fmt --all`, `git diff --check`, and the relevant `windows:*` + tasks after changes. + +Do not add broad abstractions or new Windows runtime support to satisfy a build +error. If a missing runtime feature is required, stop and propose a follow-on +skill or design doc. + +## Final Report Checklist + +Every substantial Windows build run should report: + +| Item | Required detail | +|---|---| +| Git state | Branch, upstream GitHub base commit, and whether local changes existed. | +| Host preconditions | OS, Rust, MSVC discovery, and notable warnings. | +| Commands run | Exact `mise run --skip-tools windows:*` commands. | +| x64 check/build | Pass/fail and log path. | +| ARM64 check/build | Pass/fail/skipped and log path. | +| Native tests | Passed/failed/ignored/filtered counts and log path for the host architecture. | +| Unsupported contracts | Which focused tests ran and their result. | +| Artifacts | Binary paths, size, and SHA256 when available. | +| Skips | Explicitly explain tests not run for a non-native architecture, unsupported driver package exclusions, and Windows cfg-gated tests. | +| Follow-ups | Only concrete follow-ups tied to failures or requested scope. | diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md new file mode 100644 index 0000000000..16b9a48586 --- /dev/null +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -0,0 +1,230 @@ +# Reference: Windows MSVC maintenance lane + +Companion to [SKILL.md](SKILL.md). Use this file for quick lookup while +maintaining the existing build-only Windows MSVC lane. + +## Lane Files + +| File | Purpose | +|---|---| +| `tasks/windows.toml` | Mise task definitions for `windows:*`. | +| `tasks/scripts/windows-msvc.ps1` | Visual Studio environment discovery, rustup target setup, Cargo invocation, logs, artifact report. | +| `.github/workflows/windows-msvc.yml` | Manual GitHub Actions x64 job and disabled ARM64 scaffold, each with an architecture-specific Rust dependency cache. | +| `architecture/windows-msvc-build.md` | Human-readable design contract. | + +## Commands + +Use `--skip-tools` for all Windows mise tasks: + +```powershell +mise run --skip-tools windows:check:x64 +mise run --skip-tools windows:check:arm64 +mise run --skip-tools windows:build:x64 +mise run --skip-tools windows:build:arm64 +mise run --skip-tools windows:test:x64 +mise run --skip-tools windows:test:arm64 +mise run --skip-tools windows:test:unsupported:x64 +mise run --skip-tools windows:test:unsupported:arm64 +mise run --skip-tools windows:ci +``` + +For host-native full validation, detect architecture first: + +```powershell +$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +if ($arch -eq [System.Runtime.InteropServices.Architecture]::Arm64) { + mise run --skip-tools windows:check:arm64 + mise run --skip-tools windows:build:arm64 + mise run --skip-tools windows:test:arm64 + mise run --skip-tools windows:test:unsupported:arm64 + mise run --skip-tools windows:artifacts +} else { + mise run --skip-tools windows:ci +} +``` + +The native test tasks reject a target that does not match the host architecture. +Do not report x64 compatibility-under-emulation coverage from an ARM64 run. + +The wrapper adds missing rustup targets and clears inherited +`RUSTC_WRAPPER`. It does not install Visual Studio, Rust, Docker, Kubernetes, +Podman, WSL, Hyper-V, or VM tooling. + +On Windows, `mise run pre-commit` routes `rust:check`, `rust:lint`, and +`test:rust` through this wrapper for the host-native target. The shared task +definitions retain their existing Unix commands. Only tests for Linux glibc +installer behavior, Linux build-environment shell helpers, and Linux +service/RPM packaging assets skip on Windows. The Windows Clippy command +excludes unsupported runtime packages as top-level targets and allows only +unused imports, dead code, and unused async functions caused by cfg-gated +Windows stubs; other warnings remain errors. + +The wrapper limits Cargo to four jobs by default and serializes wrapper-owned +Cargo commands with a host-local mutex. It does not set `CL` or `_CL_` because +`clang-cl` also consumes them and can parse a global `/MP4` option as an input +file. + +For ARM64, verify the Visual Studio instance contains the ARM64 MSVC tools, +ARM64 Spectre-mitigated libraries, Clang tools, CMake tools, and a Windows SDK. +Clang supplies host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for +ARM64 crypto dependencies such as `ring` and `aws-lc-sys`. Native ARM64 uses +the normal bundled-Z3 CMake path. An x64-to-ARM64 check/build discovers and +adds host-native Ninja to `PATH`, while the crypto crates select `clang-cl`. +Bundled Z3 uses CMake's Visual Studio ARM64 generator with native MSVC `cl.exe` +because `z3-sys 0.10.9` passes the MSBuild-only `-m` argument. Use a short +`CARGO_TARGET_DIR` if Windows path-length limits are reached. + +## Unsupported Driver Rules + +Windows is a build target only. These runtimes remain unsupported: + +- Docker +- Kubernetes +- Podman +- VM + +Rules: + +- Keep registration stubs in the gateway composition crate where the gateway needs them. +- Return clear unsupported errors at runtime. +- Do not build standalone Windows driver binaries. +- Do not add Docker Desktop, WSL, Hyper-V, Podman machine, Podman Desktop, or + VM-backed execution as part of this skill. + +Current focused unsupported-contract tests: + +```text +windows_builtin_compute_drivers_report_unsupported +``` + +Run them with the architecture-specific focused task on the native host. + +## Cargo Excludes + +The Windows wrapper intentionally excludes unsupported runtime packages as +top-level workspace targets for check/test: + +```text +--exclude openshell-driver-docker +--exclude openshell-driver-kubernetes +--exclude openshell-driver-kubernetes-secrets +--exclude openshell-driver-podman +--exclude openshell-driver-vault +--exclude openshell-driver-vm +--exclude openshell-sandbox +--exclude openshell-supervisor-network +--exclude openshell-supervisor-process +--exclude openshell-vfio +``` + +The gateway keeps platform configuration and unsupported-operation contracts +without depending on the Docker, Kubernetes, Podman, sandbox supervisor, +process supervisor, VM, or VFIO runtime crates. The Kubernetes Secrets and +Vault libraries still compile as gateway dependencies; only their standalone +Unix-socket binaries and package-level tests are excluded as top-level targets. + +## Common Errors + +### Unix imports leak into Windows builds + +Symptoms: + +```text +unresolved import std::os::unix +unresolved import tokio::net::UnixListener +unresolved import nix::... +``` + +Fix pattern: + +```rust +#[cfg(unix)] +use tokio::net::{UnixListener, UnixStream}; +``` + +Move Unix-only functions into Unix-only modules, or add a Windows stub that +returns an unsupported error. + +### Linux-only dependency reaches Windows + +Symptoms: + +```text +failed to run custom build command for libseccomp-sys +pkg-config could not find libsecret +``` + +Fix pattern: + +```toml +[target.'cfg(target_os = "linux")'.dependencies] +libseccomp = "..." +``` + +Only gate the dependency if no Windows path should use it. + +### ARM64 check fails but x64 passes + +Likely causes: + +- Native dependency does not support `aarch64-pc-windows-msvc`. +- ARM64 MSVC or Spectre-mitigated libraries are missing. +- Host-native `clang-cl`, Ninja, or CMake is missing during an x64-to-ARM64 build. +- `CL` or `_CL_` injects a global MSVC option such as `/MP4` into `clang-cl`. +- Build script assumes x64 tools. +- Inline assembly or prebuilt artifact lacks ARM64 handling. + +Do not skip ARM64 silently. Either fix the target handling or report the exact +blocked dependency. + +### Focused tests report many filtered-out tests + +This is expected for `windows:test:unsupported:x64`. Cargo runs one named test +and filters the other `openshell-gateway` tests. Report these as filtered, not +ignored. + +## Reporting Counts + +Use the log summaries from: + +| Log | Count source | +|---|---| +| `test-x86_64-pc-windows-msvc.log` | Full x64 workspace test pass. | +| `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test pass. | +| `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-contract re-runs and filtered counts. | +| `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 re-runs and filtered counts. | + +Separate: + +- passed +- failed +- ignored +- filtered out +- cfg-gated zero-test targets +- package-level excludes + +Package-level excludes are not printed as ignored tests by Cargo. + +## Final Sanity Checks + +Before committing Windows-lane changes, choose checks based on the host +architecture: + +```powershell +cargo fmt --all +git diff --check +$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +if ($arch -eq [System.Runtime.InteropServices.Architecture]::Arm64) { + mise run --skip-tools windows:check:arm64 + mise run --skip-tools windows:build:arm64 + mise run --skip-tools windows:test:arm64 + mise run --skip-tools windows:test:unsupported:arm64 +} else { + mise run --skip-tools windows:check:x64 + mise run --skip-tools windows:check:arm64 + mise run --skip-tools windows:test:unsupported:x64 +} +``` + +Run the full x64-host `windows:ci` lane when build or test behavior changed and +the host can run that lane natively. diff --git a/.agents/skills/create-github-issue/SKILL.md b/.agents/skills/create-github-issue/SKILL.md index 8352603e76..2510b79272 100644 --- a/.agents/skills/create-github-issue/SKILL.md +++ b/.agents/skills/create-github-issue/SKILL.md @@ -1,6 +1,8 @@ --- name: create-github-issue description: Create GitHub issues using the gh CLI. Use when the user wants to create a new issue, report a bug, request a feature, or create a task in GitHub. Trigger keywords - create issue, new issue, file bug, report bug, feature request, github issue. +metadata: + internal: true --- # Create GitHub Issue @@ -17,27 +19,27 @@ This project uses YAML form issue templates. When creating issues, match the tem ### Bug Reports -Do not add a type label automatically. The body must include an **Agent Diagnostic** section — this is required by the template and enforced by project convention. The diagnostic must identify the OpenShell version tested, whether the latest release or known fixes were checked, and whether possible duplicate issues were searched. If the agent cannot verify the latest release or search existing issues, say so explicitly instead of guessing. Apply area or topic labels only when they are clearly known. +Do not add a type label automatically. The body must include a **User Story**, **Problem Statement**, **Impact / Why This Matters**, and **Acceptance Criteria**, followed by bug-specific reproduction steps and environment details. Logs are optional and must be concise and redacted. Apply area or topic labels only when they are clearly known. ```bash gh issue create \ --title "bug: " \ --body "$(cat <<'EOF' -## Agent Diagnostic +## User Story -- Skills loaded: -- OpenShell version tested: -- Latest release checked: -- Known fixes reviewed: -- Possible duplicates reviewed: -- Findings: -- Remaining reason for filing: +As a , I want , so that . -## Description +## Problem Statement + + + +## Impact / Why This Matters -**Actual behavior:** + -**Expected behavior:** +## Acceptance Criteria + +- [ ] ## Reproduction Steps @@ -46,16 +48,14 @@ gh issue create \ ## Environment -- OS: -- Docker: - OpenShell: -- Latest release checked: -- Possible duplicates checked: +- OS: +- Runtime, deployment, or integration: ## Logs ``` - + ``` EOF )" @@ -63,28 +63,39 @@ EOF ### Feature Requests -Do not add a type label automatically. The body must include a **Proposed Design** — not a "please build this" request. Apply area or topic labels only when they are clearly known. +Do not add a type label automatically. The body must include a **User Story**, **Problem Statement**, **Impact / Why This Matters**, **Proposed Design**, **Acceptance Criteria**, and **Alternatives Considered**. The proposed design should define the user-facing workflow and externally observable behavior without prescribing internal implementation. Agent investigation is optional. Apply area or topic labels only when they are clearly known. ```bash gh issue create \ --title "feat: " \ --body "$(cat <<'EOF' +## User Story + +As a , I want , so that . + ## Problem Statement - + + +## Impact / Why This Matters + + ## Proposed Design - + + +## Acceptance Criteria + +- [ ] ## Alternatives Considered - + ## Agent Investigation - + EOF )" ``` @@ -114,7 +125,7 @@ EOF GitHub built-in issue types (`Bug`, `Feature`, `Task`) should come from the matching issue template when possible, or be set manually afterward. Do not try to emulate them through labels. -Creating an issue does not accept it for roadmap work or queue agent work. Agents never apply the `roadmap` label, add issues to the roadmap project, or apply `agent:plan-requested` or `agent:implementation-requested`. Community issues proceed through `triage-issue`; a human decides whether technically validated work should be accepted and places it on the roadmap. The request labels queue work for unattended agents; a user may instead direct an agent to a specific issue. +Creating an issue does not accept it or queue agent work. Agents never apply `state:accepted`, the `roadmap` label, add issues to the roadmap project, or apply `agent:plan-requested` or `agent:implementation-requested`. Community issues proceed through `triage-issue`; a human accepts technically validated work with `state:accepted` or roadmap placement. The request labels queue work for unattended agents. A user may instead direct an agent to a specific issue; the agent warns about missing expected workflow labels and continues with the requested phase without changing them. ## Useful Options diff --git a/.agents/skills/create-github-pr/SKILL.md b/.agents/skills/create-github-pr/SKILL.md index d98aba37f2..8e14bc5f9a 100644 --- a/.agents/skills/create-github-pr/SKILL.md +++ b/.agents/skills/create-github-pr/SKILL.md @@ -1,6 +1,8 @@ --- name: create-github-pr description: Create GitHub pull requests using the gh CLI. Use when the user wants to create a new PR, submit code for review, or open a pull request. Trigger keywords - create PR, pull request, new PR, submit for review, code review. +metadata: + internal: true --- # Create GitHub Pull Request diff --git a/.agents/skills/create-rfc/SKILL.md b/.agents/skills/create-rfc/SKILL.md index f767e47587..6df87ef315 100644 --- a/.agents/skills/create-rfc/SKILL.md +++ b/.agents/skills/create-rfc/SKILL.md @@ -1,6 +1,8 @@ --- name: create-rfc description: Create OpenShell RFC proposals in rfc/ from a design request. Use when the user asks to write, draft, start, create, or update an RFC, Request for Comments, architecture proposal, API proposal, process proposal, or cross-cutting design proposal that should follow the OpenShell RFC process and template. +metadata: + internal: true --- # Create RFC diff --git a/.agents/skills/create-spike/SKILL.md b/.agents/skills/create-spike/SKILL.md index 4f30c3829a..baafd173da 100644 --- a/.agents/skills/create-spike/SKILL.md +++ b/.agents/skills/create-spike/SKILL.md @@ -1,6 +1,8 @@ --- name: create-spike description: Investigate a plain-language problem description by deeply exploring the codebase, then create a structured GitHub issue with technical findings. Prequel to build-from-issue — maps vague ideas to concrete, buildable issues. Trigger keywords - spike, investigate, explore, research issue, technical investigation, create spike, new spike, feasibility, codebase exploration. +metadata: + internal: true --- # Create Spike @@ -211,7 +213,7 @@ gh issue create \ - --- -*Created by spike investigation. `state:validated` means the issue is ready for human disposition; `state:needs-info` means specific evidence is still required. A human applies `state:accepted` if OpenShell should pursue the work and places it on the roadmap separately. To queue unattended agent planning, a human applies `agent:plan-requested`; a direct request to an agent does not require that label.* +*Created by spike investigation. `state:validated` means the issue is ready for human disposition; `state:needs-info` means specific evidence is still required. A human applies `state:accepted` or places the issue on the roadmap if OpenShell should pursue the work. To queue unattended agent planning, a human applies `agent:plan-requested`; on a direct request, the agent warns about missing expected workflow labels and continues without changing them.* EOF )" ``` @@ -235,7 +237,7 @@ After creating the issue, report: For `state:validated`: -> Review the issue and decide whether OpenShell should pursue it. If yes, replace `state:validated` with `state:accepted` and separately associate it with a roadmap item. The work may remain human-owned. Apply `agent:plan-requested` to queue planning for an unattended agent, or directly ask an agent to use `build-from-issue`. If no, close it as not planned and record the rationale. +> Review the issue and decide whether OpenShell should pursue it. If yes, apply `state:accepted`, associate it with a roadmap item, or do both. Either action records acceptance; roadmap placement additionally records sequencing. The work may remain human-owned. Apply `agent:plan-requested` to queue planning for an unattended agent, or directly ask an agent to use `build-from-issue`; on a direct request, the agent warns about missing expected workflow labels and continues without changing them. If no, close it as not planned and record the rationale. For `state:needs-info`: diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md deleted file mode 100644 index d07bf1b6c8..0000000000 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ /dev/null @@ -1,464 +0,0 @@ ---- -name: debug-openshell-cluster -description: Debug why an OpenShell gateway deployment is unhealthy, unreachable, or unable to create sandboxes. Use for gateway health failures, Docker/Podman runtime issues, Helm failures, Kubernetes scheduling, TLS or auth, gateway interceptors, supervisor middleware startup or runtime failures, external compute-driver sockets, VM drivers, or sandbox startup. Trigger keywords - debug gateway, gateway failing, deployment failing, helm install failing, cluster health, gateway health, gateway not starting, health check failed, sandbox pending, docker driver, podman driver, kubernetes driver, external driver, compute driver socket, gateway interceptor, supervisor middleware, middleware failed, vm driver. ---- - -# Debug OpenShell Gateway Deployment - -Diagnose a gateway and its selected compute platform. Do not assume OpenShell provisions Kubernetes or runs a k3s container. OpenShell targets a reachable gateway endpoint backed by Docker, Podman, Kubernetes, the experimental VM driver, or an operator-managed out-of-tree compute driver. - -Use `openshell` first to identify the active endpoint. Then use the platform tools that match the gateway's compute driver: `docker`, `podman`, `kubectl`/`helm`, or VM driver logs. - -## Overview - -The target deployment flow is: - -1. Operator starts or deploys the gateway with system packages, systemd, Helm, or a development task. The CLI does not start, stop, or destroy gateway services. -2. Operator configures the compute driver. -3. Operator provides the CLI and supervisor authentication material required by the deployment mode: edge or OIDC user auth, optional CLI mTLS, and gateway-minted sandbox JWTs. -4. The CLI registers a reachable gateway endpoint with `openshell gateway add`. -5. The gateway creates sandboxes through the selected compute driver. - -For local evaluation only, TLS may be disabled and the gateway can be reached through `http://127.0.0.1:`. - -## Prerequisites - -- The `openshell` CLI must be available for endpoint checks. -- Know the active gateway name and endpoint, or be able to inspect local gateway metadata. -- Know the compute platform: Docker, Podman, Kubernetes, VM, or an out-of-tree driver. -- For Kubernetes: `kubectl` must target the cluster that hosts OpenShell and Helm version 3 or later must be available. -- For Docker or Podman: the runtime socket must be reachable from the gateway host. - -## Workflow - -Run diagnostics in order and stop once the root cause is clear. - -### Step 1: Check CLI Reachability - -```bash -openshell gateway list --output json -openshell gateway info -openshell status -``` - -For a one-off endpoint check that bypasses stored gateway selection and metadata: - -```bash -openshell --gateway-endpoint status -``` - -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 local mTLS bundle does not match the gateway CA, or TLS termination does not match the gateway listener. -- `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. -- 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. - -### Step 2: Identify the Compute Platform - -Use gateway metadata, deployment values, or the user's setup notes to identify the driver. - -| Platform | Primary checks | -|---|---| -| Docker | Gateway process logs, Docker daemon health, sandbox containers, image pulls. | -| Podman | Podman socket, rootless networking, sandbox containers, image pulls. | -| Kubernetes | Helm release, gateway workload, service, secrets, sandbox pods, events. | -| VM | VM driver logs, rootfs availability, host virtualization support. | -| Extension | External driver process, Unix socket ownership/mode, configured driver name, capability handshake, gateway logs. | - -### Step 3: Check Gateway Startup Dependencies - -Before debugging the compute platform, inspect gateway logs for failures in dependencies initialized before the listener becomes ready. - -For out-of-tree compute drivers, confirm the custom driver name and socket agree across CLI flags or `gateway.toml`, and that the operator-owned driver is running before the gateway starts: - -```bash -rg -n 'compute_drivers|socket_path' /etc/openshell/gateway.toml -stat /run/openshell/.sock -journalctl -u --no-pager --lines=200 -journalctl -u openshell-gateway --no-pager --lines=200 -``` - -The custom driver name must not be a reserved built-in name (`docker`, `podman`, `kubernetes`, or `vm`). The socket must be accessible only to the intended gateway identity. Check gateway logs for connection errors, `GetCapabilities` failures, or an unexpected advertised driver name. The gateway does not create or supervise out-of-tree driver processes or sockets. - -For configured gateway interceptors, inspect `[[openshell.gateway.interceptors]]`, their Unix or network endpoints, and gateway startup logs: - -```bash -rg -n 'interceptors|provider_profile_sources|grpc_endpoint|binding_policy|failure_policy' /etc/openshell/gateway.toml -stat /run/openshell/interceptors/.sock -journalctl -u --no-pager --lines=200 -journalctl -u openshell-gateway --no-pager --lines=200 -``` - -The gateway calls each interceptor's `Describe` RPC and validates its manifest at startup. Check for unreachable endpoints, invalid RPC/phase bindings, strict `allowlist` or `exact` mismatches, and `post_commit` bindings that resolve to `fail_closed`. If `provider_profile_sources` names an interceptor, that interceptor must advertise provider-profile capability and return a valid, duplicate-free catalog. A selected interceptor-only source is authoritative; include `builtin` or `user` sources explicitly when composition is intended. - -For operator-run supervisor middleware, inspect `[[openshell.supervisor.middleware]]`, service reachability, and both gateway and supervisor logs: - -```bash -rg -n 'supervisor|middleware|grpc_endpoint|max_body_bytes|timeout' /etc/openshell/gateway.toml -journalctl -u --no-pager --lines=200 -journalctl -u openshell-gateway --no-pager --lines=200 -openshell logs --tail --source sandbox -``` - -The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate `HttpRequest/pre_credentials` bindings, the registration claims the reserved `openshell/` namespace, or body and timeout limits are invalid. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. - -At request time, distinguish an explicit `middleware_denied` result from `middleware_failed`. A denial is always enforced. A failure follows the policy-local `on_error`: `fail_closed` blocks the request, while `fail_open` bypasses only that stage and emits a detection finding. If a running supervisor cannot install a new registry, it preserves its last-known-good generation and emits a configuration failure event. - -For network policy validation failures, first distinguish a gateway mutation -rejection from a supervisor runtime rejection. Direct policy updates, -incremental merges and approvals, provider attachments, and provider-profile -fanout are validated against the complete effective policy before persistence -when the gateway knows the affected sandbox scope. A `FAILED_PRECONDITION` -ambiguity response means no invalid revision or partial fanout was stored. -Supervisor validation remains defense in depth for startup, races, and policy -sources outside those mutation paths. - -Runtime rejection behavior is configured only in `gateway.toml`: - -```toml -[openshell.gateway] -policy_validation_failure_mode = "fail_closed" -``` - -The default `fail_closed` mode deactivates the previous generation, closes -pinned relays, and quarantines new egress until a valid generation loads. -`retain_last_valid` explicitly keeps the previous valid policy active; without -one it still fails closed. Restart the gateway after changing this field. -Inspect sandbox OCSF configuration and finding events for the validation -rationale, configured and effective modes, active generation, and the explicit -`previous_policy_active` state. - -### Step 4: Check Docker-Backed Gateways - -```bash -docker info -docker ps --filter name=openshell -docker logs --tail=200 -docker run --rm --entrypoint /openshell-sandbox "${OPENSHELL_DOCKER_SUPERVISOR_IMAGE:-ghcr.io/nvidia/openshell/supervisor:latest}" --version -openshell status -``` - -For Docker GPU failures, check CDI support and NVIDIA CDI discovery separately: - -```bash -docker info --format '{{json .CDISpecDirs}}' -docker info --format '{{json .DiscoveredDevices}}' -for dir in /etc/cdi /var/run/cdi; do - if [ -d "$dir" ]; then - find "$dir" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.json' \) -print - else - echo "$dir missing" - fi -done -systemctl is-enabled nvidia-cdi-refresh.service nvidia-cdi-refresh.path || true -systemctl is-active nvidia-cdi-refresh.service nvidia-cdi-refresh.path || true -systemctl status nvidia-cdi-refresh.service nvidia-cdi-refresh.path --no-pager --lines=50 -journalctl -u nvidia-cdi-refresh.service --no-pager --lines=100 -``` - -When the NVIDIA Container Toolkit CDI refresh units are not enabled or no NVIDIA CDI spec has been generated, enable them and trigger a refresh: - -```bash -sudo systemctl enable --now nvidia-cdi-refresh.path -sudo systemctl enable --now nvidia-cdi-refresh.service -sudo systemctl restart nvidia-cdi-refresh.service -docker info --format '{{json .DiscoveredDevices}}' -``` - -Common findings: - -- Docker daemon unavailable: start Docker Desktop or Docker Engine. -- Gateway process stopped: inspect exit status and logs. -- Sandbox image missing or pull denied: verify image reference and registry credentials. -- Sandbox fails before readiness with an identity-resolution error: inspect the image's OCI `USER` and matching `/etc/passwd` and `/etc/group` entries, or explicitly set both process identity fields in policy. Root and missing identities are rejected. -- Docker driver cannot initialize because it cannot find `openshell-sandbox`: verify `OPENSHELL_DOCKER_SUPERVISOR_BIN`, the sibling binary next to `openshell-gateway`, or the configured supervisor image contains `/openshell-sandbox`. -- Sandbox never registers: check gateway logs and supervisor callback endpoint. -- Supervisor image exits before printing `openshell-sandbox --version`: the image should be the scratch supervisor image from `deploy/docker/Dockerfile.supervisor` and must contain a static executable at `/openshell-sandbox`. -- `mise run e2e:docker:gpu` fails with `docker info --format json did not report any discovered NVIDIA CDI GPU devices`: Docker may report `CDISpecDirs` while still having no generated NVIDIA CDI specs. Verify `.DiscoveredDevices` contains entries such as `nvidia.com/gpu=all`, verify `/etc/cdi` or `/var/run/cdi` contains a generated NVIDIA spec, and check that `nvidia-cdi-refresh.service` and `nvidia-cdi-refresh.path` from NVIDIA Container Toolkit are enabled and healthy. The service is a one-shot unit, so `inactive (dead)` can be normal after a successful run; use `systemctl status` and `journalctl` to distinguish success from a skipped or failed refresh. NVIDIA recommends enabling the path and service units, and restarting `nvidia-cdi-refresh.service` to regenerate missing or stale CDI specs. If specs are generated but Docker still reports no discovered devices, restart Docker or reload the daemon and re-check `docker info`. - -For source checkout development, restart the local gateway with: - -```bash -mise run gateway:docker -``` - -### Step 5: Check Podman-Backed Gateways - -```bash -podman info -podman ps --filter name=openshell -podman logs --tail=200 -openshell status -``` - -Common findings: - -- Podman socket unavailable: start or expose the user socket. -- Rootless networking unavailable: inspect Podman network configuration. -- Sandbox image missing or pull denied: verify image reference and registry credentials. -- Sandbox fails before readiness with an identity-resolution error: inspect the image's OCI `USER` and matching `/etc/passwd` and `/etc/group` entries, or explicitly set both process identity fields in policy. Root and missing identities are rejected. -- Supervisor cannot call back: check callback endpoint and gateway logs. -- Gateway exits before becoming healthy with a callback-listener discovery - error: inspect `podman info --debug`, the configured Podman network, and the - host's IPv4 default route. Rootless pasta uses the private source address - selected by that route; rootful Podman uses the bridge gateway address. -- Callback discovery reports that the requested address equals the primary - listener: configure a distinct primary address. For Podman Machine, keep the - IPv4 loopback callback separate by using an IPv6-loopback primary such as - `[::1]:17670`. -- Rootless slirp4netns, another named helper, or missing helper metadata - requires an explicitly remote `grpc_endpoint`. An explicit `host_gateway_ip` - cannot bypass slirp4netns host-loopback isolation. Do not work around - discovery failures by broadening the primary gateway listener to `0.0.0.0`. - -### Step 6: Check Kubernetes Helm Gateways - -```bash -helm -n openshell status openshell -helm -n openshell get values openshell -kubectl -n openshell get deployment,statefulset,pod,svc,pvc -kubectl -n openshell logs deployment/openshell -c openshell-gateway --tail=200 -kubectl -n openshell logs statefulset/openshell -c openshell-gateway --tail=200 -kubectl -n openshell rollout status deployment/openshell -kubectl -n openshell rollout status statefulset/openshell -``` - -Use the log and rollout commands for the workload kind that exists in the -release. Look for failed installs, unexpected values, missing namespace, wrong -image tag, TLS settings that do not match the registered endpoint, and -scheduling failures. - -For HA or PostgreSQL-backed installs, also check the external database Secret -referenced by `server.externalDbSecret` and the PostgreSQL workload if the test -or operator deployed one in-cluster: - -```bash -kubectl -n openshell get secret openshell-ha-pg -o yaml -kubectl -n openshell get deployment,service,pod -l app.kubernetes.io/name=openshell-e2e-postgres -kubectl -n openshell logs deployment/openshell-e2e-postgres --tail=200 -``` - -Check required Helm deployment secrets: - -```bash -kubectl -n openshell get secret \ - openshell-server-tls \ - openshell-server-client-ca \ - openshell-client-tls \ - openshell-jwt-keys -``` - -In cert-manager installs, `certManager.enabled=true` makes cert-manager own TLS -generation. The Helm chart should still render the `openshell-certgen` -pre-install/pre-upgrade hook in JWT-only mode to create `openshell-jwt-keys`, -even if `pkiInitJob.enabled` remains true. -If the gateway pod is pending with `MountVolume.SetUp failed for volume -"sandbox-jwt"` and `openshell-jwt-keys` is absent, inspect the rendered -`templates/certgen.yaml` output and the hook Job logs; cert-manager creates TLS -Secrets but does not create the sandbox JWT signing Secret. - -If the gateway exits with `failed to read sandbox JWT signing key from -/etc/openshell-jwt/signing.pem`, verify that `openshell-jwt-keys` contains -`signing.pem`, `public.pem`, and `kid`, and that the gateway workload mounts the -`sandbox-jwt` secret at `/etc/openshell-jwt`. The sandbox JWT mount is required -even when local Helm values disable TLS. - -If `server.providerTokenGrants.spiffe.enabled=true`, the gateway should still -render `[openshell.gateway.gateway_jwt]` and mount the `sandbox-jwt` Secret. -SPIRE is used only by sandbox pods for dynamic provider token grants. Verify -that SPIRE is installed, the CSI driver is available, and the Kubernetes driver -config includes `provider_spiffe_workload_api_socket_path`: - -```bash -helm -n openshell get values openshell | grep -E 'providerTokenGrants|workloadApiSocketPath' -kubectl get pods -A | grep -E 'spire|spiffe' -kubectl -n openshell get configmap openshell-config -o yaml | grep provider_spiffe_workload_api_socket_path -``` - -Sandbox pods using provider token grants should have an -`openshell.io/sandbox-id` annotation, an `openshell.ai/managed-by=openshell` -label, supervisor env vars `OPENSHELL_K8S_SA_TOKEN_FILE` and -`OPENSHELL_PROVIDER_SPIFFE_WORKLOAD_API_SOCKET`, plus both the projected -`openshell-sa-token` volume and the `spiffe-workload-api` CSI volume. - -Check the image references currently used by the gateway deployment: - -```bash -kubectl -n openshell get deployment openshell -o jsonpath="{.spec.template.spec.containers[*].image}{\"\n\"}{.spec.template.spec.containers[*].env[?(@.name==\"OPENSHELL_SUPERVISOR_IMAGE\")].value}{\"\n\"}" -kubectl -n openshell get statefulset openshell -o jsonpath="{.spec.template.spec.containers[*].image}{\"\n\"}{.spec.template.spec.containers[*].env[?(@.name==\"OPENSHELL_SUPERVISOR_IMAGE\")].value}{\"\n\"}" -helm -n openshell get values openshell | grep -E 'repository|tag|supervisorImage|workload' -``` - -The gateway image built from `deploy/docker/Dockerfile.gateway` and the scratch supervisor image built from `deploy/docker/Dockerfile.supervisor` should use the same build tag in branch and E2E deploys. A stale supervisor image can make sandbox behavior lag behind gateway policy or proto changes. - -For local/external pull mode (the default local path via `mise run cluster`), local images are tagged to the configured local registry base, pushed to that registry, and pulled by k3s via the `registries.yaml` mirror endpoint. The `cluster` task pushes prebuilt local tags (`openshell/*:dev`, falling back to `localhost:5000/openshell/*:dev` or `127.0.0.1:5000/openshell/*:dev`). - -Gateway image builds stage a partial Rust workspace from `deploy/docker/Dockerfile.images`. If cargo fails with a missing manifest under `/build/crates/...`, or an imported symbol exists locally but is missing in the image build, verify that every current gateway dependency crate, including `openshell-driver-docker`, `openshell-driver-kubernetes`, and `openshell-ocsf`, is copied into the staged workspace there. - -For plaintext local evaluation, confirm the chart has: - -```bash -helm -n openshell get values openshell | grep -E 'disableTls|grpcEndpoint' -``` - -Expected shape: - -```yaml -server: - disableTls: true - grpcEndpoint: http://openshell.openshell.svc.cluster.local:8080 -``` - -Check service exposure: - -```bash -kubectl -n openshell get svc openshell -o wide -kubectl -n openshell get endpoints openshell -``` - -For local port-forward testing: - -```bash -kubectl -n openshell port-forward svc/openshell 8080:8080 -openshell gateway add http://127.0.0.1:8080 --local --name local -openshell status -``` - -If the gateway is healthy but sandbox creation fails: - -```bash -kubectl -n openshell get pods -kubectl -n openshell get events --sort-by=.lastTimestamp | tail -n 50 -kubectl -n openshell logs deployment/openshell -c openshell-gateway --tail=200 -kubectl -n openshell logs statefulset/openshell -c openshell-gateway --tail=200 -``` - -Check the configured sandbox namespace: - -```bash -helm -n openshell get values openshell | grep sandboxNamespace -``` - -Then inspect sandbox resources in that namespace. - -Check the configured sandbox service account when TokenReview bootstrap or -sandbox registration fails. Helm creates a dedicated sandbox service account by -default and writes it to `[openshell.drivers.kubernetes].service_account_name`; -the gateway rejects projected tokens from other service accounts. - -```bash -helm -n openshell get values openshell | grep -A3 sandboxServiceAccount -kubectl -n get serviceaccount openshell-sandbox -kubectl -n openshell get configmap openshell-config -o jsonpath='{.data.gateway\.toml}' -kubectl -n get sandbox -o jsonpath='{.spec.template.spec.serviceAccountName}{"\n"}' -``` - -If `topology = "sidecar"` is rendered under `[openshell.drivers.kubernetes]`, -sandbox pods should have an `openshell-network-init` init container running -`--mode=network-init`, an `agent` container running -`openshell-sandbox --mode=process`, and an `openshell-supervisor-network` -container running `--mode=network`. The init container owns nftables setup and -should be the only sidecar topology container with `NET_ADMIN`. It also needs -`CHOWN`/`FOWNER` to hand shared emptyDir state to the effective sidecar UID. The -default binary-aware network sidecar runs as UID 0 with primary GID -`sandbox_gid` and adds `SYS_PTRACE` plus `DAC_READ_SEARCH`. When -`process_binary_aware_network_policy = false`, it runs as the configured -non-root `proxy_uid` without those inspection capabilities. The pod `fsGroup` -is set to `sandbox_gid` in both modes. - -In sidecar topology only the network sidecar should mount the gateway bootstrap -credentials (`openshell-sa-token` and `openshell-client-tls`). The process -container should not receive `OPENSHELL_ENDPOINT`, gateway TLS env vars, the -sandbox token file, or those credential mounts. Instead, the network sidecar -serves policy and provider environment state over the Unix control socket from -`OPENSHELL_SIDECAR_CONTROL_SOCKET` (`/run/openshell-sidecar/control.sock` by -default). The process supervisor must be the first and only client. After -validating its peer UID, GID, and PID, the sidecar unlinks the listener. If the -connection later closes, the network sidecar exits non-zero so Kubernetes can -restart it with a fresh listener. If the process supervisor fails before -launching the workload, -inspect both containers for control-socket bind, connect, bootstrap, or update -errors. If new SSH/exec sessions do not pick up refreshed provider environment, -inspect the network sidecar settings-poll logs and the process container logs -for provider environment update handling; the process container should consume -newer provider-env revisions without receiving gateway credentials. - -The process container reports the workload entrypoint PID over the same control -socket, and the network sidecar uses that PID for binary-scoped policy -decisions through `/proc`. If rules with `policy.binaries` are unexpectedly -denied, inspect the sidecar control logs and confirm the pod has -`shareProcessNamespace: true`. -The shared state directory should preserve `sandbox_gid` inheritance -(`02775`). Sidecar SSH uses the Linux abstract socket -`@openshell-sidecar-ssh`; the network sidecar verifies its peer PID before -bridging gateway relay requests. No `ssh.sock` file should appear in the shared -state directory. -Inspect all three when sandbox registration or egress enforcement fails: - -```bash -kubectl -n openshell get configmap openshell-config -o jsonpath='{.data.gateway\.toml}' | grep -E '^\[openshell\.drivers\.kubernetes\]|^topology\s*=' -kubectl -n get pod -o jsonpath='{range .spec.initContainers[*]}{.name}{" "}{.command}{"\n"}{end}' -kubectl -n get pod -o jsonpath='{range .spec.containers[*]}{.name}{" "}{.command}{"\n"}{end}' -kubectl -n logs -c openshell-network-init --tail=200 -kubectl -n logs -c openshell-supervisor-network --tail=200 -kubectl -n logs -c agent --tail=200 -``` - -### Step 7: Check VM-Backed Gateways - -Use the VM driver logs and host diagnostics available in the user's environment. Verify: - -- The VM driver process is running and reachable by the gateway. -- The runtime rootfs exists and matches the expected architecture. -- Host virtualization support is enabled. -- The sandbox supervisor can establish its callback connection to the gateway. - -Then run: - -```bash -openshell status -openshell logs -``` - -## Common Failure Patterns - -| Symptom | Likely cause | Check | -|---|---|---| -| `openshell status` fails | Gateway endpoint unreachable or auth mismatch | `openshell gateway info`, gateway logs | -| Gateway starts but sandbox create fails | Compute driver cannot reach runtime | Docker/Podman/Kubernetes/VM driver logs | -| Gateway exits while resolving compute-driver listener requirements | Callback alias topology is unsupported, the Podman network cannot be inspected, or the selected address is not private/authorized | Gateway startup error, `podman info --debug`, Podman network inspection, host IPv4 default route | -| Admin, health, reflection, or HTTP request is denied on a Docker/Podman callback address | Negotiated callback listeners intentionally expose only sandbox-callable gRPC methods | Retry through the gateway's primary endpoint; inspect the listener-purpose startup log if the address was unexpected | -| Docker or Podman sandbox never registers | Wrong callback endpoint or supervisor startup failure | Gateway logs and sandbox container logs | -| Docker GPU e2e fails before GPU sandbox comparison | NVIDIA CDI specs are missing or Docker has not discovered them | `docker info --format '{{json .DiscoveredDevices}}'`, `/etc/cdi`, `/var/run/cdi`, `nvidia-cdi-refresh.service` | -| Kubernetes gateway pod pending | PVC unbound, taint, selector, or insufficient resources | `kubectl -n openshell describe pod ` | -| Kubernetes sandbox pod stuck pending, workspace PVC unbound | Cluster has no default `StorageClass` and OpenShell does not set `storageClassName` on the workspace PVC (clusters with a default `StorageClass` bind fine without it) | `kubectl -n openshell describe pvc`; set `server.workspaceStorageClass` (gateway config `workspace_storage_class`) to a valid `StorageClass` | -| Kubernetes gateway pod crash loops | Missing secret, bad DB URL, bad TLS config | `kubectl -n openshell logs deployment/openshell -c openshell-gateway` or `kubectl -n openshell logs statefulset/openshell -c openshell-gateway` | -| CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways//mtls/` | -| Edge or OIDC gateway returns `Unauthenticated` | Stored login expired, audience/scopes mismatch, or gateway auth configuration changed | `openshell gateway info`, `openshell gateway login `, gateway auth logs | -| Gateway fails before serving health after enabling an interceptor | Interceptor endpoint unavailable or manifest/binding validation failed | Gateway and interceptor logs; interceptor socket; `binding_policy`, phases, and failure policy | -| Provider profiles disappear after enabling an interceptor catalog | `provider_profile_sources` selected only an authoritative interceptor or returned invalid/duplicate IDs | Inspect source list and interceptor `Describe`/catalog logs; include `builtin` and `user` when intended | -| Gateway fails after registering supervisor middleware | Service unavailable, invalid manifest, duplicate binding, reserved name, or invalid body/timeout limit | Middleware service and gateway logs; `[[openshell.supervisor.middleware]]`; `Describe` response | -| Policy update rejects `network_middlewares` | Unknown middleware name, implementation-owned config invalid, duplicate order, broad/invalid host selector, or fail-closed coverage of `tls: skip` | Policy error, gateway logs, middleware `ValidateConfig`, selector and order fields | -| Policy mutation returns `FAILED_PRECONDITION` for endpoint ambiguity | Equally specific effective endpoint selectors disagree on connection or request-processing metadata | CLI error, base and provider-composed policy, affected profile attachments; confirm no new revision was stored | -| Supervisor enters policy quarantine | A runtime candidate failed validation while `policy_validation_failure_mode = "fail_closed"` | Sandbox OCSF config/finding events, validation rationale, active generation, `previous_policy_active` | -| HTTP request returns `middleware_failed` or `middleware_denied` | Selected stage failed or explicitly denied the admitted request | Sandbox OCSF logs; policy-local middleware config; service availability; `on_error` | -| Custom compute driver is unavailable | Driver process/socket missing, inaccessible, or configured with a reserved/mismatched name | Socket ownership/mode, driver service logs, gateway `GetCapabilities` logs | -| Image pull failure | Gateway or sandbox image cannot be pulled | Runtime events and image pull credentials | -| `K8s namespace not ready` with `envoy-gateway-openshell.yaml: the server could not find the requested resource` | Optional Gateway API manifest was applied without Envoy Gateway CRDs, or k3s Helm controller startup exceeded the namespace wait | Apply `deploy/kube/manifests/envoy-gateway-openshell.yaml` manually only after Envoy Gateway is installed and `grpcRoute` is enabled | -| HTTPS ingress (`grpcRoute.gateway.listener.protocol=HTTPS`) connection resets or TLS handshake hangs | Envoy terminates TLS but the gateway pod still expects TLS, so the plaintext backend hop fails | Set `server.disableTls=true` so Envoy forwards plaintext to the pod; verify the listener `certificateRefs` Secret exists in the release namespace and `openshell status` over `https://` | -| HTTPS ingress returns `Unauthenticated` after connecting | TLS terminates at Envoy, so the gateway never sees a client cert; no OIDC issuer is configured for identity | Configure `server.oidc.issuer` and register with `openshell gateway add https:// --oidc-issuer `, or set `server.auth.allowUnauthenticatedUsers=true` for a trusted-proxy/dev cluster | - -## Reporting - -When handing results back to the user, include: - -- Active gateway endpoint and auth mode. -- Compute platform and driver. -- Gateway process or workload status. -- Recent gateway log summary. -- Missing or malformed TLS, OIDC/mTLS, or sandbox JWT material. -- Service exposure status. -- Sandbox workload status. -- The exact command that failed and the shortest fix. diff --git a/.agents/skills/fix-security-issue/SKILL.md b/.agents/skills/fix-security-issue/SKILL.md index 4e6610c8a1..1452cbe66c 100644 --- a/.agents/skills/fix-security-issue/SKILL.md +++ b/.agents/skills/fix-security-issue/SKILL.md @@ -1,6 +1,8 @@ --- name: fix-security-issue description: Implement a fix for a reviewed security issue. Takes a directly requested issue number or scans for issues labeled `topic:security` and `agent:implementation-requested`. Reads the security review from the issue comments and implements the remediation plan. Trigger keywords - fix security issue, remediate security, implement security fix, patch vulnerability. +metadata: + internal: true --- # Fix Security Issue @@ -11,7 +13,7 @@ Implement a code fix for a security issue that has already been reviewed by the - The `gh` CLI must be authenticated (`gh auth status`) - You must be in a git repository with a GitHub remote -- The issue must have `topic:security`. In unattended scan mode it must also have `agent:implementation-requested`; a direct user request to fix a specific issue does not require that label. +- The issue must have `topic:security`. In unattended scan mode it must also have `agent:implementation-requested`; for a direct user request, warn if that workflow label is missing and continue without changing it. - The issue must have a prior security review comment (posted by `review-security-issue`) with a **Legitimate concern** determination and a remediation plan ## Agent Comment Marker @@ -30,7 +32,7 @@ The user may provide an issue number directly, or ask the agent to find issues t ### If an issue number is provided -Strip any leading `#` and proceed to Step 2 with that issue ID. The user's explicit fix request authorizes implementation; do not refuse solely because `agent:implementation-requested` is absent. +Strip any leading `#` and proceed to Step 2 with that issue ID. The user's explicit fix request authorizes implementation. If `agent:implementation-requested` is absent, warn that the expected workflow label is missing and continue without changing it. ### If no issue number is provided @@ -61,7 +63,7 @@ Check the issue's `labels` array from the response above: If `topic:security` is missing, report that this skill only handles security issues and stop. If queue mode selected an issue without `agent:implementation-requested`, report that it is not ready for unattended pickup and stop. -Never apply `agent:implementation-requested` yourself. Its absence does not block a direct user request to fix a specific issue. +Never apply `agent:implementation-requested` yourself. In direct mode, warn about its absence and continue; the missing label does not block the user's request to fix the specific issue. ### Validate the security review @@ -307,7 +309,8 @@ User says: "Fix security issue #55" 1. Fetch issue #55 metadata 2. Labels are `["topic:security"]` -- missing `agent:implementation-requested` 3. Confirm that a legitimate security review and remediation plan exist -4. Proceed because the user's direct request authorizes implementation +4. Warn that `agent:implementation-requested` is missing from the expected workflow state +5. Proceed because the user's direct request authorizes implementation; leave the labels unchanged ### Issue without a review diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index adb40a9575..780cf8b9c6 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -1,6 +1,8 @@ --- name: helm-dev-environment description: Start up, tear down, and configure the local Kubernetes development environment for OpenShell. Uses k3d (Docker-backed k3s) + Skaffold + Helm. Covers cluster lifecycle, optional add-ons (Keycloak OIDC, Envoy Gateway), HA testing, and port mappings. Trigger keywords - local k8s, local cluster, k3d, skaffold, helm dev, start cluster, stop cluster, tear down cluster, delete cluster, create cluster, helm:k3s, helm:skaffold, local dev environment, dev cluster, k8s dev, envoy gateway local, keycloak local, high availability, HA. +metadata: + internal: true --- # Helm Dev Environment @@ -26,10 +28,14 @@ mise run helm:k3s:create ``` Creates a k3d cluster and merges its kubeconfig into the worktree-local `kubeconfig` file. +When the named cluster already exists, the task starts any stopped containers and refreshes +same-named kubeconfig entries so a recreated load balancer's current API port takes effect. Also applies the upstream agent-sandbox CRDs/controller (pinned via `AGENT_SANDBOX_VERSION` in `tasks/scripts/helm-k3s-local.sh`, fetched from `github.com/kubernetes-sigs/agent-sandbox` -releases) and preloads the default community sandbox image into k3d so the first sandbox -create does not wait on a large registry pull. Traefik is disabled at cluster creation time. +releases), enables its OTLP tracing on v0.5 and later, installs an OTLP trace +collector and UI in the `observability` namespace, +and preloads the default community sandbox image into k3d so the first sandbox create +does not wait on a large registry pull. Traefik is disabled at cluster creation time. **Multi-worktree support:** the cluster name is derived from the last component of the current git branch (e.g. branch `kube-support/local-dev/tmutch` → cluster @@ -47,6 +53,9 @@ Override with env vars before running `helm:k3s:create`: - `HELM_K3S_LB_HOST_PORT` (default: `8080`) - `HELM_K3S_PRELOAD_SANDBOX_IMAGE` (default: `ghcr.io/nvidia/openshell-community/sandboxes/base:latest`; set to an empty value to skip) +- `HELM_K3S_COLLECTOR_IMAGE` (default: + `mcr.microsoft.com/dotnet/aspire-dashboard:latest`) +- `HELM_K3S_COLLECTOR_HEALTH_TIMEOUT` (default: `120` seconds) ### 2. Deploy OpenShell @@ -74,13 +83,48 @@ Both commands build the `gateway` and `supervisor` images and deploy the OpenShe chart. The sidecar profile renders an `openshell-network-init` init container for nftables setup and an `openshell-supervisor-network` runtime sidecar for proxying. Binary-aware policy mode runs that sidecar as UID 0 with `SYS_PTRACE` and -`DAC_READ_SEARCH`; relaxed mode can run it as the configured proxy UID. The +`DAC_READ_SEARCH`; relaxed mode can run it as the configured proxy UID, which +must be at least `1000` and distinct from the workload UID. The sidecar-mTLS profile reuses `ci/values-sidecar.yaml` and restores `server.disableTls=false` inline for Skaffold. The `pkiInitJob` hook (a pre-install Job that runs `openshell-gateway generate-certs`) generates mTLS secrets on first -install. Envoy Gateway opt-in; see the Optional Add-ons section below. +install. The default Skaffold values export gateway and Kubernetes-driver traces to +the collector service installed by `helm:k3s:create`. Envoy Gateway opt-in; see the +Optional Add-ons section below. -The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or `kubectl port-forward`. +The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or +the unified local forwarding task: + +```bash +mise run helm:k3s:forward +``` + +The task forwards OTLP/gRPC to `http://127.0.0.1:4317` and the trace UI to +`http://127.0.0.1:18888`. When Skaffold has deployed a Kubernetes gateway, it +also forwards the gateway to `http://127.0.0.1:8090`; otherwise it continues +with the collector ports only. A successful plaintext `helm:skaffold:run` or +`helm:skaffold:run:sidecar` registers the gateway under the worktree-specific +k3d cluster name and selects it as the active gateway. Keep the forwarding +task running while using those endpoints. + +### Viewing local traces + +The gateway exports OTLP/gRPC to +`http://openshell-collector.observability.svc.cluster.local:4317` through the +default Skaffold values. Forward OTLP/gRPC and the trace UI to the host: + +```bash +mise run helm:k3s:forward +``` + +Open `http://127.0.0.1:18888` and exercise the gateway to inspect gateway and +Kubernetes compute-driver spans under their distinct service names, along with +Agent Sandbox controller reconciliation spans linked through the Sandbox +trace-context annotation. The same command exposes OTLP/gRPC on +`http://127.0.0.1:4317` and, when deployed, the Kubernetes gateway on +`http://127.0.0.1:8090`. The local `gateway:docker`, `gateway:podman`, and +`gateway:vm` tasks detect the collector listener at startup and enable trace +export only while it is reachable. **HA test deploy** (two gateway replicas + external PostgreSQL Secret): uncomment `#- ci/values-high-availability.yaml` in `deploy/helm/openshell/skaffold.yaml`, @@ -98,19 +142,20 @@ plaintext by default. To test sidecar topology with TLS enabled, use | Skaffold dev (default) | `true` | `http://` | | TLS enabled | `false` (or omitted) | `https://` | -### Connecting via port-forward +### Connecting through the forwarding task -Port `8080` is already bound by the k3d load balancer when Envoy Gateway is active, so -the port-forward uses local port `8090` to avoid a collision: +Port `8080` is already bound by the k3d load balancer when Envoy Gateway is +active, so the forwarding task uses local port `8090` for the gateway. In a +second terminal, confirm that the gateway is registered and active: ```bash -KUBECONFIG=kubeconfig kubectl port-forward -n openshell svc/openshell 8090:8080 +openshell gateway list ``` **Plaintext (default Skaffold deploy):** ```bash -openshell sandbox list --gateway-endpoint http://localhost:8090 +openshell sandbox list ``` **With mTLS enabled** — extract the client cert the PKI hook wrote to the cluster, @@ -216,11 +261,13 @@ annotations to `spiffe://openshell.local/openshell/sandbox/`. OpenShell mounts the SPIFFE CSI Workload API socket at `/spiffe-workload-api/spire-agent.sock` into sandbox pods for provider token grants. Supervisor-to-gateway authentication remains on the Kubernetes -ServiceAccount bootstrap and gateway-minted sandbox JWT path. +ServiceAccount bootstrap and gateway-minted sandbox JWT path; the selected +Kubernetes compute driver validates the projected token before the gateway +mints its JWT. --- -## Cluster Lifecycle (suspend/resume) +## Cluster Lifecycle (stop/start) Stop the cluster without losing state (faster than delete/recreate): ```bash diff --git a/.agents/skills/launch-openshell-gator/SKILL.md b/.agents/skills/launch-openshell-gator/SKILL.md index b1f70948ef..fb9ada09b0 100644 --- a/.agents/skills/launch-openshell-gator/SKILL.md +++ b/.agents/skills/launch-openshell-gator/SKILL.md @@ -1,6 +1,8 @@ --- name: launch-openshell-gator description: Launch and supervise OpenShell gator agents. Use when starting gator on issues or PRs, checking gator sandboxes, building the gator sandbox image, restarting stuck gators, inspecting gator logs, or experimenting with gator harness/model overrides. Trigger keywords - launch gator, start gator, run gator, gator sandbox, supervised gator, gator logs, restart gator. +metadata: + internal: true --- # Launch OpenShell Gator @@ -27,7 +29,8 @@ For gator's PR/issue validation policy, load `gator-gate` inside the launched sa | `scripts/agents/gator/Dockerfile` | Gator sandbox image source. Local launches build this image through OpenShell. | | `scripts/agents/gator/policy.yaml` | Sandbox policy for the gator agent. | | `scripts/agents/gator/bin/gh` | Gator-specific `gh` wrapper and same-SHA duplicate-post guard. | -| `scripts/agents/gator/bin/review-feedback-ledger` | Builds tree-aware review scope, durable findings, convergence telemetry, and checkpoint state. | +| `scripts/agents/gator/bin/review-feedback-ledger` | Builds tree-aware review scope, durable findings, convergence telemetry, and review-budget state. | +| `scripts/agents/gator/bin/resolve-gator-review-threads` | Resolves addressed Gator-owned inline review threads by ledger finding ID. | | `scripts/agents/gator/bin/validate-review-findings` | Enforces the blocker evidence schema and downgrades unsupported hypotheses. | | `scripts/agents/gator/prompts/gator.md` | Rendered top-level prompt template baked into the payload. | | `scripts/agents/gator/skills/gator-gate/SKILL.md` | In-sandbox gator state-machine skill. | @@ -157,7 +160,7 @@ sandbox_name="gator-pr-${pr_number}-supervised" "Review and monitor PR #${pr_number} through the gator-gate workflow. Scope this invocation only to PR #${pr_number}." ``` -The launcher builds the gator sandbox image when needed, stages the immutable payload, imports provider profiles, configures provider credentials and refresh, creates the sandbox, and writes a background log under `scripts/agents/gator/logs/`. +The launcher builds the gator sandbox image when needed, stages the immutable payload, imports provider profiles, configures provider credentials and refresh, creates and uploads the sandbox payload, then starts the agent supervisor with `sandbox exec`. It writes a background log under `scripts/agents/gator/logs/`. ### Launch An Issue Or Issue/PR Pair @@ -217,7 +220,7 @@ sandbox_name="gator-pr-${pr_number}-supervised" --name "$sandbox_name" \ --watch \ --background \ - "Review and monitor PR #${pr_number} through the gator-gate workflow. Scope this invocation only to PR #${pr_number}. The operator explicitly authorizes applying the test:e2e label and posting /ok to test for the current head SHA if gator determines that is required." + "Review and monitor PR #${pr_number} through the gator-gate workflow. Scope this invocation only to PR #${pr_number}. The operator explicitly authorizes applying the test:e2e label, posting /ok to test for the current head SHA, and rerunning the relevant current-head workflow when the E2E Label Help bot says that is required." ``` ## Model Or Image Experiments @@ -317,10 +320,10 @@ Restart when the payload must change, the sandbox is wedged without a sentinel, Increment `payload_version` in `scripts/agents/gator/agent.yaml` whenever a merged change alters the Gator prompt, gate skill, reviewer contract, write -guard, ledger, or bundled validator. Existing immutable watchers cannot replace -their own payload. New-version watchers detect later published versions and -stop with `stale_gator_payload`; relaunch every still-active older watcher after -the version bump is published. +guard, ledger, thread resolver, or bundled validator. Existing immutable +watchers cannot replace their own payload. New-version watchers detect later +published versions and stop with `stale_gator_payload`; relaunch every +still-active older watcher after the version bump is published. Before deleting, check that the sandbox is truly stale or that the operator asked for a restart. If a bounded review cycle is actively running and still producing useful output, prefer leaving it alone. @@ -368,7 +371,9 @@ Symptoms: host `gh` auth fails, Codex refresh fails, in-sandbox GitHub calls rep Actions: - Re-run the GitHub and Codex preflight checks. +- Existing refresh-managed providers are reused without an ordinary credential update; the launcher rotates their gateway-managed credential instead. - If host Codex auth changed, relaunch with `--reset-refresh` once. +- `--reset-refresh` removes the old refresh ownership before rediscovering host credentials, then configures and rotates the replacement refresh state. - If Entra or Microsoft auth is involved in a future provider, use the relevant auth skill. Gator's default providers are GitHub and Codex. ### Unsupported `gh pr view --json` Field @@ -384,6 +389,9 @@ The wrapper intentionally blocks duplicate same-head-SHA gator dispositions. A r - The earlier attempt failed before posting. - The prior marked disposition was only a reviewer infrastructure failure. - The prior marked disposition was only a draft blocker and the PR is now ready for review. +- A state-specific TTL nudge is due after 48 business hours. The nudge may request + the pending human action, but it must not repeat the review disposition or + trigger another reviewer run. Do not bypass with `OPENSHELL_GATOR_ALLOW_SAME_SHA_COMMENT=1` unless the operator explicitly confirms a maintainer override. diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md deleted file mode 100644 index a94afe2292..0000000000 --- a/.agents/skills/openshell-cli/cli-reference.md +++ /dev/null @@ -1,578 +0,0 @@ -# OpenShell CLI Reference - -Quick-reference for the `openshell` command-line interface. For workflow guidance, see [SKILL.md](SKILL.md). - -> **Self-teaching**: If a command or flag is not listed here, use `openshell --help` to discover it. The CLI has comprehensive built-in help at every level. - -## Global Options - -| Flag | Description | -|------|-------------| -| `-v`, `--verbose` | Increase verbosity (`-v` = info, `-vv` = debug, `-vvv` = trace) | -| `-g`, `--gateway ` | Gateway to operate on. Also settable via `OPENSHELL_GATEWAY` env var. Falls back to active gateway in `~/.config/openshell/active_gateway`. | -| `--gateway-endpoint ` | Connect directly to a gateway endpoint without looking up stored metadata. Also settable via `OPENSHELL_GATEWAY_ENDPOINT`. | -| `--gateway-insecure` | Skip TLS certificate verification. Also settable via `OPENSHELL_GATEWAY_INSECURE`; use only for trusted development endpoints. | - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `OPENSHELL_GATEWAY` | Override active gateway name (same as `--gateway`) | -| `OPENSHELL_GATEWAY_ENDPOINT` | Connect directly to a gateway endpoint (same as `--gateway-endpoint`) | -| `OPENSHELL_GATEWAY_INSECURE` | Skip TLS verification when set (same as `--gateway-insecure`) | -| `OPENSHELL_SANDBOX_POLICY` | Path to default sandbox policy YAML (fallback when `--policy` is not provided) | -| `OPENSHELL_COMMUNITY_REGISTRY` | Override the community sandbox image registry prefix used by `sandbox create --from ` | -| `OPENSHELL_THEME` | TUI theme: `auto`, `dark`, or `light` | - ---- - -## Complete Command Tree - -``` -openshell -├── gateway -│ ├── add [opts] -│ ├── login [name] -│ ├── logout [name] -│ ├── remove [name] -│ ├── info [--name] -│ ├── list -│ └── select [name] -├── status -├── whoami [--output ] -├── inference -│ ├── set --provider --model -│ ├── update [--provider] [--model] -│ └── get -├── sandbox -│ ├── create [opts] [-- CMD...] -│ ├── get [name] -│ ├── list [opts] -│ ├── delete [name]... [--all] -│ ├── exec [--name ] [opts] -- CMD... -│ ├── connect [name] [--editor ] -│ ├── upload [dest] -│ ├── download [dest] -│ ├── ssh-config [name] -│ └── provider -│ ├── list [name] -│ ├── attach -│ └── detach -├── forward -│ ├── start [name] [-d] -│ ├── stop [name] -│ ├── list -│ └── service [name] --target-port [opts] -├── service -│ ├── expose [service] -│ ├── list [sandbox] -│ ├── get [service] -│ └── delete [service] -├── logs [name] [opts] -├── policy -│ ├── set [name] --policy [--global] [--wait] -│ ├── update [name] [opts] -│ ├── get [name] [--full|--base] [--global] -│ ├── list [name] [--global] -│ ├── delete --global -│ └── prove --policy --credentials [opts] -├── settings -│ ├── get [name] [--global] -│ ├── set [name] --key --value [--global] -│ └── delete [name] --key [--global] -├── rule (advanced; hidden from top-level help) -│ ├── get [name] [--status ] -│ ├── approve [name] --chunk-id -│ ├── reject [name] --chunk-id [--reason ] -│ ├── approve-all [name] [--include-security-flagged] -│ ├── clear [name] -│ └── history [name] -├── provider -│ ├── create --name --type [opts] -│ ├── refresh -│ │ ├── status [opts] -│ │ ├── configure [opts] -│ │ ├── rotate --credential-key -│ │ └── delete --credential-key -│ ├── get -│ ├── list [opts] -│ ├── list-profiles [opts] -│ ├── profile -│ │ ├── export [opts] -│ │ ├── import (--file |--from ) -│ │ ├── update --file -│ │ ├── lint (--file |--from ) -│ │ └── delete -│ ├── update [opts] -│ └── delete ... -├── doctor -│ └── check -├── term -├── completions -└── ssh-proxy [opts] -``` - ---- - -## Gateway Commands - -### `openshell gateway add ` - -Register an existing gateway endpoint. - -| Flag | Description | -|------|-------------| -| `--name ` | Gateway name | -| `--local` | Register a local mTLS gateway; with HTTP, store a local plaintext registration | -| `--remote ` | Register a remote mTLS gateway over SSH; with HTTP, store a remote plaintext registration | -| `--oidc-issuer ` | Register an OIDC-authenticated gateway | -| `--oidc-client-id ` | OIDC client ID (default: `openshell-cli`; requires `--oidc-issuer`) | -| `--oidc-audience ` | OIDC API audience (requires `--oidc-issuer`) | -| `--oidc-scopes ` | Space-separated OAuth2 scopes (requires `--oidc-issuer`) | - -Examples: - -- `openshell gateway add http://127.0.0.1:8080 --local --name local` -- `openshell gateway add https://gateway.example.com --name production` -- `openshell gateway add ssh://user@gateway.example.com:8080 --name remote` - -An `http://` endpoint is direct plaintext. A plain `https://` endpoint uses edge authentication. `--local` and `--remote` select mTLS registration modes when used with HTTPS; required certificates must already exist. An `ssh://` endpoint is shorthand for a remote gateway. - -### `openshell gateway remove [name]` - -Remove a local gateway registration. This removes CLI metadata and stored auth tokens only; package managers, systemd, Helm, Docker, and other platform tools still own the gateway process. - -### `openshell gateway login [name]` - -Refresh browser-based authentication for an edge-authenticated or OIDC gateway. - -### `openshell gateway logout [name]` - -Clear locally stored OIDC or edge credentials for a gateway. - -### `openshell gateway info` - -Show gateway details: endpoint, auth mode, and remote host metadata when present. - -| Flag | Description | -|------|-------------| -| `--name ` | Gateway name (defaults to active) | - -### `openshell gateway select [name]` - -Set the active gateway. Writes to `~/.config/openshell/active_gateway`. Without a name, opens an interactive chooser on a TTY or lists gateways in non-interactive mode. - -### `openshell gateway list` - -List registered gateways and mark the active one. `--output table|yaml|json` selects the format. - ---- - -## Doctor Commands - -### `openshell doctor check` - -Validate local Docker prerequisites for standalone gateway development. For -package-managed or Helm gateways, use `systemctl`, `journalctl`, `kubectl`, and -`helm` directly. - ---- - -## Status Command - -### `openshell status` - -Show server connectivity, authentication status, and version for the active -gateway. Connectivity uses the public health RPC; authentication is checked -with the protected gateway-info capability query and can fail while the gateway -remains connected. - -### `openshell whoami` - -Show the authenticated user identity: subject, display name, roles, scopes, and -identity provider. Requires an authenticated gateway connection. - -| Flag | Description | -|------|-------------| -| `--output ` | Output format: `table` (default), `json`, or `yaml` | - ---- - -## Sandbox Commands - -### `openshell sandbox create [OPTIONS] [-- COMMAND...]` - -Create a sandbox through the selected gateway, wait for readiness, then connect, open an editor, or execute the trailing command. - -| Flag | Description | -|------|-------------| -| `--name ` | Sandbox name (auto-generated if omitted) | -| `--from ` | Community name, Dockerfile path, directory, or image reference (BYOC) | -| `--no-keep` | Delete the sandbox after the initial command or shell exits | -| `--editor vscode|cursor` | Launch a remote editor and keep the sandbox alive | -| `--gpu [COUNT]` | Request the driver's default GPU selection or a specific count | -| `--cpu ` | CPU limit (for example: `500m`, `1`, `2.5`) | -| `--memory ` | Memory limit (for example: `512Mi`, `4Gi`, `8G`) | -| `--driver-config-json ` | Experimental driver-keyed configuration object | -| `--provider ` | Provider to attach (repeatable) | -| `--policy ` | Custom policy YAML; overrides the built-in default and `OPENSHELL_SANDBOX_POLICY` | -| `--forward <[BIND:]PORT>` | Start a local port forward and keep the sandbox alive | -| `--tty`, `--no-tty` | Force or disable pseudo-terminal allocation | -| `--auto-providers` | Auto-create missing providers from local credentials | -| `--no-auto-providers` | Never auto-create providers; error if a required provider is missing | -| `--label ` | Attach a label (repeatable) | -| `--env ` | Inject an environment variable (repeatable) | -| `--approval-mode manual|auto` | Handle agent-authored policy proposals; default: `manual` | -| `--upload [:]` | Upload local files to the working directory or an explicit destination (repeatable) | -| `--no-git-ignore` | Disable `.gitignore` filtering for `--upload` | -| `[-- COMMAND...]` | Initial command (defaults to an interactive shell) | - -### `openshell sandbox get [name]` - -Show sandbox details and the active policy. Metadata identifies sandbox or global policy source and the corresponding revision. The name defaults to the last-used sandbox. - -| Flag | Description | -|------|-------------| -| `--policy-only` | Print only the active policy YAML to stdout | - -### `openshell sandbox list` - -| Flag | Default | Description | -|------|---------|-------------| -| `--limit ` | 100 | Maximum sandboxes | -| `--offset ` | 0 | Pagination offset | -| `--ids` | false | Print only sandbox IDs | -| `--names` | false | Print only sandbox names | -| `--selector ` | none | Filter by `key1=value1,key2=value2` | -| `--output table|yaml|json` | `table` | Output format | - -### `openshell sandbox delete [NAME]...` - -Delete one or more named sandboxes, or use `--all`. Deletion stops background port forwards. - -### `openshell sandbox exec [OPTIONS] -- COMMAND...` - -Execute a command through the gRPC exec endpoint, stream its output, and exit with the remote command's exit code. - -| Flag | Default | Description | -|------|---------|-------------| -| `-n`, `--name ` | last-used | Sandbox name | -| `--workdir ` | none | Working directory in the sandbox | -| `--timeout ` | 0 | Command timeout; `0` disables it | -| `--tty`, `--no-tty` | auto | Force or disable a pseudo-terminal | -| `--env ` | none | Command environment variable (repeatable) | - -### `openshell sandbox connect [name]` - -Open an interactive SSH shell. The name defaults to the last-used sandbox. `--editor vscode|cursor` launches a supported remote editor instead. - -### `openshell sandbox upload [dest]` - -Upload files using tar-over-SSH. The destination defaults to the container working directory. `.gitignore` filtering is enabled unless `--no-git-ignore` is passed. - -### `openshell sandbox download [dest]` - -Download files using tar-over-SSH. The local destination defaults to `.`. - -### `openshell sandbox ssh-config [name]` - -Print an SSH config `Host` block. The name defaults to the last-used sandbox. - -### `openshell sandbox provider` - -Manage providers on an existing sandbox: - -- `openshell sandbox provider list [name]` -- `openshell sandbox provider attach ` -- `openshell sandbox provider detach ` - ---- - -## Port Forwarding Commands - -### `openshell forward start [name]` - -Start forwarding a local port to a sandbox. - -| Flag | Description | -|------|-------------| -| `` | `[bind_address:]port`; the port is used locally and remotely | -| `[name]` | Sandbox name (defaults to last-used) | -| `-d`, `--background` | Run in background | - -### `openshell forward stop [name]` - -Stop a background port forward. When the sandbox name is omitted, it is inferred from active forwards. - -### `openshell forward list` - -List all active port forwards (sandbox, port, PID, status). - -### `openshell forward service [name] --target-port ` - -Forward a local TCP port to a loopback service inside a sandbox over the gRPC relay. - -| Flag | Default | Description | -|------|---------|-------------| -| `--target-port ` | required | Service port inside the sandbox | -| `--target-host ` | `127.0.0.1` | Loopback service host | -| `--local <[BIND:]PORT>` | target port | Local bind; port `0` requests dynamic assignment | - ---- - -## Service Commands - -Gateway-managed HTTP service endpoints: - -- `openshell service expose [service]` -- `openshell service list [sandbox] [--limit N] [--offset N]` -- `openshell service get [service]` -- `openshell service delete [service]` - ---- - -## Logs Command - -### `openshell logs [name]` - -View sandbox logs. Supports one-shot and streaming. - -| Flag | Default | Description | -|------|---------|-------------| -| `-n ` | 200 | Number of log lines | -| `--tail` | false | Stream live logs | -| `--since ` | none | Only show logs from this duration ago (e.g., `5m`, `1h`) | -| `--source ` | `all` | Filter: `gateway`, `sandbox`, or `all` (repeatable) | -| `--level ` | none | Minimum level: `error`, `warn`, `info`, `debug`, `trace` | - -The sandbox name defaults to the last-used sandbox. - ---- - -## Policy Commands - -### `openshell policy update [name]` - -Incrementally merge live network policy changes into the current sandbox policy. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision. - -| Flag | Default | Description | -|------|---------|-------------| -| `--add-endpoint ` | repeatable | `host:port[:access[:protocol[:enforcement[:options]]]]`. Adds or merges an endpoint. | -| `--remove-endpoint ` | repeatable | `host:port`. Removes the endpoint or just the requested port from a multi-port endpoint. | -| `--add-allow ` | repeatable | `host:port:METHOD:path_glob`. Adds REST or WebSocket allow rules. | -| `--add-deny ` | repeatable | `host:port:METHOD:path_glob`. Adds REST or WebSocket deny rules. | -| `--remove-rule ` | repeatable | Deletes a named network rule. | -| `--binary ` | repeatable | Adds binaries to each `--add-endpoint` rule. Valid only with `--add-endpoint`. | -| `--rule-name ` | none | Overrides the generated rule name. Valid only when exactly one `--add-endpoint` is provided. | -| `--dry-run` | false | Preview the merged policy locally without sending an update to the gateway. | -| `--wait` | false | Wait for the sandbox to confirm the new policy revision is loaded. | -| `--timeout ` | 60 | Timeout for `--wait`. | - -Notes: - -- The sandbox name defaults to the last-used sandbox. -- `--add-allow` and `--add-deny` operate on REST and WebSocket endpoints. Use full YAML for JSON-RPC, MCP, SQL, or other policy structure. -- `--wait` cannot be combined with `--dry-run`. -- Use `policy set` when replacing the full policy or changing static sections. - -### `openshell policy set [name] --policy ` - -Replace the full policy on a live sandbox. Only the dynamic `network_policies` field can be changed at runtime. - -| Flag | Default | Description | -|------|---------|-------------| -| `--policy ` | -- | Path to policy YAML (required) | -| `--global` | false | Apply as the gateway-global policy | -| `--yes` | false | Skip confirmation for a global update | -| `--wait` | false | Wait for sandbox to confirm policy is loaded | -| `--timeout ` | 60 | Timeout for `--wait` | - -Exit codes with `--wait`: 0 = loaded, 1 = failed, 124 = timeout. - -### `openshell policy get [name]` - -Show the current effective sandbox policy or stored global policy. - -| Flag | Default | Description | -|------|---------|-------------| -| `--rev ` | 0 | Show a stored revision; `0` shows the current effective policy | -| `--full` | false | Include the effective policy payload and provider-composed entries | -| `--base` | false | Include the base policy payload without provider-composed entries | -| `--output table|json` | `table` | Output format | -| `--global` | false | Show the global policy revision | - -### `openshell policy list [name]` - -List policy revision history (version, hash, status, created, error). - -| Flag | Default | Description | -|------|---------|-------------| -| `--limit ` | 20 | Max revisions to return | -| `--global` | false | List global policy revisions | - -### `openshell policy delete --global` - -Delete the global policy lock and restore sandbox-level policy control. `--yes` skips confirmation. - -### `openshell policy prove` - -Prove policy properties or find counterexamples. - -| Flag | Description | -|------|-------------| -| `--policy ` | Policy YAML (required) | -| `--credentials ` | Credential descriptor YAML (required) | -| `--registry ` | Capability registry directory (defaults to bundled) | -| `--accepted-risks ` | Accepted-risks YAML | -| `--compact` | One-line-per-finding output | - -### `openshell rule` (advanced) - -Review agent-authored network rule proposals. This command group is intentionally hidden from top-level help but is part of the policy-advisor workflow. - -- `openshell rule get [name] [--status pending|approved|rejected]` -- `openshell rule approve [name] --chunk-id ` -- `openshell rule reject [name] --chunk-id [--reason ]` -- `openshell rule approve-all [name] [--include-security-flagged]` -- `openshell rule clear [name]` -- `openshell rule history [name]` - -Sandbox names default to the last-used sandbox. Bulk approval of security-flagged proposals requires explicit `--include-security-flagged`. - ---- - -## Settings Commands - -Settings support sandbox and gateway-global scopes: - -- `openshell settings get [name] [--global] [--json]` -- `openshell settings set [name] --key --value [--global] [--yes]` -- `openshell settings delete [name] --key [--global] [--yes]` - -Sandbox names default to the last-used sandbox. Global mutations prompt unless `--yes` is passed. - ---- - -## Provider Commands - -Provider types are defined by built-in and custom provider profiles. Use `openshell provider list-profiles` to discover the selected gateway's current inventory. - -### `openshell provider create --name --type ` - -Create a provider configuration. - -| Flag | Description | -|------|-------------| -| `--name ` | Provider name (required) | -| `--type ` | Provider type (required) | -| `--from-existing` | Load credentials and config from local state | -| `--credential KEY[=VALUE]` | Credential pair. Bare `KEY` reads from env var. Repeatable. | -| `--from-gcloud-adc` | Load a compatible credential from gcloud Application Default Credentials | -| `--runtime-credentials` | Resolve required credentials at runtime in the gateway or sandbox | -| `--config KEY=VALUE` | Config key/value pair. Repeatable. | - -Exactly one credential source is required. Credential-source flags conflict with one another. - -### `openshell provider get ` - -Show provider details (id, name, type, credential keys, config keys). - -### `openshell provider list` - -List providers in a table. - -| Flag | Default | Description | -|------|---------|-------------| -| `--limit ` | 100 | Max providers | -| `--offset ` | 0 | Pagination offset | -| `--names` | false | Print only names | -| `--output table|yaml|json` | `table` | Output format | - -### `openshell provider update ` - -Update an existing provider without changing its type. - -| Flag | Description | -|------|-------------| -| `--from-existing` | Rediscover local credentials and config | -| `--credential KEY[=VALUE]` | Update a credential (repeatable) | -| `--config KEY=VALUE` | Update config (repeatable) | -| `--credential-expires-at KEY=TIMESTAMP` | Set or clear credential expiry; accepts epoch milliseconds or RFC3339, and `0` clears | - -### `openshell provider delete ...` - -Delete one or more providers by name. - -### Provider profiles - -- `openshell provider list-profiles [--output table|yaml|json]` -- `openshell provider profile export [--output table|yaml|json]` -- `openshell provider profile import (--file |--from )` -- `openshell provider profile update --file ` -- `openshell provider profile lint (--file |--from )` -- `openshell provider profile delete ` - -### Provider credential refresh - -- `openshell provider refresh status [--credential-key ]` -- `openshell provider refresh rotate --credential-key ` -- `openshell provider refresh delete --credential-key ` - -`provider refresh configure ` accepts: - -| Flag | Description | -|------|-------------| -| `--credential-key ` | Injectable credential key (required) | -| `--strategy ` | `oauth2-refresh-token`, `oauth2-client-credentials`, or `google-service-account-jwt` | -| `--material KEY=VALUE` | Non-secret refresh material (repeatable) | -| `--secret-material-env KEY[=ENVVAR]` | Secret refresh material read from the CLI environment (repeatable) | -| `--secret-material-key KEY` | Mark a supplied material key secret (repeatable) | -| `--credential-expires-at TIMESTAMP` | Current credential expiry in epoch milliseconds or RFC3339 | - ---- - -## Inference Commands - -### `openshell inference set` - -Configure the gateway's user-facing `inference.local` route or the platform-only system route. Provider and model are required. - -| Flag | Default | Description | -|------|---------|-------------| -| `--provider ` | -- | Provider record name (required) | -| `--model ` | -- | Model identifier to use for generation requests (required) | -| `--system` | false | Configure the system inference route | -| `--no-verify` | false | Skip endpoint verification before saving | -| `--timeout ` | 0 | Request timeout; `0` uses the 60-second default | - -### `openshell inference update` - -Partially update the selected inference route. - -| Flag | Default | Description | -|------|---------|-------------| -| `--provider ` | unchanged | Provider record name | -| `--model ` | unchanged | Model identifier | -| `--system` | false | Target the system inference route | -| `--no-verify` | false | Skip endpoint verification before saving | -| `--timeout ` | unchanged | Request timeout; `0` uses the 60-second default | - -### `openshell inference get` - -Show both inference routes. `--system` shows only the system route. - ---- - -## Other Commands - -### `openshell term` - -Launch the OpenShell interactive TUI. `--theme auto|dark|light` overrides `OPENSHELL_THEME`. - -### `openshell completions ` - -Generate shell completion scripts. Supported shells: `bash`, `fish`, `zsh`, `powershell`. - -### `openshell ssh-proxy` - -SSH proxy used as a `ProxyCommand`. Not typically invoked directly. diff --git a/.agents/skills/review-github-pr/SKILL.md b/.agents/skills/review-github-pr/SKILL.md index 1058bfb3e9..de56cb4770 100644 --- a/.agents/skills/review-github-pr/SKILL.md +++ b/.agents/skills/review-github-pr/SKILL.md @@ -1,6 +1,8 @@ --- name: review-github-pr description: Review a GitHub pull request by summarizing its diff and key design decisions. Use when the user wants to review a PR, understand changes in a branch, or get a code review summary. Trigger keywords - review PR, review pull request, summarize PR, summarize diff, code review, review branch, PR summary, diff summary. +metadata: + internal: true --- # Review GitHub Pull Request @@ -102,7 +104,9 @@ Read through the full diff (and the PR description if available). Produce a summ ### Potential Concerns <- omit if none -- +- **** — Before this PR, + experienced . With this PR, , so + . Details: `:`. ``` **Guidelines for the summary:** @@ -110,7 +114,28 @@ Read through the full diff (and the PR description if available). Produce a summ - **Overview**: State what changed and why. Pull context from the PR description if available. - **Key Design Decisions**: Focus on _why_ something was done a particular way, not _what_ changed. Include `file_path:line_number` references. Examples: choice of algorithm, new abstraction introduced, API contract change, migration strategy. - **Notable Code**: Include only the most instructive or surprising snippets. Keep each snippet under 15 lines. Always include the file path above the code block. -- **Potential Concerns**: Only include if there are genuine risks — missing error handling, breaking changes, performance implications, security issues. Do not fabricate concerns. +- **Potential Concerns**: Only include genuine risks that warrant a change or a + deliberate accept/reject decision. Describe each concern in terms of observable + behavior for the affected persona, such as a sandbox creator, sandbox user, + operator, administrator, SDK consumer, or developer maintaining the system. + Always compare the previous behavior with the new concerning behavior and state + the resulting user-visible impact. Prefer the compact form: "Before this PR, + `` experienced ``. With this PR, ``, so + ``." Add only the minimum file and line references needed to substantiate + the finding. + - Use the PR base as the normal previous-behavior baseline. Review older history + only when the change is fixing or extending an earlier feature and that history + is necessary to explain the behavioral contract. In that case, describe the + relevant transitions explicitly: "Before ``, ... After ``, ... + With this PR, ...". + - Translate internal failure modes and race conditions into what the affected + person would observe. Internal implementation details belong in the trailing + file and line references, not in place of the behavior description. + - Do not assign P0/P1/P2 or similar priority labels. The behavioral comparison + and impact should give maintainers enough context to accept or reject the + suggested change. + - Do not fabricate concerns or claim a behavioral regression without evidence + for both the prior and proposed behavior. - **Agent infrastructure**: When the PR changes behavior, commands, or development workflows, use the `sync-agent-infra` maintenance map to check that related skills were updated. When it adds, removes, or renames skills or crates; changes workflow relationships or skill coverage; modifies issue or PR templates; or changes agent cross-references, apply the full consistency checklist. Report missing companion updates or drift under **Potential Concerns**. ## Step 5: Output diff --git a/.agents/skills/review-security-issue/SKILL.md b/.agents/skills/review-security-issue/SKILL.md index b84e8b597a..efb054df80 100644 --- a/.agents/skills/review-security-issue/SKILL.md +++ b/.agents/skills/review-security-issue/SKILL.md @@ -1,6 +1,8 @@ --- name: review-security-issue description: Given a GitHub issue, review the issue for security implications. You'll make a determination if the claim in the issue is legitimate and should be addressed or will be a "won't fix." Trigger keywords - security issue, review security ticket, review security issue. +metadata: + internal: true --- # Review Security Issue @@ -11,7 +13,7 @@ Review an issue that outlines a security, vulnerability, or privacy concern. - The `gh` CLI must be authenticated (`gh auth status`) - You must be in a git repository with a GitHub remote -- The issue must have `topic:security`. In unattended queue mode it must also have `agent:plan-requested`; a direct user request to review a specific issue does not require that label. +- The issue must have `topic:security`. In unattended queue mode it must also have `agent:plan-requested`; for a direct user request, warn if that workflow label is missing and continue without changing it. ## Agent Comment Marker @@ -44,7 +46,7 @@ First, check the issue's labels from the metadata fetched in Step 1. - **If the issue has `agent:implementation-requested`**, the issue has already been reviewed and a human authorized remediation. There is no review to perform. Suggest using `fix-security-issue` and stop. - **If `topic:security` is missing**, report that this specialized skill only reviews security issues and stop. - **If this is queue mode and `agent:plan-requested` is missing**, report that the issue is not ready for unattended pickup and stop. -- **If the user directly requested review of this issue**, proceed even when `agent:plan-requested` is absent. Never add or offer to add the human-only request label. +- **If the user directly requested review of this issue**, warn that `agent:plan-requested` is missing, then proceed without it. Never add or offer to add the human-only request label. Next, fetch existing comments on the issue: @@ -145,7 +147,7 @@ After posting a legitimate-concern review with a remediation plan, replace `agen gh issue edit --remove-label "agent:plan-requested" --add-label "agent:plan-ready" ``` -This signals that an unattended agent produced a remediation plan that awaits human review. For an unlabeled direct invocation, leave the `agent:*` labels unchanged. A later direct request can authorize remediation without `agent:implementation-requested`; unattended remediation still requires that label. For a not-actionable determination, remove `agent:plan-requested` if present, do not add another `agent:*` label, and report that a human should close the issue or record the risk decision. +This signals that an unattended agent produced a remediation plan that awaits human review. For an unlabeled direct invocation, leave the `agent:*` labels unchanged. A later direct request can authorize remediation without `agent:implementation-requested`; warn that the expected label is missing and continue, while unattended remediation still requires that label. For a not-actionable determination, remove `agent:plan-requested` if present, do not add another `agent:*` label, and report that a human should close the issue or record the risk decision. ## Step 6: Address Follow-up Comments diff --git a/.agents/skills/sbom/SKILL.md b/.agents/skills/sbom/SKILL.md index 5d62dde0ba..e5c3e48c20 100644 --- a/.agents/skills/sbom/SKILL.md +++ b/.agents/skills/sbom/SKILL.md @@ -1,6 +1,8 @@ --- name: sbom description: Generate and manage Software Bill of Materials (SBOMs) for the OpenShell project. Covers SBOM generation with Syft, license resolution via public registries, and CSV export for compliance review. Trigger keywords - SBOM, sbom, bill of materials, license audit, license resolution, generate sbom, sbom csv, dependency license, supply chain, license scan. +metadata: + internal: true --- # SBOM Generation and License Resolution @@ -9,15 +11,52 @@ Generate CycloneDX SBOMs, resolve missing licenses, and export to CSV for compli ## Overview -The OpenShell SBOM tooling produces CycloneDX JSON SBOMs using Syft, resolves missing or hash-based licenses by querying public registries (crates.io, npm, PyPI), and exports the results to CSV for stakeholder review. +The OpenShell SBOM tooling produces source-tree CycloneDX JSON SBOMs using Syft, resolves missing or hash-based licenses by querying public registries (crates.io, npm, PyPI), and exports the results to CSV for stakeholder review. SBOMs are **release artifacts only** -- they are generated on demand and not committed to the repository. Output lands in `deploy/sbom/output/` (gitignored). +Pushed gateway and supervisor images carry an SPDX SBOM and minimal SLSA provenance as OCI attestations. Branch E2E, Release Dev, and Release Tag image binaries embed cargo-auditable metadata, so their image SBOMs include linked Rust crates. + ## Prerequisites - `mise install` has been run (installs Syft and other tools) - The repository is checked out at the root +## Inspecting an Image SBOM + +BuildKit uses its default Syft scanner and attaches one SPDX document per platform. Read one without pulling the image: + +```bash +docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest \ + --format '{{ json (index .SBOM "linux/amd64").SPDX }}' +``` + +Validate the final attestation, requiring a Cargo package for an auditable image: + +```bash +tasks/scripts/verify-image-sbom.sh ghcr.io/nvidia/openshell/gateway:latest --require-cargo +``` + +## Inspecting an Auditable Image Binary + +Opt into auditable metadata when staging a local image binary: + +```bash +OPENSHELL_AUDITABLE=1 PREBUILT_ARCH=amd64 \ + tasks/scripts/stage-prebuilt-binaries.sh gateway +``` + +Scan the staged binary rather than the source tree: + +```bash +mise x -- syft \ + "file:deploy/docker/.build/prebuilt-binaries/amd64/openshell-gateway" \ + -o cyclonedx-json +``` + +This output is limited to packages Syft discovers from that binary. Use +`mise run sbom` for the broader source-tree license-compliance inventory. + ## Workflow 1: Full SBOM Generation (One Command) ```bash diff --git a/.agents/skills/sync-agent-infra/SKILL.md b/.agents/skills/sync-agent-infra/SKILL.md index e1d5b52c12..184ce71d5b 100644 --- a/.agents/skills/sync-agent-infra/SKILL.md +++ b/.agents/skills/sync-agent-infra/SKILL.md @@ -1,6 +1,8 @@ --- name: sync-agent-infra description: Detect and fix drift across agent-first infrastructure files. Ensures skill inventories, workflow chains, architecture tables, issue/PR templates, and cross-references stay consistent when skills, crates, or workflows change. Run after adding, removing, or renaming skills or components. Trigger keywords - sync agent infra, sync skills, update agent docs, check agent consistency, agent infra drift, sync contributing, sync agents. +metadata: + internal: true --- # Sync Agent Infrastructure @@ -11,14 +13,14 @@ Detect and fix drift across the agent-first infrastructure files. These files re |------|---------------| | `AGENTS.md` | Project identity, workflow chains, architecture overview, issue/PR conventions, skill maintenance pointer | | `CONTRIBUTING.md` | Skills table, workflow chains, "When to Open an Issue" guidance, skill references | -| `docs/resources/issue-lifecycle.mdx` | Human-facing issue states, roadmap decisions, and direct-versus-queued agent ownership | -| `README.md` | "Built With Agents" section, "Explore with your agent" skill references | +| `CONTRIBUTING.md` issue lifecycle section | Human-facing issue states, roadmap decisions, acceptance signals, and direct-versus-queued agent ownership | +| `README.md` | "Use OpenShell with Your Agent" and "Built With Agents" sections | | `.github/ISSUE_TEMPLATE/bug_report.yml` | Skill name references in diagnostic guidance | | `.github/ISSUE_TEMPLATE/feature_request.yml` | Skill name references in investigation guidance | | `.github/ISSUE_TEMPLATE/config.yml` | Contact link text referencing skills | | `.github/workflows/issue-triage.yml` | Comment text referencing skills | | `.agents/skills/triage-issue/SKILL.md` | Skill name references in gate check and diagnosis steps | -| `.agents/skills/openshell-cli/SKILL.md` | Companion skills table | +| `skills/*/SKILL.md` | Standalone user instructions and links to documentation, included files, and related skills | | `.agents/skills/create-github-pr/SKILL.md` | Pre-PR agent infrastructure check | | `.agents/skills/review-github-pr/SKILL.md` | Review-time agent infrastructure check | | `.agents/skills/build-from-issue/SKILL.md` | Label awareness and pre-commit agent infrastructure check | @@ -26,7 +28,7 @@ Detect and fix drift across the agent-first infrastructure files. These files re ## When to Run -- After adding, removing, or renaming a skill in `.agents/skills/` +- After adding, removing, renaming, or moving a skill in `skills/` or `.agents/skills/` - After adding, removing, or renaming a crate in `crates/` - After changing workflow chain relationships between skills - After changing which product or development areas a skill covers @@ -35,7 +37,7 @@ Detect and fix drift across the agent-first infrastructure files. These files re ## Skill Maintenance Map -Use this map when product behavior, commands, or development workflows change. It is a routing aid, not an exhaustive dependency list. Search `.agents/skills/` for the changed command, field, component, or workflow before concluding that no other skill needs an update. +Use this map when product behavior, commands, or development workflows change. It is a routing aid, not an exhaustive dependency list. Search both `skills/` and `.agents/skills/` for the changed command, field, component, or workflow before concluding that no other skill needs an update. | Change area | Skills to review | |---|---| @@ -53,7 +55,7 @@ Use this map when product behavior, commands, or development workflows change. I | PR template, review conventions, or vouch behavior | `create-github-pr`, `review-github-pr`, `build-from-issue` | | Security review or remediation workflow | `review-security-issue`, `fix-security-issue` | | RFC template, numbering, or lifecycle | `create-rfc` | -| Documentation structure, navigation, or doc-update workflow | `update-docs` | +| Documentation structure, navigation, or doc-update workflow | `update-docs-from-commits` | | Skills, crates, workflow chains, issue/PR templates, or agent cross-references | `sync-agent-infra` | ## Prerequisites @@ -66,13 +68,14 @@ Gather the source of truth for each category. ### Skills -List all skill directories: +List public and contributor skill directories separately: ```bash +ls -1 skills/ ls -1 .agents/skills/ ``` -This is the canonical skill list. Every other file must agree with it. +The directories are canonical by audience: `skills/` contains public, installable user/operator skills and `.agents/skills/` contains internal contributor workflows. Every other file must agree with both inventories. ### Crates @@ -88,7 +91,7 @@ The canonical workflow chains are defined in `AGENTS.md` under "## Workflow Chai ### Labels -The canonical label set is used by skills and templates. The key labels are: `state:triage-needed`, `state:needs-info`, `state:validated`, `state:accepted`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, `agent:pr-opened`, `roadmap`, `topic:security`, `good first issue`, `help wanted`, `spike`, and the relevant `area:*`, `topic:*`, `integration:*`, and `test:*` labels. The `agent:*` request labels control unattended queue pickup; they are not prerequisites when a user directly asks an agent to work on a specific issue. +The canonical label set is used by skills and templates. The key labels are: `state:triage-needed`, `state:needs-info`, `state:validated`, `state:accepted`, `agent:plan-requested`, `agent:plan-ready`, `agent:implementation-requested`, `agent:in-progress`, `agent:pr-opened`, `roadmap`, `topic:security`, `good first issue`, `help wanted`, `spike`, and the relevant `area:*`, `topic:*`, `integration:*`, and `test:*` labels. Lifecycle and `agent:*` request labels gate unattended queue pickup. They do not prevent a direct user request: the agent warns about each missing or incomplete expected workflow label and continues with the requested phase without changing those labels. ## Step 2: Check Each File for Drift @@ -96,31 +99,35 @@ For each file in the table above, check for the following inconsistencies: ### `CONTRIBUTING.md` -1. **Skills table** — Every skill in `.agents/skills/` must appear in the "Agent Skills for Contributors" table. No skill in the table should reference a directory that doesn't exist. -2. **Workflow chains** — Must match `AGENTS.md` workflow chains exactly. -3. **Skill references in prose** — Any skill mentioned by name in "Before You Open an Issue", "When to Open an Issue", or "When NOT to Open an Issue" must exist in `.agents/skills/`. +1. **Public skills table** — Every skill in `skills/` must appear in "Skills for Using OpenShell" and no contributor skill may appear there. +2. **Contributor skills table** — Every skill in `.agents/skills/` must appear in "Agent Skills for Contributors" and no public skill may appear there. +3. **Inventory paths** — No skill in either table should reference a directory that does not exist. +4. **Workflow chains** — Must match `AGENTS.md` workflow chains exactly. +5. **Skill references in prose** — Any named skill must exist in exactly one canonical skill directory. ### `AGENTS.md` 1. **Architecture overview** — Every crate in `crates/` must appear in the architecture table. The `python/`, `proto/`, `deploy/`, `.agents/` rows must also be present. -2. **Workflow chains** — Verify each skill named in a chain exists in `.agents/skills/`. -3. **Issue/PR conventions** — Verify referenced skills (`create-github-issue`, `create-github-pr`, `build-from-issue`) exist. -4. **Skill maintenance pointer** — Verify it still points to `sync-agent-infra` and does not duplicate the maintenance map from this skill. +2. **Skill layout** — The architecture table must contain separate `skills/` and `.agents/skills/` rows with accurate audience descriptions. +3. **Workflow chains** — Verify each skill named in a chain exists in exactly one of the two skill directories. +4. **Issue/PR conventions** — Verify referenced skills (`create-github-issue`, `create-github-pr`, `build-from-issue`) exist. +5. **Skill maintenance pointer** — Verify it still points to `sync-agent-infra` and does not duplicate the maintenance map from this skill. ### Issue Lifecycle Documentation -1. **`docs/resources/issue-lifecycle.mdx`** — State, roadmap, and agent-workflow meanings must match `AGENTS.md` and `CONTRIBUTING.md`. -2. **Invocation modes** — The `agent:*` request labels must control unattended queue pickup without being presented as prerequisites for a direct user request to a specific agent. +1. **`CONTRIBUTING.md` issue lifecycle section** — State, roadmap, acceptance-signal, and agent-workflow meanings must match `AGENTS.md`. +2. **Invocation modes** — Lifecycle and `agent:*` request labels must gate unattended queue pickup without blocking a direct user request to a specific agent. +3. **Direct-mode warnings** — Guidance must require the agent to warn about each missing or incomplete expected workflow label, continue with the requested phase, and leave labels unchanged. ### `README.md` -1. **"Explore with your agent"** — Skill names referenced must exist in `.agents/skills/`. -2. **"Built With Agents"** — Skill names referenced must exist. Workflow descriptions should be consistent with `AGENTS.md` chains. +1. **Public installation guidance** — The README must distinguish `skills/` from `.agents/skills/`, include `npx skills add NVIDIA/OpenShell`, and list only canonical public skills as installable. +2. **"Built With Agents"** — Contributor skill names must exist under `.agents/skills/`. Workflow descriptions should be consistent with `AGENTS.md` chains. ### Issue Templates -1. **`bug_report.yml`** — Skill names in the Agent Diagnostic guidance and checklist must exist. -2. **`feature_request.yml`** — Skill names in the Agent Investigation guidance must exist. +1. **`bug_report.yml`** — Must collect a User Story, Problem Statement, Impact / Why This Matters, Acceptance Criteria, Reproduction Steps, and Environment. Logs are optional and bug-specific; reporter diagnostics must not be required. +2. **`feature_request.yml`** — Must collect a User Story, Problem Statement, Impact / Why This Matters, Proposed Design, Acceptance Criteria, and Alternatives Considered. The design describes workflow and observable behavior without prescribing internal implementation; agent investigation is optional. 3. **`config.yml`** — Skill category descriptions in contact links should be accurate. ### Issue Triage Workflow @@ -130,12 +137,23 @@ For each file in the table above, check for the following inconsistencies: ### Skill Cross-References 1. **`triage-issue`** — Skills referenced in gate check and diagnosis steps must exist. -2. **`openshell-cli`** — Companion skills table entries must exist. -3. **`build-from-issue`** — Label names must match the project's label taxonomy, and request labels must gate unattended queue pickup without blocking direct user requests. +2. **`openshell-cli`** — Companion skills table entries must exist in one canonical location. +3. **`build-from-issue`** — Label names must match the project's label taxonomy. Lifecycle and request labels must gate unattended queue pickup, while direct requests warn on workflow discrepancies and continue. 4. **`create-spike`** — Reference to `build-from-issue` as next step must be accurate. 5. **`review-security-issue`** / **`fix-security-issue`** — Cross-references between the two must be accurate. 6. **PR creation and review checks** — The `create-github-pr`, `review-github-pr`, `build-from-issue`, and `principal-engineer-reviewer` references to `sync-agent-infra` must exist and use trigger conditions aligned with this skill. +### Skill Layout, Metadata, and Portability + +1. **Placement** — The four public skills (`openshell-cli`, `generate-sandbox-policy`, `debug-inference`, and `debug-openshell-cluster`) must live only in `skills/`. Every other repository skill must live only in `.agents/skills/`. +2. **Internal metadata** — Every `.agents/skills/*/SKILL.md` must set `metadata.internal: true`. Public skills must not set internal metadata. Treat this as a discovery filter, not an access-control boundary. +3. **Unique names** — Parse the `name` field from every `SKILL.md` under both roots. Every name must be globally unique and match the documented inventory. +4. **Local references** — Every relative Markdown link and referenced file in a skill must resolve within that installed skill directory unless the reference is an explicit published URL. +5. **Canonical paths** — Contributor skills that name the source location of a public skill must use `skills//...`, never `.agents/skills//...`. +6. **Public portability** — Public skills must not require repository-relative files under `docs/`, `architecture/`, `crates/`, `deploy/`, or `.agents/`; source builds; `mise`; or repository E2E workflows. Use installed `openshell --help` for command syntax and Markdown endpoints under `https://docs.nvidia.com/openshell/latest/` (URLs ending in `.md`) for product documentation. +7. **No canonical documentation copies** — Review public reference files and large command/schema blocks. Remove material that merely copies CLI help, policy schemas, architecture docs, or published operational documentation; retain only skill-specific reasoning and worked interactions. +8. **Discovery** — Run `npx -y skills add . --list` from a clean checkout or disposable copy. It must list exactly the four public skills. Remove any generated lock file or installed directory after the check. + ## Step 3: Report Drift If any inconsistencies are found, report them in a structured format: @@ -144,9 +162,12 @@ If any inconsistencies are found, report them in a structured format: ## Agent Infrastructure Drift Report ### Skills Inventory -- ADDED (exists in .agents/skills/ but missing from CONTRIBUTING.md): -- REMOVED (in CONTRIBUTING.md but missing from .agents/skills/): -- OK: skills consistent +- PUBLIC ADDED (exists in skills/ but missing from CONTRIBUTING.md): +- PUBLIC REMOVED (documented as public but missing from skills/): +- CONTRIBUTOR ADDED (exists in .agents/skills/ but missing from CONTRIBUTING.md): +- CONTRIBUTOR REMOVED (documented as contributor but missing from .agents/skills/): +- METADATA/PATH/NAME ERRORS: +- OK: public and contributor skills consistent ### Architecture Table - ADDED (exists in crates/ but missing from AGENTS.md): @@ -177,6 +198,7 @@ If drift is found, fix it by updating the affected files: 5. **Removed crate** — Remove the row from the AGENTS.md architecture table. 6. **Changed workflow chain** — Update chains in both `AGENTS.md` and `CONTRIBUTING.md`. Update the "Built With Agents" section in `README.md` if the change is user-visible. 7. **Changed skill coverage** — Update the skill maintenance map in this file and any affected cross-references or companion-skill tables. +8. **Audience or portability drift** — Move the skill to its canonical root, fix internal metadata, replace stale public-skill paths, repair local links, and replace copied product documentation with CLI self-discovery or published documentation links. After fixing, re-run Step 2 to verify consistency. diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index 4bf7d38ae3..2d0fdfe366 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -1,6 +1,8 @@ --- name: test-release-canary description: Manually dispatch and iterate on the Release Canary workflow that smoke-tests published OpenShell artifacts (install.sh on macOS/Ubuntu/Fedora, Helm chart on kind) after each Release Dev publish. Use when changing `.github/workflows/release-canary.yml`, validating a release before tagging, debugging a canary failure, or reproducing a canary job locally. Trigger keywords - release canary, release-canary, canary failed, canary dispatch, test release canary, post-release smoke, install.sh canary, helm chart canary, kind canary, dispatch canary. +metadata: + internal: true --- # Test Release Canary @@ -14,10 +16,21 @@ The Release Canary (`.github/workflows/release-canary.yml`) smoke-tests the arti | `macos` | `macos-latest-xlarge` | `install.sh` resolves the Homebrew formula, brew installs the cask, and `openshell status` reaches the brew-services–backed local gateway with the VM driver. | | `ubuntu` | `ubuntu-latest` | `install.sh` installs the Debian package, the post-install systemd user service starts, and `openshell status` reaches the local gateway with the Docker driver. | | `fedora` | `fedora:latest` container | `install.sh` installs the RPM packages, the local gateway starts under Podman, and `openshell status` succeeds. | +| `ubuntu-snap` | `ubuntu-latest` | Downloads the Snap artifact from Release Dev, installs it with `--dangerous`, connects the required interfaces, and waits up to 30 seconds for the recovered local gateway. | | `kubernetes` | `ubuntu-latest` + kind | `helm install oci://ghcr.io/nvidia/openshell/helm-chart --version 0.0.0-dev` succeeds in a kind cluster, the gateway pod becomes Ready, port-forward exposes 8080, and the released CLI registers the in-cluster gateway and runs `openshell status` against it. | +All canary jobs disable anonymous OpenShell telemetry. Host package jobs inject +`OPENSHELL_TELEMETRY_ENABLED=false` through the service environment, and the +Kubernetes job installs with `server.telemetryEnabled=false`, so smoke traffic +does not contribute to product usage metrics. + `install.sh` defaults to the *latest tagged* release — the canary is therefore checking that the most recent public release still installs, not the just-published `dev` build. The `kubernetes` job is the exception: it pins to `0.0.0-dev` chart + `:dev` images. +The canary does not install or import `@nvidia/openshell-sdk`. TypeScript SDK +validation lives in the `TypeScript SDK` branch check, including a publish +dry-run. The tagged release workflow publishes the package to GitHub Packages; +verify that job directly when diagnosing SDK publication failures. + ## Trigger paths The workflow has two triggers: @@ -31,7 +44,7 @@ on: ``` - **Automatic.** Every successful `Release Dev` run (on `main` or a manual dispatch of Release Dev) fires the canary. Each job gates on `github.event.workflow_run.conclusion == 'success'` so a failed Release Dev does not run the canary. -- **Manual.** `workflow_dispatch` lets you run the canary on demand against any branch's workflow definition. +- **Manual.** `workflow_dispatch` lets you run the canary on demand against any branch's workflow definition. To include `ubuntu-snap`, supply `release-dev-run-id` for a successful Release Dev run whose Snap artifact should be tested; without it, that job is skipped because no artifact is available. When dispatched manually, `github.event.workflow_run.head_sha` is empty and the workflow falls back to `github.sha` (the branch tip) for the `install.sh` URL. @@ -43,6 +56,13 @@ Run the canary as-is on the current branch: gh workflow run release-canary.yml --ref "$(git branch --show-current)" ``` +To exercise the Ubuntu Snap job, pass the successful Release Dev run ID: + +```shell +gh workflow run release-canary.yml --ref "$(git branch --show-current)" \ + -f release-dev-run-id= +``` + Watch the run that starts: ```shell @@ -79,10 +99,13 @@ The `kubernetes` job can be reproduced on any machine with Docker and `mise inst ```shell kind create cluster --name release-canary-local +bash e2e/support/install-agent-sandbox.sh + helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ --version 0.0.0-dev \ --namespace openshell --create-namespace \ --set server.disableTls=true \ + --set server.telemetryEnabled=false \ --wait --timeout 5m kubectl wait --namespace openshell \ @@ -109,6 +132,7 @@ Loopback registration auto-derives the gateway name to `openshell` if `--name` i |---|---|---| | `macos`/`ubuntu`/`fedora` job fails on `install.sh` | Latest tagged release missing an asset, checksum mismatch, or `install.sh` regression on this branch. | Job log around the `curl … install.sh \| sh` step. | | `macos`/`ubuntu`/`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_DRIVERS` env in the "Ensure …" step. | +| `ubuntu-snap` fails after interface connection | The gateway did not recover after Docker became available, or did not become reachable within the 30-second bound. | Failure diagnostics dump Snap service/connection/change state, gateway and snapd journals, Snap logs, and port 17670 listeners. | | `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`. | | `kubernetes` job fails on `openshell gateway add` or `status` | Port-forward not reachable, or CLI/gateway proto mismatch. | `port-forward.log` and `openshell gateway list` in the diagnostics dump. | diff --git a/.agents/skills/triage-issue/SKILL.md b/.agents/skills/triage-issue/SKILL.md index 5e5d503025..76ab01bdbe 100644 --- a/.agents/skills/triage-issue/SKILL.md +++ b/.agents/skills/triage-issue/SKILL.md @@ -1,6 +1,8 @@ --- name: triage-issue description: Assess, validate, and route community-filed issues for human disposition and roadmap placement. Takes a specific issue number or processes a confirmed batch of issues labeled state:triage-needed. Investigates reported behavior, separates objective findings from product decisions, and prepares validated issues for a human yes/no decision. Trigger keywords - triage issue, triage, assess issue, review incoming issue, triage issues. +metadata: + internal: true --- # Triage Issue @@ -25,9 +27,9 @@ Triage establishes technical validity; it does not decide whether valid work bel OpenShell has no `priority:*` labels. Sequencing comes from association with an item on the OpenShell Roadmap, and that association is a maintainer decision. -`state:validated` means the factual assessment is complete and awaits human disposition. A human declines by closing the issue as not planned with a rationale, or accepts by replacing `state:validated` with `state:accepted` and placing the issue on the roadmap as documented in `CONTRIBUTING.md`. Accepted work may remain human-owned. A maintainer can queue deeper agent investigation or planning with `agent:plan-requested`, or directly ask an agent to work on a specific issue. +`state:validated` means the factual assessment is complete and awaits human disposition. A human declines by closing the issue as not planned with a rationale, or accepts by applying `state:accepted`, placing the issue on the roadmap, or doing both as documented in `CONTRIBUTING.md`. Accepted work may remain human-owned. A maintainer can queue deeper agent investigation or planning with `agent:plan-requested`, or a user can directly ask an agent to work on a specific issue. -The optional `agent:*` workflow controls unattended queue pickup: `agent:plan-requested` queues planning, and `agent:implementation-requested` queues implementation after plan review. A direct user instruction separately authorizes the phase it requests and does not require either label. +The optional `agent:*` workflow controls unattended queue pickup: `agent:plan-requested` queues planning, and `agent:implementation-requested` queues implementation after plan review. A direct user instruction separately authorizes the phase it requests. The agent warns about missing or incomplete expected lifecycle and workflow labels, then continues without changing them. ## Agent Comment Marker @@ -98,18 +100,14 @@ Search the issue comments for the triage agent marker (`> **📋 triage-agent**` - **If the marker is found** and no subsequent human comments exist with new information or questions, report that the issue has already been triaged and stop. - **If the marker is found** but there are newer human comments with additional information, proceed to Step 3 to re-evaluate with the new context. -- **If a human already declined the issue or applied `state:accepted`**, do not undo or reinterpret that decision. +- **If a human already declined the issue, applied `state:accepted`, or placed it on the roadmap**, do not undo or reinterpret that decision. - **If the marker is not found**, proceed to Step 3. -## Step 3: Validate the Agent-First Gate +## Step 3: Check Report Completeness -Check whether the issue body contains a substantive agent diagnostic section. Treat this as evidence quality, not as a reason to skip obvious safety or routing actions. Look for: +Check for a substantive User Story, Problem Statement, Impact / Why This Matters, and Acceptance Criteria. The impact should explain the consequences of the current behavior and any insufficient workaround. For bug reports, also identify the reproduction steps and relevant environment. For feature requests, review the Proposed Design and Alternatives Considered. Reporter-supplied diagnostics and agent output are optional and must not be used as an intake gate. -- An "Agent Diagnostic" heading or section (from the bug report template) -- Evidence that the reporter used agent skills (skill names mentioned, diagnostic output pasted) -- Concrete investigation output (not just placeholder text or "N/A") - -If the diagnostic is missing, continue when the report already contains enough concrete evidence to assess safely. Otherwise classify it as `needs-information`, request the exact missing evidence, remove `state:triage-needed`, and add `state:needs-info`. +If the report contains enough context to understand and assess the need, continue. If a required section lacks material information, classify it as `needs-information`, request only the exact missing information, remove `state:triage-needed`, and add `state:needs-info`. - If a public issue may disclose a security vulnerability, do not repeat or expand sensitive details. Classify it as `security-report` and direct the operator to `SECURITY.md`. - Route usage questions and support requests to the documented support venue. @@ -121,7 +119,7 @@ Proceed to Step 4 for reports requiring technical validation. Before deeper diagnosis, determine whether the report may already be fixed in a newer release. -1. Extract the reported OpenShell version from the issue body, Agent Diagnostic, environment section, logs, and comments. If no version is provided, record that as missing context and continue. +1. Extract the reported OpenShell version from the issue body, environment section, logs, and comments. If no version is provided, record that as missing context and continue. 2. Check current release information and known fixes when available: - `gh release list --limit 10` - `gh release view ` @@ -140,24 +138,26 @@ Assess the report by investigating the codebase. Use the `principal-engineer-rev ``` Prompt the sub-agent with: - The full issue title and body -- The reporter's agent diagnostic output - Instructions to evaluate with a skeptical lens: - 1. Is this report describing a real problem or user error? - 2. Can the described behavior be reproduced from the information given? - 3. Does the reporter's agent diagnostic match what you see in the codebase? - 4. If this is a bug, what component is affected? - 5. If this is a feature request, is it technically coherent and feasible? Do not decide whether the project should accept it. - 6. Are there any open or closed issues that duplicate this? - 7. What uncertainty remains, and what exact evidence would resolve it? + 1. What persona and desired capability does the user story establish? + 2. Does the problem statement match current product behavior? + 3. Does the impact explain the consequences, current workaround, and why that workaround is insufficient? + 4. Are the acceptance criteria specific, observable, and consistent with the user story? + 5. Can the described workflow be reproduced or otherwise validated from the information given? + 6. Does the current product support the requested outcome, and what component owns the behavior? + 7. Is the report best classified as a bug, feature request, support request, or another category? + 8. If this is a feature request, is the proposed design technically coherent and feasible? Do not decide whether the project should accept it. + 9. Are there any open or closed issues that duplicate this? + 10. What uncertainty remains, and what exact evidence would resolve it? ``` Based on the sub-agent's analysis, also attempt to validate the report directly: - For bug reports: check the relevant code paths, look for the described failure mode - For feature requests: assess feasibility against the existing architecture -- For gateway deployment or infrastructure issues: reference the `debug-openshell-cluster` skill's known failure patterns -- For inference and provider-topology issues: reference the `debug-inference` skill's known failure patterns -- For CLI/usage issues: reference the `openshell-cli` skill's command reference +- For gateway deployment or infrastructure issues: reference the known failure patterns in `skills/debug-openshell-cluster/SKILL.md` +- For inference and provider-topology issues: reference `skills/debug-inference/SKILL.md` +- For CLI/usage issues: reference the workflows in `skills/openshell-cli/SKILL.md` and confirm installed syntax with `openshell --help` Record impact signals for the human decision: affected users and scope, regression status, workaround availability, severity evidence, and evidence quality. Do not convert those facts into a roadmap or sequencing recommendation. @@ -205,20 +205,21 @@ Post a structured comment with the triage marker: > - **Evidence quality:** > > ### Human Decision Required -> Decide whether OpenShell should address this issue. If yes, replace -> `state:validated` with `state:accepted`, associate it with a roadmap -> item, and decide whether the work remains human-owned. +> Decide whether OpenShell should address this issue. If yes, apply +> `state:accepted`, associate it with a roadmap item, or do both, and decide +> whether the work remains human-owned. Either action records acceptance; +> roadmap placement additionally records sequencing. > To queue investigation or planning for an unattended agent, also apply > `agent:plan-requested`. You can instead directly ask an agent to use -> `create-spike` or `build-from-issue` on this issue. If no, close it as not -> planned and record the rationale. -> Roadmap association is independent sequencing metadata. +> `create-spike` or `build-from-issue` on this issue; the agent will warn about +> missing expected workflow labels and continue without changing them. If no, +> close it as not planned and record the rationale. + ``` For other outcomes, replace the impact and decision sections with the exact information request, objective resolution, or safe routing guidance. Keep exactly one intake/triage state among `state:triage-needed`, `state:needs-info`, and `state:validated`. Remove `state:triage-needed` after every completed assessment. Never apply `state:accepted`, any `agent:*` label, or the `roadmap` label during triage. Never close a validated issue. - ## Relationship to Other Skills ``` @@ -230,7 +231,7 @@ Community issue filed | state:validated | - human decline OR state:accepted + roadmap placement + human decline OR state:accepted / roadmap placement | create-spike (if deeper investigation is approved) | diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md index 7f11db26ff..5abc534f77 100644 --- a/.agents/skills/tui-development/SKILL.md +++ b/.agents/skills/tui-development/SKILL.md @@ -1,6 +1,8 @@ --- name: tui-development description: Guide for developing the OpenShell TUI — a ratatui-based terminal UI for the OpenShell platform. Covers architecture, navigation, data fetching, theming, UX conventions, and development workflow. Trigger keywords - term, TUI, terminal UI, ratatui, openshell-tui, tui development, tui feature, tui bug. +metadata: + internal: true --- # OpenShell TUI Development Guide @@ -43,8 +45,8 @@ Gateway (discovered via openshell_bootstrap::list_gateways()) - **Gateways** are discovered from on-disk config via `openshell_bootstrap::list_gateways()`. Each gateway has a name, endpoint, local/remote flag, and source label. - **Workspaces** are fetched via `ListWorkspaces`. The user cycles through workspaces with `[w]`, or views all workspaces at once. The current workspace scopes provider and sandbox lists. -- **Provider Profiles** are fetched per-workspace via `ListProviderProfiles` when `providers_v2_enabled` is true. Profiles are cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)` and matched to providers by type. They provide category, credential metadata, endpoint/binary counts, and inference capability. -- **Providers** are fetched via `ListProviders` scoped to the current workspace. Each `ProviderListEntry` pairs a provider with its optional cached profile. When `providers_v2_enabled` is true, CRUD operations are read-only in the TUI; when false, the TUI supports create/update/delete. +- **Provider Profiles** are fetched per-workspace via `ListProviderProfiles`. Profiles are cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)` and matched to providers by type. They provide category, credential metadata, endpoint/binary counts, and inference capability. +- **Providers** are fetched via `ListProviders` scoped to the current workspace. Each `ProviderListEntry` pairs a provider with its optional cached profile. The TUI supports profile-backed create, update, and delete operations. - **Global Settings** are fetched via `GetGatewayConfig` and displayed in a tabbed pane alongside providers on the dashboard. Each setting is a registered key with a typed value (bool/int/string). Platform-admin access is required; `PermissionDenied` disables the pane. - **Sandboxes** belong to the active gateway and workspace. Fetched via `ListSandboxes` with a periodic tick refresh. - **Sandbox Settings** are effective settings returned by `GetSandboxConfig`, each with a scope (sandbox, global, or unset). Globally-managed settings are blocked from sandbox-level edits. @@ -53,7 +55,7 @@ Gateway (discovered via openshell_bootstrap::list_gateways()) The **title bar** always reflects this hierarchy, reading left-to-right from general to specific: ``` - OpenShell │ Current Gateway: [source] () │ Workspace: + OpenShell v │ Current Gateway: [source] () │ Workspace: ``` ## 3. Navigation & Screen Architecture @@ -142,8 +144,8 @@ Every frame renders four vertical regions: ### Title bar examples -- Dashboard: ` >_ OpenShell ALPHA | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard` -- Sandbox detail: ` >_ OpenShell ALPHA | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox` +- Dashboard: ` >_ OpenShell v | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard` +- Sandbox detail: ` >_ OpenShell v | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox` ### Adding a new screen @@ -170,7 +172,7 @@ Phase 2: WatchSandbox(follow_logs: true) → live tail → send via Event::L **Sandboxes**: Fetched via `ListSandboxes` on a 2-second tick, scoped to the current workspace (or all workspaces). -**Providers**: Fetched via `ListProviders` on each tick. When `providers_v2_enabled` is true, provider profiles are also fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`. +**Providers**: Fetched via `ListProviders` on each tick. Provider profiles are fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`. **Settings**: Global settings are fetched via `GetGatewayConfig` on each tick. Sandbox settings are fetched alongside the sandbox policy via `GetSandboxConfig` and refreshed on each tick when viewing a sandbox. @@ -321,7 +323,7 @@ TUI actions should parallel `openshell` CLI commands so users have familiar ment | `openshell sandbox connect` | `[s]` on sandbox policy view to launch SSH shell | | `openshell logs ` | `[l]` on sandbox detail to open log viewer | | `openshell provider list` | Provider table on Dashboard (middle pane) | -| `openshell provider create` | `[c]` on provider panel (when not providers_v2) | +| `openshell provider create` | `[c]` on provider panel | | `openshell status` | Status in title bar + gateway list | When adding new TUI features, check what the CLI offers and maintain consistency. @@ -383,11 +385,8 @@ All actions are accessible via keyboard shortcuts displayed in the nav bar. The **Dashboard (Gateways focus):** `[Tab] Switch Panel [Enter] Select [j/k] Navigate │ [:] Command [q] Quit` -**Dashboard (Providers focus, providers_v2):** -`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail read-only │ [:] Command [q] Quit` - -**Dashboard (Providers focus, legacy):** -`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete │ [:] Command [q] Quit` +**Dashboard (Providers focus):** +`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete [w] Workspace │ [:] Command [q] Quit` **Dashboard (Global Settings focus):** `[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Edit [d] Delete │ [:] Command [q] Quit` @@ -412,7 +411,7 @@ All actions are accessible via keyboard shortcuts displayed in the nav bar. The | File | Purpose | | --- | --- | | `crates/openshell-tui/Cargo.toml` | Crate manifest — dependencies on `openshell-core`, `openshell-bootstrap`, `ratatui`, `crossterm`, `tonic`, `tokio` | -| `crates/openshell-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_data`, `refresh_providers`, `refresh_global_settings`, `refresh_workspaces`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`, `fetch_providers_v2_setting`), gateway switching, mTLS channel building, provider CRUD spawners, settings CRUD spawners, draft approval spawners | +| `crates/openshell-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_data`, `refresh_providers`, `refresh_global_settings`, `refresh_workspaces`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`), gateway switching, mTLS channel building, provider CRUD spawners, settings CRUD spawners, draft approval spawners | | `crates/openshell-tui/src/app.rs` | `App` state struct, `Screen`/`Focus`/`InputMode`/`LogSourceFilter`/`MiddlePaneTab`/`SandboxPolicyTab` enums, `LogLine`/`GatewayEntry`/`GlobalSettingEntry`/`SandboxSettingEntry`/`ProviderListEntry`/`ProviderDetailView` structs, create sandbox/provider form state, all key handling logic | | `crates/openshell-tui/src/event.rs` | `Event` enum (`Key`, `Mouse`, `Tick`, `Redraw`, `Resize`, `LogLines`, `CreateResult`, `ProviderCreateResult`, `ProviderDetailFetched`, `ProviderUpdateResult`, `ProviderDeleteResult`, `DraftActionResult`, `GlobalSettingsFetched`, `GlobalSettingSetResult`, `GlobalSettingDeleteResult`, `SandboxSettingSetResult`, `SandboxSettingDeleteResult`, `ForwardWarnings`), `EventHandler` with mpsc channels and crossterm polling | | `crates/openshell-tui/src/theme.rs` | `colors` module (NVIDIA_GREEN, EVERGLADE, BG, FG) and `styles` module (all `Style` constants) | @@ -526,7 +525,6 @@ The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at 4. On success: - `app.client` is replaced with a new intercepted client - `reset_sandbox_state()` clears all sandbox/log/draft/policy data - - `fetch_providers_v2_setting()` probes the new gateway's `GetGatewayConfig` to determine whether providers_v2 mode is enabled, so provider CRUD controls render correctly - `refresh_data()` runs the full capability refresh sequence: `refresh_health` → `refresh_global_settings` → `refresh_workspaces` → `refresh_providers` → `refresh_sandboxes` 5. On failure: `status_text` shows the error @@ -534,13 +532,12 @@ The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at On launch, before the event loop starts: -1. `fetch_providers_v2_setting()` — probe gateway capability -2. `refresh_gateway_list()` — discover gateways from disk -3. `refresh_data()` — full refresh (health, global settings, workspaces, providers, sandboxes) +1. `refresh_gateway_list()` — discover gateways from disk +2. `refresh_data()` — full refresh (health, global settings, workspaces, providers, sandboxes) ### Workspace switching lifecycle -1. User presses `[w]` on the sandboxes panel → `cycle_workspace()` advances through discovered workspace names, then "all" +1. User presses `[w]` on the providers or sandboxes panel → `cycle_workspace()` advances through discovered workspace names, then "all" 2. `pending_workspace_refresh = true` is set, cursor indices are reset 3. Event loop calls `refresh_providers()` and `refresh_sandboxes()` with the new workspace scope diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md index f38d7f576e..aa2db8cd58 100644 --- a/.agents/skills/update-docs/SKILL.md +++ b/.agents/skills/update-docs/SKILL.md @@ -1,6 +1,8 @@ --- name: update-docs-from-commits description: Scan recent git commits for changes that affect user-facing behavior, then draft or update the corresponding documentation pages. Use when docs have fallen behind code changes, after a batch of features lands, or when preparing a release. Trigger keywords - update docs, draft docs, docs from commits, sync docs, catch up docs, doc debt, docs behind, docs drift. +metadata: + internal: true --- # Update Docs from Commits diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index a7e7ea46da..c9e2843311 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -1,6 +1,8 @@ --- name: watch-github-actions description: Watch and monitor GitHub Actions workflow runs using the gh CLI. Use when the user wants to check workflow status, watch a running workflow, view CI/CD jobs, or monitor build progress. Trigger keywords - watch pipeline, pipeline status, CI status, check build, monitor CI, view pipeline, pipeline progress, workflow status, actions status. +metadata: + internal: true --- # Watch GitHub Actions diff --git a/.bazelrc b/.bazelrc deleted file mode 100644 index 4850c1d53c..0000000000 --- a/.bazelrc +++ /dev/null @@ -1,6 +0,0 @@ -build --@rules_rs//rs/private/prost:compile_well_known_types=false -test --test_env=PATH - -build:release --compilation_mode=opt -build:release --@rules_rust//rust/settings:codegen_units=1 -build:release --@rules_rust//rust/settings:extra_rustc_flag=-Cstrip=symbols diff --git a/.bazelversion b/.bazelversion deleted file mode 100644 index 44931da266..0000000000 --- a/.bazelversion +++ /dev/null @@ -1 +0,0 @@ -9.1.1 diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..1c1235c6ea --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[profile.ci] +fail-fast = false +status-level = "slow" +final-status-level = "fail" +failure-output = "immediate-final" +# Print slow test names after 30 seconds and terminate them after two minutes. +slow-timeout = { period = "30s", terminate-after = 4 } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b569db2a7c..4d32b414c1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,5 @@ # Broad ownership — core team reviews everything -* @NVIDIA/openshell-codeowners @mrunalp @maxamillion @derekwaynecarr +* @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr # Vouch list — maintainers only (bot commits bypass, but manual edits need review) .github/VOUCHED.td @NVIDIA/openshell-codeowners diff --git a/.github/DISCUSSION_TEMPLATE/vouch-request.yml b/.github/DISCUSSION_TEMPLATE/vouch-request.yml index 58d98e300c..b5ee84de53 100644 --- a/.github/DISCUSSION_TEMPLATE/vouch-request.yml +++ b/.github/DISCUSSION_TEMPLATE/vouch-request.yml @@ -8,7 +8,7 @@ body: OpenShell uses a vouch system for first-time contributors. Fill out this form to request approval. A maintainer will review and comment `/vouch` - if approved. + if approved. Approved requests are closed automatically. **Write in your own words.** Do not have an AI generate this request. Requests that read like LLM output will be denied. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index de5d8112ae..dd774e25c8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,39 +1,58 @@ name: Bug Report -description: Report a bug. Your agent should investigate first — see CONTRIBUTING.md. +description: Report unexpected behavior with a clear user story and expected outcome. type: Bug body: - type: markdown attributes: value: | - ## Agent-First Troubleshooting + ## Bug Reports - OpenShell is an agent-first project. Before filing this bug, point your coding agent at the repo and have it investigate using the available skills (`debug-openshell-cluster`, `debug-inference`, `openshell-cli`, etc.). See [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md) for the full skills table. + Describe who encountered the bug, the problem it creates, why it matters, and the observable criteria for a fix. Please check the latest OpenShell release and search existing issues for possible duplicates before filing. - Please also check the latest OpenShell release and search existing issues for possible duplicates before filing. If you cannot upgrade, retest, or search existing issues, explain why in the diagnostic. + Do not report security vulnerabilities here; follow [SECURITY.md](https://github.com/NVIDIA/OpenShell/blob/main/SECURITY.md) instead. - type: textarea - id: agent-diagnostic + id: user-story attributes: - label: Agent Diagnostic + label: User Story + description: Describe who encountered this behavior, what they need to do, and why it matters. + placeholder: | + As a , + I want , + so that . + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Summarize what is broken or missing in the current behavior. Focus on the issue itself; describe its consequences and current workarounds in Impact / Why This Matters. + placeholder: Describe the gap or failure in OpenShell's current behavior and when it occurs. + validations: + required: true + + - type: textarea + id: impact + attributes: + label: Impact / Why This Matters description: | - Paste the output from your agent's investigation of this bug. What skills did it load? What did it find? What did it try? Which OpenShell version did it test, and did it check the latest release, known fixes, and possible duplicates? + Explain how the bug affects users and what they must do to work around it today. Describe the operational cost, security risk, data loss, blocked workflow, or other concrete consequence. Include evidence where available, but do not diagnose the cause or prescribe the fix. placeholder: | - Example: - - Loaded `debug-inference` skill - - Tested OpenShell v0.x.x - - Checked latest release / known fixes: no matching fix found - - Searched existing issues for duplicates: no matching issue found - - Ran `openshell inference get` and `openshell provider get ollama` - - Found `OPENAI_BASE_URL=http://127.0.0.1:11434/v1`, which is unreachable from the gateway - - Updated the provider to use `host.openshell.internal`, but the issue persists because the gateway is remote + When this happens, users must... + This results in... + This matters because... validations: required: true - type: textarea - id: description + id: acceptance-criteria attributes: - label: Description - description: What happened? What did you expect to happen? + label: Acceptance Criteria + description: List the specific, observable outcomes that would demonstrate the bug is fixed. + placeholder: | + - [ ] + - [ ] validations: required: true @@ -41,7 +60,7 @@ body: id: reproduction attributes: label: Reproduction Steps - description: Minimal steps to reproduce the issue. + description: Provide the minimal steps needed to reproduce the issue. placeholder: | 1. Run `openshell sandbox create -- claude` 2. ... @@ -52,13 +71,12 @@ body: id: environment attributes: label: Environment - description: OS, Docker version, OpenShell version tested, whether the latest release and existing issues were checked, and any other relevant details. + description: Include the OpenShell version and relevant OS, deployment mode, runtime, or integration details. placeholder: | - - OS: macOS 15.2 / Ubuntu 24.04 / Windows 11 + WSL2 - - Docker: Docker Desktop 4.x / Docker Engine 27.x - OpenShell: v0.x.x (output of `openshell --version`) - - Latest release checked: yes / no, because ... - - Possible duplicates checked: yes / no, because ... + - OS: macOS 15.2 / Ubuntu 24.04 / Windows 11 + WSL2 + - Runtime: Docker Desktop 4.x / Docker Engine 27.x / Kubernetes v1.x + - Deployment or integration: validations: required: true @@ -67,24 +85,8 @@ body: attributes: label: Logs description: | - Relevant log output, error messages, or stack traces. - Redact credentials, API keys, and tokens before pasting — verbose error output from some frameworks includes the full request config. + Include the smallest relevant log excerpt, error message, or stack trace. + Redact credentials, API keys, tokens, and query parameters before pasting. render: shell validations: required: false - - - type: checkboxes - id: checklist - attributes: - label: Agent-First Checklist - options: - - label: I pointed my agent at the repo and had it investigate this issue - required: true - - label: I loaded relevant skills (e.g., `debug-openshell-cluster`, `debug-inference`, `openshell-cli`) - required: true - - label: I checked the latest OpenShell release and either reproduced the issue there or explained why I cannot upgrade/test it - required: true - - label: I searched existing issues for possible duplicates or explained why I could not - required: true - - label: My agent could not resolve this — the diagnostic above explains why - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 28e6b201b5..0f6486fde8 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -7,11 +7,10 @@ contact_links: Vouch Request discussion describing what you want to work on. A maintainer will approve you with /vouch. - name: Have a question? - url: https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#agent-skills-for-contributors + url: https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#skills-for-using-openshell about: > - Point your agent at the repo. It has skills for CLI usage, cluster and - inference debugging, policy generation, and more. See CONTRIBUTING.md - for the full skills table. + Install the public OpenShell skills for CLI usage, cluster and inference + debugging, and policy generation. See CONTRIBUTING.md for details. - name: Security vulnerability? url: https://github.com/NVIDIA/OpenShell/blob/main/SECURITY.md about: > diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index bbc1188d7a..10ae9cbe50 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature Request -description: Propose a feature with a design. Not a "please build this" request. +description: Propose a feature with a user story and design. Not a "please build this" request. type: Feature body: - type: markdown @@ -7,15 +7,40 @@ body: value: | ## Design-First Feature Proposals - OpenShell feature requests must include a design proposal — describe the system behavior you want, not just the outcome. New features start here, not as RFC pull requests. If maintainers decide an RFC is necessary, they will request one from this issue and assign the RFC number. + OpenShell feature requests must include a user story and workflow-level design proposal. Describe who needs the feature, the problem it solves, why the current gap matters, the desired user-facing workflow, and the observable criteria for success. Leave implementation choices to the people building the feature. New features start here, not as RFC pull requests. If maintainers decide an RFC is necessary, they will request one from this issue and assign the RFC number. If your agent explored the codebase to assess feasibility (e.g., using the `create-spike` skill), include its findings. + - type: textarea + id: user-story + attributes: + label: User Story + description: Describe who needs this feature, what they need to do, and why it matters. + placeholder: | + As a , + I want , + so that . + validations: + required: true + - type: textarea id: problem attributes: label: Problem Statement - description: What problem does this solve? Why does it matter? + description: Summarize the capability or behavior missing from OpenShell today. Focus on the gap itself; describe its consequences and current workarounds in Impact / Why This Matters. + validations: + required: true + + - type: textarea + id: impact + attributes: + label: Impact / Why This Matters + description: | + Explain what users must do today and why that is insufficient. Describe the operational cost, security risk, blocked workflow, or adoption barrier caused by the problem. Include concrete evidence where available, but do not prescribe the solution. + placeholder: | + Without this feature, users must... + This results in... + This matters because... validations: required: true @@ -24,7 +49,18 @@ body: attributes: label: Proposed Design description: | - How should this work? Describe the system behavior, components involved, and user-facing interface. This should be a design, not a wish list. + How should this work from the user's perspective? Describe the desired workflow and externally observable behavior. Avoid prescribing internal components or implementation unless they are essential constraints. + validations: + required: true + + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance Criteria + description: List specific, observable outcomes that would make this feature complete without prescribing how implementers achieve them. + placeholder: | + - [ ] + - [ ] validations: required: true @@ -32,7 +68,7 @@ body: id: alternatives attributes: label: Alternatives Considered - description: What other approaches did you evaluate? Why is the proposed design better? + description: What other user-facing workflows or behaviors did you consider? Why does the proposed approach best satisfy the user story? validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f6de74c859..2efbaac5d1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,7 +13,7 @@ No issue required: ## Testing - + - [ ] `mise run pre-commit` passes - [ ] Unit tests added/updated - [ ] E2E tests added/updated (if applicable) diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 0000000000..4613e1617b --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/actionlint-sarif-template.txt b/.github/actionlint-sarif-template.txt new file mode 100644 index 0000000000..01c589e218 --- /dev/null +++ b/.github/actionlint-sarif-template.txt @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Actionlint", + "version": {{ getVersion | json }}, + "informationUri": "https://github.com/rhysd/actionlint", + "rules": [ + {{$first := true}} + {{range $ := allKinds}} + {{if $first}}{{$first = false}}{{else}},{{end}} + { + "id": {{json $.Name}}, + "name": {{$.Name | toPascalCase | json}}, + "defaultConfiguration": { + "level": "warning" + }, + "fullDescription": { + "text": {{json $.Description}} + }, + "helpUri": "https://github.com/rhysd/actionlint/blob/v1.7.12/docs/checks.md" + } + {{end}} + ] + } + }, + "results": [ + {{$first := true}} + {{range $ := .}} + {{if $first}}{{$first = false}}{{else}},{{end}} + { + "ruleId": {{json $.Kind}}, + "level": "warning", + "message": { + "text": {{json $.Message}} + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": {{json $.Filepath}}, + "uriBaseId": "%SRCROOT%" + }, + "region": { + "startLine": {{$.Line}}, + "startColumn": {{$.Column}}, + "endColumn": {{$.EndColumn}}, + "snippet": { + "text": {{json $.Snippet}} + } + } + } + } + ] + } + {{end}} + ] + } + ] +} diff --git a/.github/actionlint.yml b/.github/actionlint.yml new file mode 100644 index 0000000000..35aa72ea78 --- /dev/null +++ b/.github/actionlint.yml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +self-hosted-runner: + labels: + - linux-amd64-cpu8 + - linux-amd64-gpu-rtxpro6000-latest-1 + - linux-arm64-cpu8 + - linux-arm64-gpu-l4-latest-1 + - nv + - ubuntu-26.04 + - windows-arm64 + - wsl-amd64-gpu-rtxpro6000-latest-1 + +paths: + .github/workflows/windows-msvc.yml: + ignore: + - 'constant expression "false" in condition' diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml new file mode 100644 index 0000000000..08557fbfd4 --- /dev/null +++ b/.github/actions/build-docker-image/action.yml @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Docker Image +description: Build and push one architecture of an OpenShell image + +inputs: + component: + description: Docker image and Dockerfile name + required: true + binary: + description: Binary staged in the Docker build context + required: true + triple: + description: Binary artifact target triple + required: true + arch: + description: Docker architecture name + required: true + platform: + description: Docker platform + required: true + image-tag: + description: Docker image tag + required: true + github-token: + description: Token used to push the image + required: true + +runs: + using: composite + steps: + - uses: ./.github/actions/setup-buildx + with: + buildkitd-config: /etc/buildkit/buildkitd.toml + + - name: Log in to GHCR + shell: bash + run: echo "${INPUTS_GITHUB_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + env: + INPUTS_GITHUB_TOKEN: ${{ inputs.github-token }} + + - name: Download ${{ inputs.binary }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.binary }}-${{ inputs.triple }} + path: artifact + + - name: Stage ${{ inputs.binary }} + shell: bash + run: install -Dm0755 artifact/${INPUTS_BINARY} deploy/docker/.build/prebuilt-binaries/${INPUTS_ARCH}/${INPUTS_BINARY} + env: + INPUTS_BINARY: ${{ inputs.binary }} + INPUTS_ARCH: ${{ inputs.arch }} + + - name: Build ${{ inputs.component }} image + shell: bash + env: + IMAGE_TAG: ${{ inputs.image-tag }} + INPUTS_PLATFORM: ${{ inputs.platform }} + INPUTS_COMPONENT: ${{ inputs.component }} + INPUTS_ARCH: ${{ inputs.arch }} + run: | + docker buildx build \ + --builder openshell \ + --platform ${INPUTS_PLATFORM} \ + --file deploy/docker/Dockerfile.${INPUTS_COMPONENT} \ + --target ${INPUTS_COMPONENT} \ + --tag ghcr.io/nvidia/openshell/${INPUTS_COMPONENT}:${IMAGE_TAG}-${INPUTS_ARCH} \ + --cache-from type=gha,scope=${INPUTS_COMPONENT}-${INPUTS_ARCH} \ + --cache-to type=gha,mode=max,scope=${INPUTS_COMPONENT}-${INPUTS_ARCH} \ + --provenance=mode=min \ + --attest type=sbom \ + --output type=image,push=true,oci-mediatypes=true,oci-artifact=true \ + . diff --git a/.github/actions/build-rust-binary/action.yml b/.github/actions/build-rust-binary/action.yml new file mode 100644 index 0000000000..408219b57c --- /dev/null +++ b/.github/actions/build-rust-binary/action.yml @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Rust Binary +description: Build, verify, package, and upload an auditable Rust binary + +inputs: + package: + description: Cargo package that owns the binary + required: true + binary: + description: Cargo binary name + required: true + triple: + description: Rust target triple + required: true + dev-shell: + description: Nix development shell used to build the binary + required: true + cargo-version: + description: Cargo package version embedded in the binary + required: true + image-tag: + description: Default supervisor image tag embedded in the binary + required: false + default: "" + artifact-name: + description: GitHub artifact name + required: false + default: "" + extra-cargo-flags: + description: Additional flags passed to cargo build + required: false + default: "" + interpreter: + description: ELF interpreter for a dynamically linked Linux binary + required: false + default: "" + +runs: + using: composite + steps: + - name: Hash development shell + id: dev-shell + shell: bash + env: + DEV_SHELL: ${{ inputs.dev-shell }} + run: echo "hash=$(nix hash file --type sha256 --base16 "$(nix eval --raw "${DEV_SHELL}.drvPath")")" >> "$GITHUB_OUTPUT" + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: binaries-${{ inputs.binary }}-${{ inputs.triple }}-${{ steps.dev-shell.outputs.hash }} + cache-on-failure: "true" + cache-workspace-crates: "true" + cache-bin: "false" + cmd-format: nix develop ${{ inputs.dev-shell }} -c {0} + + - name: Set version + shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0} + env: + INPUTS_CARGO_VERSION: ${{ inputs.cargo-version }} + run: sed -i "s/^version = \"0\\.0\\.0\"$/version = \"${INPUTS_CARGO_VERSION}\"/" Cargo.toml + + - name: Build ${{ inputs.binary }} + shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0} + env: + OPENSHELL_IMAGE_TAG: ${{ inputs.image-tag }} + INPUTS_PACKAGE: ${{ inputs.package }} + INPUTS_BINARY: ${{ inputs.binary }} + INPUTS_EXTRA_CARGO_FLAGS: ${{ inputs.extra-cargo-flags }} + run: GIT_DIR=/nonexistent cargo auditable build --release --package "${INPUTS_PACKAGE}" --bin "${INPUTS_BINARY}" ${INPUTS_EXTRA_CARGO_FLAGS} + + - name: Normalize Darwin dynamic binary + if: endsWith(inputs.triple, '-apple-darwin') + shell: bash -euo pipefail {0} + env: + INPUTS_BINARY: ${{ inputs.binary }} + run: | + binary=target/release/${INPUTS_BINARY} + nix_iconv_paths=$(otool -L "$binary" | awk '$1 ~ "^/nix/store/.*/lib/libiconv\\.2\\.dylib$" { print $1 }') + if [[ -n "$nix_iconv_paths" ]]; then + while IFS= read -r nix_iconv_path; do + install_name_tool -change "$nix_iconv_path" /usr/lib/libiconv.2.dylib "$binary" + done <<< "$nix_iconv_paths" + codesign --force --sign - "$binary" + fi + + - name: Verify Darwin portability + if: endsWith(inputs.triple, '-apple-darwin') + shell: bash -euo pipefail {0} + env: + INPUTS_BINARY: ${{ inputs.binary }} + run: | + binary=target/release/${INPUTS_BINARY} + nix_dependencies=$(otool -L "$binary" | awk '$1 ~ "^/nix/store/" { print $1 }') + if [[ -n "$nix_dependencies" ]]; then + echo "error: $binary has non-portable Nix store dependencies:" >&2 + printf ' %s\n' "$nix_dependencies" >&2 + exit 1 + fi + codesign --verify --verbose=2 "$binary" + + - name: Verify ${{ inputs.binary }} + shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0} + env: + INPUTS_BINARY: ${{ inputs.binary }} + run: | + # Confirm the binary runs and reports the expected name. + target/release/${INPUTS_BINARY} --version | grep -q "^${INPUTS_BINARY} " + # Confirm Syft can decode the embedded cargo-auditable metadata. + SYFT_CHECK_FOR_APP_UPDATE=false syft file:target/release/${INPUTS_BINARY} -o cyclonedx-json | grep 'pkg:cargo/' > /dev/null + + - name: Verify static linkage + if: endsWith(inputs.triple, '-linux-musl') + shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0} + env: + INPUTS_BINARY: ${{ inputs.binary }} + run: tasks/scripts/verify-static-binary.sh target/release/${INPUTS_BINARY} + + - name: Normalize Linux dynamic binary + if: endsWith(inputs.triple, '-linux-gnu') + shell: nix develop ${{ inputs.dev-shell }} -c bash -euo pipefail {0} + env: + INPUTS_BINARY: ${{ inputs.binary }} + INPUTS_INTERPRETER: ${{ inputs.interpreter }} + run: | + binary=target/release/${INPUTS_BINARY} + # Remove Nix store paths so the binary can run on other distributions. + patchelf --set-interpreter "${INPUTS_INTERPRETER}" --remove-rpath "$binary" + # Z3 must be embedded instead of loaded from the target system. + test -z "$(patchelf --print-needed "$binary" | grep '^libz3')" + # Reject symbols introduced after glibc 2.28. + tasks/scripts/verify-glibc-symbols.sh 2.28 "$binary" + + - name: Upload ${{ inputs.binary }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ inputs.artifact-name || format('{0}-{1}', inputs.binary, inputs.triple) }} + path: target/release/${{ inputs.binary }} + compression-level: 0 + retention-days: 5 + if-no-files-found: error diff --git a/.github/actions/check-job-results/action.yml b/.github/actions/check-job-results/action.yml new file mode 100644 index 0000000000..bd6beb9456 --- /dev/null +++ b/.github/actions/check-job-results/action.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Check job results +description: Fail when any required upstream job did not succeed + +inputs: + results: + description: JSON-encoded GitHub Actions needs context + required: true + +runs: + using: composite + steps: + - name: Check required jobs + shell: bash + env: + JOB_RESULTS: ${{ inputs.results }} + run: | + set -euo pipefail + failures="$( + jq -r ' + to_entries[] + | select(.value.result != "success") + | "\(.key) concluded \(.value.result)" + ' <<< "$JOB_RESULTS" + )" + if [ -n "$failures" ]; then + while IFS= read -r failure; do + echo "::error::$failure" + done <<< "$failures" + exit 1 + fi diff --git a/.github/actions/release-helm-oci/action.yml b/.github/actions/release-helm-oci/action.yml index d20691ad0f..46649e0cb0 100644 --- a/.github/actions/release-helm-oci/action.yml +++ b/.github/actions/release-helm-oci/action.yml @@ -4,7 +4,7 @@ name: Release Helm OCI description: > Patch chart version/appVersion, refuse duplicate OCI versions on public - releases, package the chart, and push to GHCR OCI. + releases, package the gateway and workspace charts, and push them to GHCR OCI. inputs: chart-version: @@ -51,11 +51,16 @@ runs: shell: bash run: | set -euo pipefail - CHART_DIR="${RUNNER_TEMP}/chart-build" - cp -a deploy/helm/openshell/. "${CHART_DIR}" - sed -i "s/^version:.*/version: ${CHART_VERSION}/" "${CHART_DIR}/Chart.yaml" - sed -i "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" "${CHART_DIR}/Chart.yaml" - echo "chart_dir=${CHART_DIR}" >> "$GITHUB_OUTPUT" + GATEWAY_CHART_DIR="${RUNNER_TEMP}/gateway-chart-build" + WORKSPACE_CHART_DIR="${RUNNER_TEMP}/workspace-chart-build" + cp -a deploy/helm/openshell/. "${GATEWAY_CHART_DIR}" + cp -a deploy/helm/openshell-workspace/. "${WORKSPACE_CHART_DIR}" + for chart_dir in "${GATEWAY_CHART_DIR}" "${WORKSPACE_CHART_DIR}"; do + sed -i "s/^version:.*/version: ${CHART_VERSION}/" "${chart_dir}/Chart.yaml" + sed -i "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" "${chart_dir}/Chart.yaml" + done + echo "gateway_chart_dir=${GATEWAY_CHART_DIR}" >> "$GITHUB_OUTPUT" + echo "workspace_chart_dir=${WORKSPACE_CHART_DIR}" >> "$GITHUB_OUTPUT" echo "chart_version=${CHART_VERSION}" >> "$GITHUB_OUTPUT" - name: Refuse duplicate chart version @@ -65,38 +70,52 @@ runs: shell: bash run: | set -euo pipefail - OCI_CHART="oci://ghcr.io/nvidia/openshell/helm-chart" - if helm show chart "${OCI_CHART}" --version "${CHART_VERSION}" >/dev/null 2>&1; then - echo "::error::Chart ${CHART_VERSION} is already published. Use a new tag or delete the existing package first." - exit 1 - fi + for chart in helm-chart openshell-workspace; do + OCI_CHART="oci://ghcr.io/nvidia/openshell/${chart}" + if helm show chart "${OCI_CHART}" --version "${CHART_VERSION}" >/dev/null 2>&1; then + echo "::error::Chart ${chart}:${CHART_VERSION} is already published. Use a new tag or delete the existing package first." + exit 1 + fi + done - name: Package Helm chart env: - CHART_DIR: ${{ steps.prep.outputs.chart_dir }} + GATEWAY_CHART_DIR: ${{ steps.prep.outputs.gateway_chart_dir }} + WORKSPACE_CHART_DIR: ${{ steps.prep.outputs.workspace_chart_dir }} shell: bash run: | set -euo pipefail - helm package "${CHART_DIR}" --destination /tmp - ls /tmp/helm-chart-*.tgz + mkdir -p /tmp/helm-charts + helm package "${GATEWAY_CHART_DIR}" --destination /tmp/helm-charts + helm package "${WORKSPACE_CHART_DIR}" --destination /tmp/helm-charts + ls /tmp/helm-charts/*.tgz - name: Push Helm chart to GHCR OCI shell: bash run: | set -euo pipefail - helm push /tmp/helm-chart-*.tgz oci://ghcr.io/nvidia/openshell + for archive in /tmp/helm-charts/*.tgz; do + helm push "${archive}" oci://ghcr.io/nvidia/openshell + done - name: Push SHA-pinned chart if: inputs.pin-sha != '' env: PIN_SHA: ${{ inputs.pin-sha }} - CHART_DIR: ${{ steps.prep.outputs.chart_dir }} + GATEWAY_CHART_DIR: ${{ steps.prep.outputs.gateway_chart_dir }} + WORKSPACE_CHART_DIR: ${{ steps.prep.outputs.workspace_chart_dir }} shell: bash run: | set -euo pipefail - SHA_CHART_DIR="${RUNNER_TEMP}/chart-build-sha" - cp -a "${CHART_DIR}/." "${SHA_CHART_DIR}" - sed -i "s/^version:.*/version: 0.0.0-dev.${PIN_SHA}/" "${SHA_CHART_DIR}/Chart.yaml" - sed -i "s/^appVersion:.*/appVersion: \"${PIN_SHA}\"/" "${SHA_CHART_DIR}/Chart.yaml" - helm package "${SHA_CHART_DIR}" --destination /tmp/sha-pin - helm push /tmp/sha-pin/helm-chart-*.tgz oci://ghcr.io/nvidia/openshell + mkdir -p /tmp/sha-pin + for source_dir in "${GATEWAY_CHART_DIR}" "${WORKSPACE_CHART_DIR}"; do + chart_name="$(basename "${source_dir}")" + sha_chart_dir="${RUNNER_TEMP}/${chart_name}-sha" + cp -a "${source_dir}/." "${sha_chart_dir}" + sed -i "s/^version:.*/version: 0.0.0-dev.${PIN_SHA}/" "${sha_chart_dir}/Chart.yaml" + sed -i "s/^appVersion:.*/appVersion: \"${PIN_SHA}\"/" "${sha_chart_dir}/Chart.yaml" + helm package "${sha_chart_dir}" --destination /tmp/sha-pin + done + for archive in /tmp/sha-pin/*.tgz; do + helm push "${archive}" oci://ghcr.io/nvidia/openshell + done diff --git a/.github/actions/setup-e2e-cli/action.yml b/.github/actions/setup-e2e-cli/action.yml index fb894194c8..d7d1c8a296 100644 --- a/.github/actions/setup-e2e-cli/action.yml +++ b/.github/actions/setup-e2e-cli/action.yml @@ -1,10 +1,11 @@ name: Setup E2E CLI -description: Download an architecture-matched prebuilt OpenShell CLI for E2E tests +description: Download architecture-matched prebuilt OpenShell host CLIs for E2E tests inputs: - artifact-prefix: - description: Artifact name prefix; linux- is appended automatically - required: true + conformance-artifact-prefix: + description: Optional conformance CLI artifact name prefix; linux- is appended automatically + required: false + default: "" runs: using: composite @@ -12,18 +13,37 @@ runs: - name: Download prebuilt CLI uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ format('{0}-linux-{1}', inputs.artifact-prefix, runner.arch == 'X64' && 'amd64' || 'arm64') }} + name: ${{ runner.arch == 'X64' && 'openshell-x86_64-unknown-linux-musl' || 'openshell-aarch64-unknown-linux-musl' }} path: .e2e/prebuilt-cli - name: Configure prebuilt CLI shell: bash run: | - set -euo pipefail cli="$GITHUB_WORKSPACE/.e2e/prebuilt-cli/openshell" - if [[ ! -f "$cli" ]]; then - echo "downloaded artifact is missing $cli" >&2 - exit 1 - fi 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/actions/setup-e2e-driver/action.yml b/.github/actions/setup-e2e-driver/action.yml new file mode 100644 index 0000000000..c2172b2a63 --- /dev/null +++ b/.github/actions/setup-e2e-driver/action.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E Driver +description: Download an architecture-matched standalone compute driver for E2E tests + +inputs: + binary: + description: Compute driver binary name + required: true + +runs: + using: composite + steps: + - name: Download prebuilt ${{ inputs.binary }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.binary }}-${{ runner.arch == 'X64' && 'x86_64-unknown-linux-gnu' || 'aarch64-unknown-linux-gnu' }} + path: .e2e/prebuilt-driver + + - name: Configure prebuilt ${{ inputs.binary }} + shell: bash + run: | # zizmor: ignore[github-env] validated filename under the trusted workspace path + if [[ ! "${INPUTS_BINARY}" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "invalid driver binary name: ${INPUTS_BINARY}" >&2 + exit 1 + fi + driver="$GITHUB_WORKSPACE/.e2e/prebuilt-driver/${INPUTS_BINARY}" + chmod +x "$driver" + "$driver" --version + echo "OPENSHELL_EXTERNAL_DRIVER_BIN=$driver" >> "$GITHUB_ENV" + env: + INPUTS_BINARY: ${{ inputs.binary }} diff --git a/.github/actions/setup-e2e-gateway/action.yml b/.github/actions/setup-e2e-gateway/action.yml index b0ee52392b..255a7f4e4c 100644 --- a/.github/actions/setup-e2e-gateway/action.yml +++ b/.github/actions/setup-e2e-gateway/action.yml @@ -2,9 +2,10 @@ name: Setup E2E Gateway description: Download an architecture-matched prebuilt OpenShell gateway for E2E tests inputs: - artifact-prefix: - description: Artifact name prefix; linux- is appended automatically - required: true + artifact-name: + description: GitHub artifact name + required: false + default: "" runs: using: composite @@ -12,18 +13,13 @@ runs: - name: Download prebuilt gateway uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ format('{0}-linux-{1}', inputs.artifact-prefix, runner.arch == 'X64' && 'amd64' || 'arm64') }} + name: ${{ inputs.artifact-name || (runner.arch == 'X64' && 'openshell-gateway-x86_64-unknown-linux-gnu' || 'openshell-gateway-aarch64-unknown-linux-gnu') }} path: .e2e/prebuilt-gateway - name: Configure prebuilt gateway shell: bash run: | - set -euo pipefail gateway="$GITHUB_WORKSPACE/.e2e/prebuilt-gateway/openshell-gateway" - if [[ ! -f "$gateway" ]]; then - echo "downloaded artifact is missing $gateway" >&2 - exit 1 - fi chmod +x "$gateway" "$gateway" --version echo "OPENSHELL_GATEWAY_BIN=$gateway" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-e2e-kind/action.yml b/.github/actions/setup-e2e-kind/action.yml new file mode 100644 index 0000000000..b9fa8375ba --- /dev/null +++ b/.github/actions/setup-e2e-kind/action.yml @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E kind +description: Create a kind cluster and preload OpenShell images for E2E tests + +inputs: + cluster-name: + description: kind cluster name + required: true + image-tag: + description: OpenShell image tag to load + required: true + images: + description: Space-separated OpenShell image components to load + required: false + default: gateway supervisor + registry: + description: Container registry and namespace + required: false + default: ghcr.io/nvidia/openshell + registry-username: + description: Registry username + required: true + registry-password: + description: Registry password + required: true + mise-version: + description: mise release to install + required: false + default: v2026.4.25 + +runs: + using: composite + steps: + - uses: ./.github/actions/setup-mise + with: + version: ${{ inputs.mise-version }} + + - name: Log in to GHCR + shell: bash + env: + REGISTRY: ${{ inputs.registry }} + REGISTRY_USERNAME: ${{ inputs.registry-username }} + REGISTRY_PASSWORD: ${{ inputs.registry-password }} + run: echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY%%/*}" -u "$REGISTRY_USERNAME" --password-stdin + + - name: Create kind cluster + uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + with: + cluster_name: ${{ inputs.cluster-name }} + wait: 120s + + - name: Export kind kubeconfig to mise path + shell: bash + env: + CLUSTER_NAME: ${{ inputs.cluster-name }} + run: | + set -euo pipefail + kind get kubeconfig --name "$CLUSTER_NAME" > "$GITHUB_WORKSPACE/kubeconfig" + chmod 600 "$GITHUB_WORKSPACE/kubeconfig" + + - name: Load OpenShell images into kind + shell: bash + env: + CLUSTER_NAME: ${{ inputs.cluster-name }} + IMAGE_COMPONENTS: ${{ inputs.images }} + IMAGE_TAG: ${{ inputs.image-tag }} + REGISTRY: ${{ inputs.registry }} + run: | + set -euo pipefail + for component in $IMAGE_COMPONENTS; do + case "$component" in + gateway | supervisor) ;; + *) echo "ERROR: unsupported OpenShell image component: $component" >&2; exit 1 ;; + esac + image="${REGISTRY}/${component}:${IMAGE_TAG}" + archive="${RUNNER_TEMP:-/tmp}/openshell-${component}-linux-amd64.tar" + docker pull --platform linux/amd64 "$image" + docker image save --platform linux/amd64 --output "$archive" "$image" + kind load image-archive "$archive" --name "$CLUSTER_NAME" + done diff --git a/.github/actions/setup-e2e-podman/action.yml b/.github/actions/setup-e2e-podman/action.yml new file mode 100644 index 0000000000..47f57f5be8 --- /dev/null +++ b/.github/actions/setup-e2e-podman/action.yml @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E Podman +description: Configure the supported rootless Podman environment for E2E tests + +inputs: + mise-version: + description: mise release to install + required: false + default: v2026.4.25 + podman-major: + description: Expected Podman major version + required: true + podman-package-version: + description: Exact Ubuntu Podman package version + required: true + conmon-package-version: + description: Exact Ubuntu conmon package version + required: true + +runs: + using: composite + steps: + - uses: ./.github/actions/setup-mise + with: + version: ${{ inputs.mise-version }} + + - name: Install Podman and build dependencies + shell: bash + run: | # zizmor: ignore[github-env] persists only literal and trusted runner-derived paths + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + apparmor \ + build-essential \ + fuse-overlayfs \ + libssl-dev \ + openssh-client \ + passt \ + pkg-config \ + "conmon=${INPUTS_CONMON_PACKAGE_VERSION}" \ + "podman=${INPUTS_PODMAN_PACKAGE_VERSION}" \ + uidmap + # Hosted runners can place newer binaries under /usr/local. Select the + # distro CLI and use Podman's supported final conmon-path override. + podman_config="${RUNNER_TEMP}/openshell-containers.conf" + printf '%s\n' \ + '[engine]' \ + 'conmon_path = ["/usr/bin/conmon"]' \ + > "${podman_config}" + echo "/usr/bin" >> "${GITHUB_PATH}" + echo "CONTAINERS_CONF_OVERRIDE=${podman_config}" >> "${GITHUB_ENV}" + env: + INPUTS_CONMON_PACKAGE_VERSION: ${{ inputs.conmon-package-version }} + INPUTS_PODMAN_PACKAGE_VERSION: ${{ inputs.podman-package-version }} + + - name: Allow pasta to receive Podman stop signals + shell: bash + run: | + set -euo pipefail + # Ubuntu's packaged profile blocks this signal and makes Podman wait + # for its SIGKILL fallback. + profile=/etc/apparmor.d/usr.bin.pasta + rule=' signal (receive) peer=podman,' + if ! sudo grep -Fqx "${rule}" "${profile}"; then + sudo sed -i '\|^ include $|a\ signal (receive) peer=podman,' "${profile}" + fi + sudo grep -Fqx "${rule}" "${profile}" + sudo apparmor_parser --replace "${profile}" + + - name: Configure rootless Podman + shell: bash + run: | # zizmor: ignore[github-env] runtime path is derived solely from the runner UID + set -euo pipefail + if ! grep -q "^${USER}:" /etc/subuid; then + sudo usermod --add-subuids 100000-165535 "$USER" + fi + if ! grep -q "^${USER}:" /etc/subgid; then + sudo usermod --add-subgids 100000-165535 "$USER" + fi + runtime_dir="/run/user/$(id -u)" + sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" + echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV" + + - name: Verify rootless Podman environment + shell: bash + run: | + set -euo pipefail + podman_version="$(podman version --format '{{.Client.Version}}')" + case "$podman_version" in + "${INPUTS_PODMAN_MAJOR}".*) ;; + *) echo "ERROR: expected Podman ${INPUTS_PODMAN_MAJOR}.x, found $podman_version" >&2; exit 1 ;; + esac + test "$(dpkg-query -W -f='${Version}' podman)" = "${INPUTS_PODMAN_PACKAGE_VERSION}" + test "$(dpkg-query -W -f='${Version}' conmon)" = "${INPUTS_CONMON_PACKAGE_VERSION}" + test "$(command -v podman)" = "/usr/bin/podman" + test "$(podman info --format '{{.Host.Conmon.Path}}')" = "/usr/bin/conmon" + test "$(podman info --format '{{.Host.Security.Rootless}}')" = "true" + test "$(podman info --format '{{.Host.RootlessNetworkCmd}}')" = "pasta" + test "$(sudo sysctl -n kernel.apparmor_restrict_unprivileged_userns)" = "1" + echo "=== host ===" + uname -a + echo "=== AppArmor ===" + cat /proc/self/attr/current + sudo aa-status || true + echo "=== Podman ===" + podman version + podman info --debug + env: + INPUTS_PODMAN_MAJOR: ${{ inputs.podman-major }} + INPUTS_PODMAN_PACKAGE_VERSION: ${{ inputs.podman-package-version }} + INPUTS_CONMON_PACKAGE_VERSION: ${{ inputs.conmon-package-version }} + + - name: Probe rootless capability bounding set + shell: bash + run: | + set -euo pipefail + probe="$RUNNER_TEMP/openshell-capbset-probe" + cc -static -O2 -Wall -Wextra -Werror \ + e2e/support/capbset-probe.c \ + -o "$probe" + podman run --rm \ + --cap-add=SETPCAP \ + --volume "$probe:/openshell-capbset-probe:ro" \ + docker.io/library/alpine:3.22 \ + /openshell-capbset-probe diff --git a/.github/actions/setup-e2e-vm-driver/action.yml b/.github/actions/setup-e2e-vm-driver/action.yml index 20c43cf98f..a074ed97a8 100644 --- a/.github/actions/setup-e2e-vm-driver/action.yml +++ b/.github/actions/setup-e2e-vm-driver/action.yml @@ -1,10 +1,5 @@ name: Setup E2E VM Driver -description: Download a prebuilt OpenShell VM driver for E2E tests - -inputs: - artifact-name: - description: Artifact name to download, such as driver-vm-linux-amd64 - required: true +description: Download an architecture-matched prebuilt OpenShell VM driver for E2E tests runs: using: composite @@ -12,27 +7,13 @@ runs: - name: Download prebuilt VM driver uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ inputs.artifact-name }} + name: ${{ runner.arch == 'X64' && 'openshell-driver-vm-x86_64-unknown-linux-gnu' || 'openshell-driver-vm-aarch64-unknown-linux-gnu' }} path: .e2e/prebuilt-vm-driver - name: Configure prebuilt VM driver shell: bash run: | - set -euo pipefail - extract_dir="$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver/bin" - mkdir -p "$extract_dir" - archive="$(find "$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver" -maxdepth 1 -type f -name 'openshell-driver-vm-*.tar.gz' -print -quit)" - if [[ -z "$archive" ]]; then - echo "downloaded artifact is missing openshell-driver-vm-*.tar.gz" >&2 - find "$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver" -maxdepth 2 -type f -print >&2 || true - exit 1 - fi - tar -xzf "$archive" -C "$extract_dir" - driver="$extract_dir/openshell-driver-vm" - if [[ ! -f "$driver" ]]; then - echo "downloaded artifact is missing $driver" >&2 - exit 1 - fi + driver="$GITHUB_WORKSPACE/.e2e/prebuilt-vm-driver/openshell-driver-vm" chmod +x "$driver" "$driver" --version echo "OPENSHELL_VM_DRIVER_BIN=$driver" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-e2e-vm/action.yml b/.github/actions/setup-e2e-vm/action.yml new file mode 100644 index 0000000000..6164d999c5 --- /dev/null +++ b/.github/actions/setup-e2e-vm/action.yml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup E2E VM +description: Configure a bare Linux KVM host for VM E2E tests + +inputs: + mise-version: + description: mise release to install + required: false + default: v2026.4.25 + rust-cache-key: + description: Shared key for Rust test artifacts + required: false + default: e2e-vm-linux-amd64 + +runs: + using: composite + steps: + - name: Install system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + libssl-dev \ + openssh-client \ + pkg-config \ + socat \ + zstd + + - name: Enable KVM access + shell: bash + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The GitHub-hosted runner did not expose /dev/kvm" + lscpu + grep -m1 -E '^(flags|Features)' /proc/cpuinfo || true + ls -la /dev + exit 1 + fi + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --settle --name-match=kvm + ls -l /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - name: Validate VM host tools + shell: bash + run: | + command -v mke2fs + command -v mkfs.ext4 + command -v debugfs + + - uses: ./.github/actions/setup-mise + with: + version: ${{ inputs.mise-version }} + + - name: Cache Rust target and registry + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: ${{ inputs.rust-cache-key }} + cache-on-failure: "true" diff --git a/.github/actions/setup-mise/action.yml b/.github/actions/setup-mise/action.yml new file mode 100644 index 0000000000..e5a94a6801 --- /dev/null +++ b/.github/actions/setup-mise/action.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup mise +description: Install mise and the tools pinned by the repository + +inputs: + version: + description: mise release to install + required: false + default: v2026.4.25 + +runs: + using: composite + steps: + - name: Restore mise cache + id: mise-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.local/share/mise + key: mise-${{ runner.os }}-${{ runner.arch }}-${{ inputs.version }}-${{ hashFiles('mise.toml', 'mise.lock') }} + restore-keys: | + mise-${{ runner.os }}-${{ runner.arch }}-${{ inputs.version }}- + + - name: Install mise and tools + shell: bash + env: + MISE_VERSION: ${{ inputs.version }} + run: | # zizmor: ignore[github-env] PATH entries are fixed beneath the runner-controlled HOME + set -euo pipefail + curl https://mise.run | sh + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" + mise install --locked + + - name: Save mise cache + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.local/share/mise + key: ${{ steps.mise-cache.outputs.cache-primary-key }} diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 0000000000..3cd6940c05 --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup Nix +description: Install Nix and configure the OpenShell Cachix cache + +inputs: + cachix-auth-token: + description: Token used to write build outputs to Cachix + required: false + default: "" + +runs: + using: composite + steps: + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + github_access_token: ${{ github.token }} + + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: openshell + authToken: ${{ inputs.cachix-auth-token }} + skipPush: ${{ inputs.cachix-auth-token == '' }} diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..5fc0887272 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: OpenShell Rust and SDKs + +paths: + - crates + - examples + - sdk/go + - sdk/typescript/src + - python/openshell + +paths-ignore: + # Integration-test targets are not removed by the Rust cfg override. + - crates/*/tests + - python/openshell/_proto + - sdk/typescript/src/gen diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 3bc6944537..958d0b53c0 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -41,6 +41,7 @@ jobs: needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' runs-on: linux-amd64-cpu8 + timeout-minutes: 30 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -65,6 +66,7 @@ jobs: needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' runs-on: linux-amd64-cpu8 + timeout-minutes: 30 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -79,106 +81,119 @@ jobs: - name: Check license headers run: mise run license:check + cargo-deny: + name: Cargo Deny + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 30 + defaults: + run: + shell: nix develop .#devShells.x86_64-linux.default -c bash -euo pipefail {0} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: openshell + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Check dependencies + run: cargo deny check licenses bans sources + rust: - name: Rust (${{ matrix.runner }}) + name: Rust (${{ matrix.system }}) needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' strategy: fail-fast: false matrix: - runner: [linux-amd64-cpu8, linux-arm64-cpu8] + include: + - runner: linux-amd64-cpu8 + system: x86_64-linux + - runner: linux-arm64-cpu8 + system: aarch64-linux + - runner: macos-15-xlarge + system: aarch64-darwin runs-on: ${{ matrix.runner }} - env: - SCCACHE_GHA_ENABLED: "true" - SCCACHE_GHA_VERSION: branch-checks-rust-${{ matrix.runner }} - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + timeout-minutes: 30 + defaults: + run: + shell: nix develop .#devShells.${{ matrix.system }}.default -c bash -euo pipefail {0} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Configure GHA sccache backend - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} - - name: Install tools - run: mise install --locked + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: openshell + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Realize Nix development shell + shell: bash + run: nix build --no-link ".#devShells.${{ matrix.system }}.default" - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - # Keep branch-check caches partitioned by runner architecture; lint + # Keep branch-check caches partitioned by target system; lint # and test intentionally share the same job-local target directory. - shared-key: rust-checks-${{ matrix.runner }} + shared-key: rust-checks-${{ matrix.system }} # Preserve compiled artifacts from failed lint/test runs so the next # push to the same PR branch does not start from a cold cache. cache-on-failure: "true" + cache-workspace-crates: "true" + cache-bin: "false" + cmd-format: nix develop .#devShells.${{ matrix.system }}.default -c {0} - name: Format - run: mise run rust:format:check + run: | + cargo fmt --all -- --check + cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check - name: Lint - run: mise run rust:lint - - - name: Test - run: mise run test:rust - - - name: Verify telemetry can be compiled out - run: mise run rust:verify:telemetry-off - - - name: sccache stats - if: always() run: | - set +e - stats_bin="${SCCACHE_PATH:-sccache}" - "$stats_bin" --show-stats - status=$? - if [ "$status" -ne 0 ]; then - echo "::warning::sccache stats unavailable (exit $status)" - fi - exit 0 + cargo clippy --workspace --all-targets -- -D warnings + cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings + cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets - rust-macos: - name: Rust lint (macOS) - needs: pr_metadata - if: needs.pr_metadata.outputs.should_run == 'true' - runs-on: macos-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install mise + - name: Test + env: + OPENSHELL_TELEMETRY_ENABLED: "false" run: | - curl --proto '=https' --tlsv1.2 -sSf https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Configure GHA sccache backend - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 + cargo nextest run --profile ci --workspace --features openshell-server/test-support - - name: Install Rust and Clippy + - name: Verify telemetry can be compiled out run: | - mise install --locked rust - rustup component add clippy + cargo build -p openshell-gateway --bin openshell-gateway + tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway + cargo build -p openshell-gateway --bin openshell-gateway --no-default-features --features defaults-without-telemetry + tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-gateway + cargo build -p openshell-sandbox --bin openshell-sandbox --no-default-features --features defaults-without-telemetry + tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-sandbox - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - shared-key: rust-clippy-macos - cache-on-failure: "true" + - name: Verify the defaults-without-telemetry feature alias tracks the default feature set + run: tasks/scripts/verify-defaults-without-telemetry.sh - - name: Lint macOS-sensitive crates - # Formatting is target-independent and already checked by the Linux jobs. - # The full mise lint covers every workspace/E2E target and requires extra - # native dependencies such as Z3; keep this guard focused on macOS cfgs. + - name: Verify system CA roots build mode compiles and excludes bundled Mozilla roots run: | - cargo clippy \ - -p openshell-sandbox \ - -p openshell-core \ - -p openshell-cli \ - --all-targets \ - -- -D warnings + cargo check -p openshell-sandbox --all-targets --no-default-features --features system-ca-roots + if cargo tree -p openshell-sandbox -i webpki-roots --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-roots; then + echo "ERROR: webpki-roots found in system CA roots build" >&2 + exit 1 + fi + if cargo tree -p openshell-sandbox -i webpki-root-certs --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-root-certs; then + echo "ERROR: webpki-root-certs found in system CA roots build" >&2 + exit 1 + fi python: name: Python (${{ matrix.runner }}) @@ -189,6 +204,7 @@ jobs: matrix: runner: [linux-amd64-cpu8, linux-arm64-cpu8] runs-on: ${{ matrix.runner }} + timeout-minutes: 30 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -215,11 +231,32 @@ jobs: - name: Test run: mise run test:python + go: + name: Go SDK + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 30 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools + run: mise install --locked + + - name: Lint, build, test, proto-check + run: mise run go:ci + markdown: name: Markdown needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' runs-on: linux-amd64-cpu8 + timeout-minutes: 30 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -233,3 +270,33 @@ jobs: - name: Lint run: mise run markdown:lint + + sdk-typescript: + name: TypeScript SDK + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 30 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools + run: mise install --locked + + - name: Check TypeScript SDK + run: mise run sdk:ts:ci + + # Exercise the full release publish path (version stamp, dist-tag, + # prepublishOnly, tarball) without uploading. Uses the off-tag dev + # version, which validates the prerelease dist-tag branch too. + - name: Verify publishable artifact (dry-run) + env: + OPENSHELL_NPM_PUBLISH_ARGS: --dry-run + run: | + OPENSHELL_NPM_VERSION="$(uv run python tasks/scripts/release.py get-version --npm)" \ + mise run sdk:ts:publish diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index b6810fb2c4..2e316f88bd 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -26,6 +26,7 @@ jobs: run_core_e2e: ${{ steps.labels.outputs.run_core_e2e }} run_gpu_e2e: ${{ steps.labels.outputs.run_gpu_e2e }} run_kubernetes_ha_e2e: ${{ steps.labels.outputs.run_kubernetes_ha_e2e }} + run_kubernetes_credential_drivers_e2e: ${{ steps.labels.outputs.run_kubernetes_credential_drivers_e2e }} run_any_e2e: ${{ steps.labels.outputs.run_any_e2e }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -43,7 +44,8 @@ jobs: push) run_core_e2e="$(jq -r 'index("test:e2e") != null' <<< "$LABELS_JSON")" run_gpu_e2e="$(jq -r 'index("test:e2e-gpu") != null' <<< "$LABELS_JSON")" - run_kubernetes_ha_e2e="$(jq -r 'index("test:e2e-kubernetes") != null' <<< "$LABELS_JSON")" + run_kubernetes_ha_e2e=false + run_kubernetes_credential_drivers_e2e=false ;; merge_group) # Merge groups have no PR labels. When GPU E2E is required as documented @@ -52,14 +54,16 @@ jobs: run_core_e2e=true run_gpu_e2e=true run_kubernetes_ha_e2e=false + run_kubernetes_credential_drivers_e2e=false ;; *) run_core_e2e=true run_gpu_e2e=true - run_kubernetes_ha_e2e=true + run_kubernetes_ha_e2e=false + run_kubernetes_credential_drivers_e2e=false ;; esac - if [ "$run_core_e2e" = "true" ] || [ "$run_gpu_e2e" = "true" ] || [ "$run_kubernetes_ha_e2e" = "true" ]; then + if [ "$run_core_e2e" = "true" ] || [ "$run_gpu_e2e" = "true" ] || [ "$run_kubernetes_ha_e2e" = "true" ] || [ "$run_kubernetes_credential_drivers_e2e" = "true" ]; then run_any_e2e=true else run_any_e2e=false @@ -68,22 +72,188 @@ jobs: echo "run_core_e2e=$run_core_e2e" echo "run_gpu_e2e=$run_gpu_e2e" echo "run_kubernetes_ha_e2e=$run_kubernetes_ha_e2e" + echo "run_kubernetes_credential_drivers_e2e=$run_kubernetes_credential_drivers_e2e" echo "run_any_e2e=$run_any_e2e" } >> "$GITHUB_OUTPUT" - build-gateway: + version: needs: [pr_metadata] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + cargo: ${{ steps.version.outputs.cargo }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Compute version + id: version + run: | + cargo="$(python3 tasks/scripts/release.py get-version --cargo)" + echo "cargo=$cargo" >> "$GITHUB_OUTPUT" + + build-cli: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-cli-binaries.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-conformance: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' + permissions: + contents: read + strategy: + matrix: + include: + - triple: x86_64-unknown-linux-musl + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.musl + - triple: aarch64-unknown-linux-musl + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.musl + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-conformance-cli + binary: openshell-conformance + artifact-name: openshell-conformance-${{ matrix.triple }} + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-sandbox: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-sandbox-binaries.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-gateway: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-gateway-binaries.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-gateway-plain: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + strategy: + matrix: + include: + - triple: x86_64-unknown-linux-gnu + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.glibc-2-28 + interpreter: /lib64/ld-linux-x86-64.so.2 + - triple: aarch64-unknown-linux-gnu + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.glibc-2-28 + interpreter: /lib/ld-linux-aarch64.so.1 + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-gateway + binary: openshell-gateway + artifact-name: openshell-gateway-plain-${{ matrix.triple }} + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ needs.version.outputs.cargo }} + extra-cargo-flags: --no-default-features + interpreter: ${{ matrix.interpreter }} + secrets: inherit + + build-driver-docker: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-driver-docker + binary: openshell-driver-docker + triple: aarch64-unknown-linux-gnu + runner: linux-arm64-cpu8 + dev-shell: .#devShells.aarch64-linux.glibc-2-28 + cargo-version: ${{ needs.version.outputs.cargo }} + interpreter: /lib/ld-linux-aarch64.so.1 + secrets: inherit + + build-driver-podman: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-driver-podman + binary: openshell-driver-podman + triple: x86_64-unknown-linux-gnu + runner: linux-amd64-cpu8 + dev-shell: .#devShells.x86_64-linux.glibc-2-28 + cargo-version: ${{ needs.version.outputs.cargo }} + interpreter: /lib64/ld-linux-x86-64.so.2 + secrets: inherit + + build-driver-kubernetes: + needs: [pr_metadata, version] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-driver-kubernetes + binary: openshell-driver-kubernetes + triple: x86_64-unknown-linux-gnu + runner: linux-amd64-cpu8 + dev-shell: .#devShells.x86_64-linux.glibc-2-28 + cargo-version: ${{ needs.version.outputs.cargo }} + interpreter: /lib64/ld-linux-x86-64.so.2 + secrets: inherit + + build-vm-driver: + needs: [pr_metadata, version, build-sandbox] + if: needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + contents: read + uses: ./.github/workflows/build-vm-driver.yml + with: + cargo-version: ${{ needs.version.outputs.cargo }} + secrets: inherit + + build-gateway-image: + needs: [pr_metadata, build-gateway] + if: >- + needs.pr_metadata.outputs.should_run == 'true' && + (needs.pr_metadata.outputs.run_core_e2e == 'true' || + needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true' || + needs.pr_metadata.outputs.run_kubernetes_credential_drivers_e2e == 'true') permissions: contents: read packages: write uses: ./.github/workflows/docker-build.yml with: component: gateway - image-tag: ${{ github.sha }} + binary: openshell-gateway + target-suffix: unknown-linux-gnu + secrets: inherit - build-supervisor: - needs: [pr_metadata] + build-supervisor-image: + needs: [pr_metadata, build-sandbox] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' permissions: contents: read @@ -91,50 +261,95 @@ jobs: uses: ./.github/workflows/docker-build.yml with: component: supervisor - image-tag: ${{ github.sha }} + binary: openshell-sandbox + target-suffix: unknown-linux-musl + secrets: inherit - build-cli: - needs: [pr_metadata] - if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_any_e2e == 'true' + docker-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' permissions: + actions: read contents: read - packages: write - uses: ./.github/workflows/docker-build.yml + packages: read + uses: ./.github/workflows/e2e-docker-test.yml with: - component: cli - platform: linux/amd64,linux/arm64 + image-tag: ${{ github.sha }} + runner: linux-arm64-cpu8 + conformance-artifact-prefix: openshell-conformance - build-driver-vm-linux: - needs: [pr_metadata] + podman-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-supervisor-image] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' permissions: + actions: read contents: read packages: read - uses: ./.github/workflows/driver-vm-linux.yml + uses: ./.github/workflows/e2e-podman-test.yml with: image-tag: ${{ github.sha }} - checkout-ref: ${{ github.sha }} - runtime-platforms: linux-x86_64 - driver-targets: >- - [{"arch":"amd64","runner":"linux-amd64-cpu8","target":"x86_64-unknown-linux-gnu","zig_target":"x86_64-unknown-linux-gnu.2.28","platform":"linux-x86_64","guest_arch":"x86_64"}] + conformance-artifact-prefix: openshell-conformance - e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux] + vm-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-vm-driver] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' permissions: actions: read contents: read packages: read - uses: ./.github/workflows/e2e-test.yml + uses: ./.github/workflows/e2e-vm-test.yml + with: + conformance-artifact-prefix: openshell-conformance + + docker-external-driver-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway-plain, build-driver-docker, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-docker-test.yml with: image-tag: ${{ github.sha }} runner: linux-arm64-cpu8 - cli-artifact-prefix: rust-binary-cli - gateway-artifact-prefix: rust-binary-gateway - vm-driver-artifact-name: driver-vm-linux-amd64 + 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}] + + podman-external-driver-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway-plain, build-driver-podman, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-podman-test.yml + with: + 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"}] + + vm-external-driver-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway-plain, build-vm-driver] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-vm-test.yml + with: + 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-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, build-conformance, build-gateway, build-supervisor-image] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_gpu_e2e == 'true' permissions: actions: read @@ -143,11 +358,10 @@ jobs: uses: ./.github/workflows/e2e-gpu-test.yaml with: image-tag: ${{ github.sha }} - cli-artifact-prefix: rust-binary-cli - gateway-artifact-prefix: rust-binary-gateway + conformance-artifact-prefix: openshell-conformance kubernetes-e2e: - needs: [pr_metadata, build-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' strategy: fail-fast: false @@ -175,10 +389,55 @@ jobs: job-name: Kubernetes E2E (Rust smoke, ${{ matrix.topology }}, Agent Sandbox ${{ matrix.agent_sandbox_api }}) agent-sandbox-version: ${{ matrix.agent_sandbox_version }} extra-helm-values: ${{ matrix.extra_helm_values }} - cli-artifact-prefix: rust-binary-cli + conformance-artifact-prefix: openshell-conformance + + kubernetes-workspace-managed-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-kubernetes-test.yml + with: + 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-cli, build-conformance, build-gateway-plain, build-driver-kubernetes, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-kubernetes-test.yml + with: + image-tag: ${{ github.sha }} + job-name: Kubernetes E2E (external compute driver) + e2e-task: e2e:kubernetes:external-driver + gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu + external-driver-binary: openshell-driver-kubernetes + cluster-images: supervisor + conformance-artifact-prefix: openshell-conformance + + kubernetes-workspace-operator-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-kubernetes-test.yml + with: + 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-gateway, build-supervisor, build-cli] + needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true' permissions: actions: read @@ -190,95 +449,70 @@ jobs: job-name: Kubernetes HA E2E (Rust smoke) extra-helm-values: deploy/helm/openshell/ci/values-high-availability.yaml external-postgres-secret: openshell-ha-pg - cli-artifact-prefix: rust-binary-cli + conformance-artifact-prefix: openshell-conformance + + kubernetes-credential-drivers-e2e: + needs: [pr_metadata, build-cli, build-conformance, build-gateway-image, build-supervisor-image] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_credential_drivers_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-kubernetes-test.yml + with: + 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 - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux, e2e, kubernetes-e2e] + needs: [pr_metadata, docker-e2e, podman-e2e, vm-e2e, docker-external-driver-e2e, podman-external-driver-e2e, vm-external-driver-e2e, kubernetes-e2e, kubernetes-external-driver-e2e, kubernetes-workspace-managed-e2e, kubernetes-workspace-operator-e2e] if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' runs-on: ubuntu-latest steps: - - name: Verify core E2E jobs - env: - BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }} - BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }} - BUILD_CLI_RESULT: ${{ needs.build-cli.result }} - BUILD_DRIVER_VM_RESULT: ${{ needs.build-driver-vm-linux.result }} - E2E_RESULT: ${{ needs.e2e.result }} - KUBERNETES_E2E_RESULT: ${{ needs.kubernetes-e2e.result }} - run: | - set -euo pipefail - failed=0 - for item in \ - "build-gateway:$BUILD_GATEWAY_RESULT" \ - "build-supervisor:$BUILD_SUPERVISOR_RESULT" \ - "build-cli:$BUILD_CLI_RESULT" \ - "build-driver-vm-linux:$BUILD_DRIVER_VM_RESULT" \ - "e2e:$E2E_RESULT" \ - "kubernetes-e2e:$KUBERNETES_E2E_RESULT"; do - name="${item%%:*}" - result="${item#*:}" - if [ "$result" != "success" ]; then - echo "::error::$name concluded $result" - failed=1 - fi - done - exit "$failed" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} gpu-e2e-result: name: GPU E2E result - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, gpu-e2e] + needs: [pr_metadata, gpu-e2e] if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_gpu_e2e == 'true' runs-on: ubuntu-latest steps: - - name: Verify GPU E2E jobs - env: - BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }} - BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }} - BUILD_CLI_RESULT: ${{ needs.build-cli.result }} - GPU_E2E_RESULT: ${{ needs.gpu-e2e.result }} - run: | - set -euo pipefail - failed=0 - for item in \ - "build-gateway:$BUILD_GATEWAY_RESULT" \ - "build-supervisor:$BUILD_SUPERVISOR_RESULT" \ - "build-cli:$BUILD_CLI_RESULT" \ - "gpu-e2e:$GPU_E2E_RESULT"; do - name="${item%%:*}" - result="${item#*:}" - if [ "$result" != "success" ]; then - echo "::error::$name concluded $result" - failed=1 - fi - done - exit "$failed" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} kubernetes-ha-e2e-result: name: Kubernetes HA E2E result - needs: [pr_metadata, build-gateway, build-supervisor, build-cli, kubernetes-ha-e2e] + needs: [pr_metadata, kubernetes-ha-e2e] if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true' runs-on: ubuntu-latest steps: - - name: Verify Kubernetes HA E2E jobs - env: - BUILD_GATEWAY_RESULT: ${{ needs.build-gateway.result }} - BUILD_SUPERVISOR_RESULT: ${{ needs.build-supervisor.result }} - BUILD_CLI_RESULT: ${{ needs.build-cli.result }} - KUBERNETES_HA_E2E_RESULT: ${{ needs.kubernetes-ha-e2e.result }} - run: | - set -euo pipefail - failed=0 - for item in \ - "build-gateway:$BUILD_GATEWAY_RESULT" \ - "build-supervisor:$BUILD_SUPERVISOR_RESULT" \ - "build-cli:$BUILD_CLI_RESULT" \ - "kubernetes-ha-e2e:$KUBERNETES_HA_E2E_RESULT"; do - name="${item%%:*}" - result="${item#*:}" - if [ "$result" != "success" ]; then - echo "::error::$name concluded $result" - failed=1 - fi - done - exit "$failed" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} + + kubernetes-credential-drivers-e2e-result: + name: Kubernetes Credential Drivers E2E result + needs: [pr_metadata, kubernetes-credential-drivers-e2e] + if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_credential_drivers_e2e == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000000..1305108fdf --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Binary + +on: + workflow_call: + inputs: + package: + required: true + type: string + binary: + required: true + type: string + triple: + required: true + type: string + runner: + required: true + type: string + dev-shell: + required: true + type: string + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + artifact-name: + required: false + type: string + default: "" + extra-cargo-flags: + required: false + type: string + default: "" + interpreter: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + name: ${{ inputs.binary }} (${{ inputs.triple }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['checkout-ref'] || github.sha }} + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: ./.github/actions/build-rust-binary + with: + package: ${{ inputs.package }} + binary: ${{ inputs.binary }} + triple: ${{ inputs.triple }} + dev-shell: ${{ inputs['dev-shell'] }} + cargo-version: ${{ inputs['cargo-version'] }} + image-tag: ${{ inputs['image-tag'] }} + artifact-name: ${{ inputs['artifact-name'] }} + extra-cargo-flags: ${{ inputs['extra-cargo-flags'] }} + interpreter: ${{ inputs.interpreter }} diff --git a/.github/workflows/build-cli-binaries.yml b/.github/workflows/build-cli-binaries.yml new file mode 100644 index 0000000000..b8fa96dd7d --- /dev/null +++ b/.github/workflows/build-cli-binaries.yml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build CLI Binaries + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + include: + - triple: x86_64-unknown-linux-musl + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.musl + - triple: aarch64-unknown-linux-musl + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.musl + - triple: aarch64-apple-darwin + runner: macos-15-xlarge + dev_shell: .#devShells.aarch64-darwin.default + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-cli + binary: openshell + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ inputs.cargo-version }} + image-tag: ${{ inputs.image-tag }} + checkout-ref: ${{ inputs.checkout-ref }} + secrets: inherit diff --git a/.github/workflows/build-gateway-binaries.yml b/.github/workflows/build-gateway-binaries.yml new file mode 100644 index 0000000000..391e421080 --- /dev/null +++ b/.github/workflows/build-gateway-binaries.yml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Gateway Binaries + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + include: + - triple: x86_64-unknown-linux-gnu + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.glibc-2-28 + interpreter: /lib64/ld-linux-x86-64.so.2 + - triple: aarch64-unknown-linux-gnu + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.glibc-2-28 + interpreter: /lib/ld-linux-aarch64.so.1 + - triple: aarch64-apple-darwin + runner: macos-15-xlarge + dev_shell: .#devShells.aarch64-darwin.default + interpreter: "" + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-gateway + binary: openshell-gateway + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ inputs.cargo-version }} + image-tag: ${{ inputs.image-tag }} + interpreter: ${{ matrix.interpreter }} + checkout-ref: ${{ inputs.checkout-ref }} + secrets: inherit diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml new file mode 100644 index 0000000000..71aa04dddf --- /dev/null +++ b/.github/workflows/build-rpm.yml @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build RPM + +on: + workflow_call: + inputs: + checkout-ref: + required: true + type: string + arch: + required: true + type: string + runner: + required: true + type: string + cli-target: + required: true + type: string + gateway-target: + required: true + type: string + rpm-version: + required: false + type: string + default: "" + rpm-release: + required: false + type: string + default: "" + cargo-version: + required: false + type: string + default: "" + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + build: + name: Build RPM Package (Linux ${{ inputs.arch }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 60 + container: + image: docker.io/library/fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80 + steps: + - name: Install packaging dependencies + run: | + dnf install -y \ + packit rpm-build \ + cargo cargo-rpm-macros git-core \ + pandoc python3-devel systemd-rpm-macros + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref }} + fetch-depth: 0 + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: rpm-vendor-${{ inputs.arch }} + cache-targets: "false" + cache-bin: "false" + cache-on-failure: "true" + + - name: Download CLI artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-${{ inputs.cli-target }} + path: package-binaries/ + + - name: Download gateway artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-gateway-${{ inputs.gateway-target }} + path: package-binaries/ + + - name: Configure package inputs + run: | + set -euo pipefail + chmod +x package-binaries/openshell{,-gateway} + ls -lah package-binaries + + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Fetch tags + run: git fetch --tags --force + + - name: Build RPMs via Packit + env: + OPENSHELL_RPM_VERSION: ${{ inputs.rpm-version }} + OPENSHELL_RPM_RELEASE: ${{ inputs.rpm-release }} + OPENSHELL_CARGO_VERSION: ${{ inputs.cargo-version }} + OPENSHELL_PREBUILT_BINARIES_DIR: ${{ github.workspace }}/package-binaries + run: packit build locally + + - name: Collect RPM artifacts + run: | + set -euo pipefail + mkdir -p artifacts + mapfile -t rpms < <(find "$GITHUB_WORKSPACE" -maxdepth 3 -type f -name '*.rpm' ! -name '*.src.rpm' | sort) + if [ "${#rpms[@]}" -eq 0 ]; then + echo "::error::No RPM artifacts found under $GITHUB_WORKSPACE" + find "$GITHUB_WORKSPACE" -maxdepth 3 -type f | sort + exit 1 + fi + cp "${rpms[@]}" artifacts/ + echo "=== Built RPMs ===" + ls -lah artifacts/ + + - name: Upload RPM artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rpm-linux-${{ inputs.arch }} + path: artifacts/*.rpm + retention-days: 5 diff --git a/.github/workflows/build-sandbox-binaries.yml b/.github/workflows/build-sandbox-binaries.yml new file mode 100644 index 0000000000..53f81bec40 --- /dev/null +++ b/.github/workflows/build-sandbox-binaries.yml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build Sandbox Binaries + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + include: + - triple: x86_64-unknown-linux-musl + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.musl + - triple: aarch64-unknown-linux-musl + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.musl + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-sandbox + binary: openshell-sandbox + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ inputs.cargo-version }} + image-tag: ${{ inputs.image-tag }} + checkout-ref: ${{ inputs.checkout-ref }} + secrets: inherit diff --git a/.github/workflows/build-vm-driver.yml b/.github/workflows/build-vm-driver.yml new file mode 100644 index 0000000000..9df710cb82 --- /dev/null +++ b/.github/workflows/build-vm-driver.yml @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build VM Driver + +on: + workflow_call: + inputs: + cargo-version: + required: true + type: string + image-tag: + required: false + type: string + default: "" + checkout-ref: + required: false + type: string + default: "" + secrets: + CACHIX_AUTH_TOKEN: + required: true + +permissions: + contents: read + +jobs: + build: + name: openshell-driver-vm (${{ matrix.triple }}) + strategy: + matrix: + include: + - arch: x86_64 + triple: x86_64-unknown-linux-gnu + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.glibc-2-28 + interpreter: /lib64/ld-linux-x86-64.so.2 + - arch: aarch64 + triple: aarch64-unknown-linux-gnu + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.glibc-2-28 + interpreter: /lib/ld-linux-aarch64.so.1 + - arch: aarch64 + triple: aarch64-apple-darwin + runner: macos-15-xlarge + dev_shell: .#devShells.aarch64-darwin.default + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + defaults: + run: + shell: nix develop ${{ matrix.dev_shell }} -c bash -euo pipefail {0} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['checkout-ref'] || github.sha }} + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Download openshell-sandbox + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sandbox-${{ matrix.arch }}-unknown-linux-musl + path: sandbox + + - name: Build VM runtime + run: nix build .#vm-runtime + + - name: Assemble compressed VM runtime + run: | + compressed_dir="${RUNNER_TEMP}/vm-runtime-compressed" + install -d "$compressed_dir" + cp result/compressed/*.zst "$compressed_dir/" + zstd -19 -T1 sandbox/openshell-sandbox -o "$compressed_dir/openshell-sandbox.zst" + + - name: Build openshell-driver-vm + uses: ./.github/actions/build-rust-binary + env: + OPENSHELL_VM_RUNTIME_COMPRESSED_DIR: ${{ runner.temp }}/vm-runtime-compressed + with: + package: openshell-driver-vm + binary: openshell-driver-vm + triple: ${{ matrix.triple }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ inputs['cargo-version'] }} + image-tag: ${{ inputs['image-tag'] }} + interpreter: ${{ matrix.interpreter }} diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml new file mode 100644 index 0000000000..2756215cb0 --- /dev/null +++ b/.github/workflows/cargo-deny.yml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Cargo Deny (scheduled) + +on: + schedule: + - cron: "23 7 * * *" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +permissions: + contents: read + packages: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + cargo-deny: + name: Cargo Deny + runs-on: linux-amd64-cpu8 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools + run: mise install --locked + + - name: Check dependencies + run: mise run rust:deny diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..bd9aef1d89 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: CodeQL + +on: + schedule: + - cron: "29 5 * * *" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + # Keep Rust test fixtures out of production-focused security results. + CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES: "-test" + strategy: + fail-fast: false + matrix: + include: + - language: rust + build-mode: none + - language: go + build-mode: manual + - language: python + build-mode: none + - language: javascript-typescript + build-mode: none + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + if: matrix.language == 'go' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: sdk/go/go.mod + cache-dependency-path: sdk/go/go.sum + + - name: Initialize CodeQL + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Build Go SDK + if: matrix.language == 'go' + working-directory: sdk/go + run: go build ./... + + - name: Analyze + id: analyze + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + category: /language:${{ matrix.language }} + output: codeql-results + upload: never + + - name: Summarize findings + if: always() + env: + LANGUAGE: ${{ matrix.language }} + shell: bash + run: | + set -euo pipefail + shopt -s globstar nullglob + sarif_files=(codeql-results/**/*.sarif) + + { + echo "### CodeQL: $LANGUAGE" + echo + if [ "${#sarif_files[@]}" -eq 0 ]; then + echo "No SARIF report was produced." + else + finding_count=$(jq -s '[.[].runs[]?.results[]?] | length' "${sarif_files[@]}") + echo "Findings: $finding_count" + echo + echo "Findings are informational and do not fail CI." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload SARIF to Code Scanning + if: steps.analyze.outcome == 'success' + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: codeql-results + category: /language:${{ matrix.language }} + + - name: Upload SARIF + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: codeql-${{ matrix.language }}-${{ github.run_id }} + path: codeql-results + if-no-files-found: ignore + retention-days: 14 + + result: + name: OpenShell / CodeQL (informational) + if: always() + needs: analyze + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Evaluate analyzer execution + env: + ANALYZE_RESULT: ${{ needs.analyze.result }} + shell: bash + run: | + if [ "$ANALYZE_RESULT" != "success" ]; then + echo "::error::One or more CodeQL analyzers did not complete successfully." + exit 1 + fi + echo "All CodeQL analyzers completed; findings remain informational." diff --git a/.github/workflows/codex-security.yml b/.github/workflows/codex-security.yml new file mode 100644 index 0000000000..a008af019f --- /dev/null +++ b/.github/workflows/codex-security.yml @@ -0,0 +1,286 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Codex Security Release Qualification + +on: + push: + tags: + - "v*.*.*-pre.*" + workflow_call: + inputs: + candidate_ref: + description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N) + required: true + type: string + stable_ref: + description: Optional previous stable tag override + required: false + default: "" + type: string + allow_full_bootstrap: + description: Allow a full scan when no previous stable tag exists + required: false + default: false + type: boolean + outputs: + base_sha: + description: Previous stable commit, empty for a full bootstrap scan + value: ${{ jobs.analyze.outputs.base_sha }} + candidate_sha: + description: Qualified pre-release commit + value: ${{ jobs.analyze.outputs.candidate_sha }} + category: + description: Code Scanning category for the release train + value: ${{ jobs.analyze.outputs.category }} + train: + description: Stable version targeted by the pre-release train + value: ${{ jobs.analyze.outputs.train }} + secrets: + CODEX_SECURITY_API_KEY: + required: true + workflow_dispatch: + inputs: + candidate_ref: + description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N) + required: true + type: string + stable_ref: + description: Optional previous stable tag override + required: false + type: string + upload_sarif: + description: Upload manual-run results to Code Scanning + required: false + default: false + type: boolean + allow_full_bootstrap: + description: Allow a full scan when no previous stable tag exists + required: false + default: false + type: boolean + +permissions: + actions: read + contents: read + security-events: write + +concurrency: + group: codex-security-release-qualification + cancel-in-progress: true + +env: + CODEX_SECURITY_MAX_CONCURRENT_THREADS: "8" + CODEX_SECURITY_REASONING_EFFORT: medium + NVIDIA_INFERENCE_BASE_URL: https://inference-api.nvidia.com/v1 + NVIDIA_INFERENCE_MODEL: openai/openai/gpt-5.6-sol + +jobs: + analyze: + name: Codex Security (${{ inputs.candidate_ref || github.ref_name }}) + # The agent executes no shell commands on the repository self-hosted runner, + # so its preflight never scopes the diff and it seals no draft. + runs-on: ubuntu-latest + timeout-minutes: 120 + outputs: + base_sha: ${{ steps.range.outputs.base_sha }} + candidate_sha: ${{ steps.range.outputs.candidate_sha }} + category: ${{ steps.range.outputs.category }} + train: ${{ steps.range.outputs.train }} + steps: + # Install the trusted scanner before repository-controlled files exist in + # the workspace, and invoke it later through its absolute path. + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "26" + package-manager-cache: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + # Codex confines model-run commands with bubblewrap, which needs + # unprivileged user namespaces. Ubuntu 24.04 restricts those through + # AppArmor, so bubblewrap cannot set up the sandbox network namespace and + # the scan agent executes nothing at all. + - name: Allow unprivileged user namespaces + run: | + set -euo pipefail + if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Install Codex Security + run: | + set -euo pipefail + npm install \ + --prefix "$RUNNER_TEMP/codex-security" \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + @openai/codex-security@0.1.24 + + - name: Verify Codex Security + env: + CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security + run: | + set -euo pipefail + test -x "$CODEX_SECURITY_BIN" + "$CODEX_SECURITY_BIN" --version + + # The range resolver has to come from the workflow's own revision. A + # scanned candidate predates it, and running the resolver from the + # revision under scan would let that revision pick its own scan range. + - name: Check out the workflow revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: workflow-revision + sparse-checkout: tasks/scripts + persist-credentials: false + + - name: Stage the range resolver + run: | + set -euo pipefail + install -d -m 700 "$RUNNER_TEMP/range-resolver" + cp workflow-revision/tasks/scripts/release.py \ + workflow-revision/tasks/scripts/codex_security_range.py \ + "$RUNNER_TEMP/range-resolver/" + rm -rf workflow-revision + + - name: Check out the pre-release + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.candidate_ref || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Resolve release range + id: range + env: + ALLOW_FULL_BOOTSTRAP: ${{ inputs.allow_full_bootstrap || false }} + CANDIDATE_REF: ${{ inputs.candidate_ref || github.ref_name }} + STABLE_REF: ${{ inputs.stable_ref || '' }} + run: | + set -euo pipefail + git show-ref --verify --quiet refs/remotes/origin/main + + args=( + --candidate "$CANDIDATE_REF" + --main-ref origin/main + ) + if [ -n "$STABLE_REF" ]; then + args+=(--stable "$STABLE_REF") + fi + if [ "$ALLOW_FULL_BOOTSTRAP" = "true" ]; then + args+=(--allow-full-bootstrap) + fi + + python3 "$RUNNER_TEMP/range-resolver/codex_security_range.py" "${args[@]}" + + - name: Scan changes since the previous stable + env: + BASE_SHA: ${{ steps.range.outputs.base_sha }} + CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security + CODEX_SECURITY_STATE_DIR: ${{ runner.temp }}/codex-security-state-${{ github.run_id }}-${{ github.run_attempt }} + HEAD_SHA: ${{ steps.range.outputs.candidate_sha }} + NVIDIA_INFERENCE_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} + OPENAI_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} + SCAN_DIR: ${{ runner.temp }}/codex-security-results-${{ github.run_id }}-${{ github.run_attempt }} + SCAN_SCOPE: ${{ steps.range.outputs.scan_scope }} + run: | + set -euo pipefail + install -d -m 700 "$CODEX_SECURITY_STATE_DIR" "$SCAN_DIR" + + target_args=() + if [ "$SCAN_SCOPE" = "diff" ]; then + target_args=(--diff "$BASE_SHA" --head "$HEAD_SHA") + elif [ "$SCAN_SCOPE" != "full" ]; then + echo "::error::Unsupported Codex Security scan scope: $SCAN_SCOPE" + exit 2 + fi + + "$CODEX_SECURITY_BIN" scan . \ + "${target_args[@]}" \ + --auth api-key \ + --model "$NVIDIA_INFERENCE_MODEL" \ + --effort "$CODEX_SECURITY_REASONING_EFFORT" \ + --codex 'model_provider="nvidia"' \ + --codex 'model_providers.nvidia.name="NVIDIA Inference"' \ + --codex "model_providers.nvidia.base_url=\"$NVIDIA_INFERENCE_BASE_URL\"" \ + --codex 'model_providers.nvidia.env_key="NVIDIA_INFERENCE_API_KEY"' \ + --codex 'model_providers.nvidia.wire_api="responses"' \ + --codex 'model_providers.nvidia.supports_websockets=false' \ + --codex "features.multi_agent_v2.max_concurrent_threads_per_session=$CODEX_SECURITY_MAX_CONCURRENT_THREADS" \ + --codex 'approval_policy="never"' \ + --output-dir "$SCAN_DIR" \ + --headless > /dev/null + + - name: Export SARIF + env: + CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security + SARIF_FILE: ${{ runner.temp }}/codex-security.sarif + SCAN_DIR: ${{ runner.temp }}/codex-security-results-${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + "$CODEX_SECURITY_BIN" export "$SCAN_DIR" \ + --export-format sarif \ + --source-root "$GITHUB_WORKSPACE" \ + --output "$SARIF_FILE" + + - name: Summarize findings + env: + BASE_TAG: ${{ steps.range.outputs.base_tag }} + CANDIDATE_TAG: ${{ steps.range.outputs.candidate_tag }} + COMMIT_COUNT: ${{ steps.range.outputs.commit_count }} + SARIF_FILE: ${{ runner.temp }}/codex-security.sarif + SCAN_SCOPE: ${{ steps.range.outputs.scan_scope }} + TRAIN: ${{ steps.range.outputs.train }} + run: | + set -euo pipefail + finding_count=$(jq '[.runs[]?.results[]?] | length' "$SARIF_FILE") + { + echo "### Codex Security release qualification" + echo + echo "- Train: \`$TRAIN\`" + echo "- Candidate: \`$CANDIDATE_TAG\`" + echo "- Maximum concurrent agent threads: $CODEX_SECURITY_MAX_CONCURRENT_THREADS" + if [ "$SCAN_SCOPE" = "diff" ]; then + echo "- Previous stable: \`$BASE_TAG\`" + echo "- Commits in cumulative diff: $COMMIT_COUNT" + else + echo "- Scope: approved full bootstrap scan" + fi + echo "- Coverage: complete" + echo "- Findings: $finding_count" + echo + echo "Findings are informational during the observation phase." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload SARIF to Code Scanning + if: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_sarif }} + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: ${{ runner.temp }}/codex-security.sarif + ref: refs/heads/main + sha: ${{ steps.range.outputs.candidate_sha }} + category: ${{ steps.range.outputs.category }} + + result: + name: OpenShell / Codex Security (informational) + if: ${{ always() }} + needs: analyze + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Evaluate scanner execution + env: + ANALYZE_RESULT: ${{ needs.analyze.result }} + run: | + set -euo pipefail + if [ "$ANALYZE_RESULT" != "success" ]; then + echo "::error::Codex Security release qualification did not complete successfully." + exit 1 + fi + echo "Codex Security completed; findings remain informational." diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000000..775eb9f83e --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,183 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Conformance + +on: + workflow_dispatch: {} + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr_metadata: + name: Resolve PR metadata + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + should_run: ${{ steps.gate.outputs.should_run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - id: gate + uses: ./.github/actions/pr-gate + + version: + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + cargo: ${{ steps.version.outputs.cargo }} + rpm_version: ${{ steps.version.outputs.rpm_version }} + rpm_release: ${{ steps.version.outputs.rpm_release }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Compute versions + id: version + run: | + cargo="$(python3 tasks/scripts/release.py get-version --cargo)" + rpm_version="$(python3 tasks/scripts/release.py get-version --rpm-version)" + rpm_release="$(python3 tasks/scripts/release.py get-version --rpm-release)" + { + echo "cargo=$cargo" + echo "rpm_version=$rpm_version" + echo "rpm_release=$rpm_release" + } >> "$GITHUB_OUTPUT" + + build-cli: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-cli + binary: openshell + triple: x86_64-unknown-linux-musl + runner: linux-amd64-cpu8 + dev-shell: .#devShells.x86_64-linux.musl + cargo-version: ${{ needs.version.outputs.cargo }} + checkout-ref: ${{ github.sha }} + secrets: inherit + + build-conformance: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-conformance-cli + binary: openshell-conformance + triple: x86_64-unknown-linux-musl + runner: linux-amd64-cpu8 + dev-shell: .#devShells.x86_64-linux.musl + cargo-version: ${{ needs.version.outputs.cargo }} + checkout-ref: ${{ github.sha }} + secrets: inherit + + build-gateway: + needs: version + permissions: + contents: read + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-gateway + binary: openshell-gateway + triple: x86_64-unknown-linux-gnu + runner: linux-amd64-cpu8 + dev-shell: .#devShells.x86_64-linux.glibc-2-28 + cargo-version: ${{ needs.version.outputs.cargo }} + image-tag: dev + interpreter: /lib64/ld-linux-x86-64.so.2 + checkout-ref: ${{ github.sha }} + secrets: inherit + + build-rpm: + needs: [version, build-cli, build-gateway] + permissions: + contents: read + uses: ./.github/workflows/build-rpm.yml + with: + checkout-ref: ${{ github.sha }} + arch: x86_64 + runner: linux-amd64-cpu8 + cli-target: x86_64-unknown-linux-musl + gateway-target: x86_64-unknown-linux-gnu + cargo-version: ${{ needs.version.outputs.cargo }} + rpm-version: ${{ needs.version.outputs.rpm_version }} + rpm-release: ${{ needs.version.outputs.rpm_release }} + + fedora: + name: Fedora with Rootless Podman + needs: [build-conformance, build-rpm] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Enable KVM access + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The runner did not expose /dev/kvm" + exit 1 + fi + sudo chmod 0666 /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - uses: ./.github/actions/setup-nix + + - name: Download RPM artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rpm-linux-x86_64 + path: rpm-input + + - name: Download conformance CLI + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-conformance-x86_64-unknown-linux-musl + path: conformance-input + + - name: Run RPM gateway continuity conformance + shell: bash + run: | + set -euo pipefail + chmod +x conformance-input/openshell-conformance + shopt -s nullglob + candidate_cli_package=(rpm-input/openshell-[0-9]*.rpm) + candidate_gateway_package=(rpm-input/openshell-gateway-[0-9]*.rpm) + if [[ ${#candidate_cli_package[@]} -ne 1 || ${#candidate_gateway_package[@]} -ne 1 ]]; then + echo "expected one candidate CLI and gateway RPM" >&2 + printf 'RPM artifacts:\n' >&2 + printf ' %s\n' rpm-input/* >&2 + exit 1 + fi + + OPENSHELL_TEST_GUEST_CACHE_DISABLE=1 nix run .#test-guest -- \ + --distro fedora \ + --with podman-rootless \ + --with selinux \ + --copy "${candidate_cli_package[0]}:/var/lib/openshell-conformance/candidate/openshell.rpm" \ + --copy "${candidate_gateway_package[0]}:/var/lib/openshell-conformance/candidate/openshell-gateway.rpm" \ + --copy conformance-input/openshell-conformance:/tmp/openshell-conformance \ + --copy nix/test-guest/conformance-plans/gateway-upgrade-restart.toml:/tmp/conformance-plan.toml \ + --provision openshell-rpm-latest-release \ + --provision gateway-rootless-podman \ + --provision openshell-rpm-gateway-upgrade \ + -- /tmp/openshell-conformance run --plan /tmp/conformance-plan.toml diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml index 3929203909..7008acd4ed 100644 --- a/.github/workflows/deb-package.yml +++ b/.github/workflows/deb-package.yml @@ -49,28 +49,25 @@ jobs: - name: Download CLI artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-linux-${{ matrix.arch }} - path: package-input/ + name: openshell-${{ matrix.cli_target }} + path: package-binaries/ - name: Download gateway artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: gateway-binary-linux-${{ matrix.arch }} - path: package-input/ + name: openshell-gateway-${{ matrix.gnu_target }} + path: package-binaries/ - name: Download VM driver artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: driver-vm-linux-${{ matrix.arch }} - path: package-input/ + name: openshell-driver-vm-${{ matrix.gnu_target }} + path: package-binaries/ - - name: Extract package inputs + - name: Configure package inputs run: | set -euo pipefail - mkdir -p package-binaries - tar -xzf "package-input/openshell-${{ matrix.cli_target }}.tar.gz" -C package-binaries - tar -xzf "package-input/openshell-gateway-${{ matrix.gnu_target }}.tar.gz" -C package-binaries - tar -xzf "package-input/openshell-driver-vm-${{ matrix.gnu_target }}.tar.gz" -C package-binaries + chmod +x package-binaries/openshell{,-gateway,-driver-vm} ls -lah package-binaries - name: Build Debian package @@ -79,10 +76,12 @@ jobs: OPENSHELL_CLI_BINARY="${PWD}/package-binaries/openshell" \ OPENSHELL_GATEWAY_BINARY="${PWD}/package-binaries/openshell-gateway" \ OPENSHELL_DRIVER_VM_BINARY="${PWD}/package-binaries/openshell-driver-vm" \ - OPENSHELL_DEB_VERSION="${{ inputs['deb-version'] }}" \ + OPENSHELL_DEB_VERSION="${INPUTS_DEB_VERSION}" \ OPENSHELL_DEB_ARCH="${{ matrix.deb_arch }}" \ OPENSHELL_OUTPUT_DIR=artifacts \ tasks/scripts/package-deb.sh + env: + INPUTS_DEB_VERSION: ${{ inputs['deb-version'] }} - name: Upload Debian package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000000..fc162fd2cb --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Dependency Review + +on: + pull_request: + merge_group: + types: [checks_requested] + workflow_dispatch: + inputs: + base_sha: + description: Base commit SHA to compare + required: true + type: string + head_sha: + description: Head commit SHA to compare + required: true + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + review: + name: Dependency Review (informational) + runs-on: ubuntu-latest + env: + BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || inputs.base_sha }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || inputs.head_sha }} + steps: + - name: Check Dependency Graph availability + id: preflight + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const shaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; + const baseRef = process.env.BASE_REF; + const headRef = process.env.HEAD_REF; + + if (!shaPattern.test(baseRef) || !shaPattern.test(headRef)) { + core.setFailed("Dependency Review requires base and head commit SHAs."); + return; + } + + try { + await github.request( + "GET /repos/{owner}/{repo}/dependency-graph/compare/{basehead}", + { + owner: context.repo.owner, + repo: context.repo.repo, + basehead: `${baseRef}...${headRef}`, + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + core.setOutput("available", "true"); + } catch (error) { + const status = error.status; + if (status === 403 || status === 404) { + core.setOutput("available", "false"); + core.warning( + `GitHub Dependency Graph is unavailable (HTTP ${status}); Dependency Review is skipped.`, + ); + await core.summary + .addHeading("Dependency Review", 3) + .addRaw(`GitHub Dependency Graph is unavailable (HTTP ${status}).`, true) + .addRaw( + "The informational review will start automatically once the repository feature is available.", + true, + ) + .write(); + return; + } + + const statusSuffix = status ? ` with HTTP ${status}` : ""; + core.setFailed( + `Dependency Graph preflight failed${statusSuffix}: ${error.message}`, + ); + } + + - name: Review dependency changes + if: steps.preflight.outputs.available == 'true' + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + base-ref: ${{ env.BASE_REF }} + head-ref: ${{ env.HEAD_REF }} + fail-on-severity: high + fail-on-scopes: runtime, development, unknown + warn-only: true + comment-summary-in-pr: never + license-check: false + show-openssf-scorecard: false diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d0d600e58b..d27ba1e984 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,305 +1,94 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + name: Docker Build on: workflow_call: inputs: component: - description: "Component to build (gateway, supervisor, cli)" required: true type: string - timeout-minutes: - description: "Per-arch Docker image job timeout in minutes" - required: false - type: number - default: 20 - push: - description: "Push image to registry" - required: false - type: boolean - default: true - platform: - description: "Target platform(s) for Docker build" - required: false - type: string - default: "linux/amd64,linux/arm64" - runner: - description: "Deprecated; per-arch native runners are selected automatically" - required: false + binary: + required: true type: string - default: "linux-amd64-cpu8" - cargo-version: - description: "Pre-computed cargo version (skips internal git-based computation)" - required: false + target-suffix: + required: true type: string - default: "" image-tag: - description: "Image tag base to build/push (defaults to the GitHub SHA)" required: false type: string default: "" checkout-ref: - description: "Git ref to check out for build inputs (defaults to the workflow SHA)" required: false type: string default: "" - publish-manifest: - description: "Push the bare-SHA manifest. Set false for single-arch branch workflows." - required: false - type: boolean - default: true - -env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} permissions: contents: read packages: write -defaults: - run: - shell: bash - jobs: - resolve: - name: Resolve build plan - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.resolve.outputs.matrix }} - platform_count: ${{ steps.resolve.outputs.platform_count }} - arches: ${{ steps.resolve.outputs.arches }} - binary_component: ${{ steps.resolve.outputs.binary_component }} - binary_name: ${{ steps.resolve.outputs.binary_name }} - artifact_prefix: ${{ steps.resolve.outputs.artifact_prefix }} - image_tag_base: ${{ steps.resolve.outputs.image_tag_base }} - features: ${{ steps.resolve.outputs.features }} - has_image: ${{ steps.resolve.outputs.has_image }} - steps: - - name: Resolve component and platform matrix - id: resolve - run: | - set -euo pipefail - - component="${{ inputs.component }}" - case "$component" in - gateway) - binary_component=gateway - binary_name=openshell-gateway - features="bundled-z3" - has_image=true - ;; - supervisor) - binary_component=sandbox - binary_name=openshell-sandbox - features="" - has_image=true - ;; - cli) - binary_component=cli - binary_name=openshell - features="" - has_image=false - ;; - *) - echo "unsupported component: $component" >&2 - exit 1 - ;; - esac - - image_tag_base="${{ inputs['image-tag'] }}" - if [[ -z "$image_tag_base" ]]; then - image_tag_base="$GITHUB_SHA" - fi - - platform_input="${{ inputs.platform }}" - platform_input="${platform_input//[[:space:]]/}" - if [[ -z "$platform_input" ]]; then - echo "platform input must not be empty" >&2 - exit 1 - fi - - IFS=',' read -r -a platforms <<< "$platform_input" - matrix='{"include":[' - arches=() - count=0 - - for platform in "${platforms[@]}"; do - case "$platform" in - linux/amd64) - arch=amd64 - runner=linux-amd64-cpu8 - ;; - linux/arm64) - arch=arm64 - runner=linux-arm64-cpu8 - ;; - *) - echo "unsupported platform: $platform" >&2 - echo "supported platforms: linux/amd64, linux/arm64" >&2 - exit 1 - ;; - esac - - if [[ $count -gt 0 ]]; then - matrix+=',' - fi - matrix+='{"platform":"'"$platform"'","arch":"'"$arch"'","runner":"'"$runner"'"}' - arches+=("$arch") - count=$((count + 1)) - done - - matrix+=']}' - { - echo "matrix=$matrix" - echo "platform_count=$count" - echo "arches=${arches[*]}" - echo "binary_component=$binary_component" - echo "binary_name=$binary_name" - echo "artifact_prefix=rust-binary-${component}" - echo "image_tag_base=$image_tag_base" - echo "features=$features" - echo "has_image=$has_image" - } >> "$GITHUB_OUTPUT" - - rust-binary: - name: Rust ${{ needs.resolve.outputs.binary_component }} (${{ matrix.arch }}) - needs: resolve - permissions: - contents: read - packages: read - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }} - uses: ./.github/workflows/rust-native-build.yml - with: - component: ${{ needs.resolve.outputs.binary_component }} - arch: ${{ matrix.arch }} - cargo-version: ${{ inputs['cargo-version'] }} - image-tag: ${{ needs.resolve.outputs.image_tag_base }} - checkout-ref: ${{ inputs['checkout-ref'] }} - features: ${{ needs.resolve.outputs.features }} - artifact-name: ${{ needs.resolve.outputs.artifact_prefix }}-linux-${{ matrix.arch }} - secrets: inherit - build: - name: Build ${{ inputs.component }} (${{ matrix.arch }}) - needs: [resolve, rust-binary] - if: needs.resolve.outputs.has_image == 'true' - runs-on: ${{ matrix.runner }} - timeout-minutes: ${{ inputs['timeout-minutes'] }} + name: ${{ inputs.component }} (${{ matrix.platform }}) strategy: fail-fast: false - matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }} - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - # Expose the nv-gha-runners buildkitd.toml registry mirror config - # inside the container so setup-buildx can read it. - - /etc/buildkit:/etc/buildkit:ro - env: - IMAGE_TAG: ${{ format('{0}-{1}', needs.resolve.outputs.image_tag_base, matrix.arch) }} - IMAGE_REGISTRY: ghcr.io/nvidia/openshell - DOCKER_PUSH: ${{ inputs.push && '1' || '0' }} - DOCKER_PLATFORM: ${{ matrix.platform }} + matrix: + include: + - arch: amd64 + rust_arch: x86_64 + platform: linux/amd64 + runner: linux-amd64-cpu8 + - arch: arm64 + rust_arch: aarch64 + platform: linux/arm64 + runner: linux-arm64-cpu8 + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs['checkout-ref'] || github.sha }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Install tools - run: mise install --locked - - - name: Log in to GHCR - if: ${{ inputs.push }} - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up buildx (local driver) - uses: ./.github/actions/setup-buildx - with: - buildkitd-config: /etc/buildkit/buildkitd.toml - - name: Download Rust binary artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: ./.github/actions/build-docker-image with: - name: ${{ needs.resolve.outputs.artifact_prefix }}-linux-${{ matrix.arch }} - path: prebuilt-rust-binary - - - name: Stage Rust binary in Docker build context - run: | - set -euo pipefail - binary="${{ needs.resolve.outputs.binary_name }}" - download_dir="prebuilt-rust-binary" - stage="deploy/docker/.build/prebuilt-binaries/${{ matrix.arch }}" - found="$(find "$download_dir" -type f -name "$binary" -print -quit)" - if [[ -z "$found" ]]; then - echo "missing downloaded artifact file: $binary" >&2 - find "$download_dir" -maxdepth 4 -type f -print >&2 || true - exit 1 - fi - mkdir -p "$stage" - install -m 0755 "$found" "$stage/$binary" - ls -lh "$stage/" - - - name: Build ${{ inputs.component }} image - env: - DOCKER_BUILDER: openshell - run: | - set -euo pipefail - mise exec -- tasks/scripts/docker-build-image.sh "${{ inputs.component }}" \ - --cache-from "type=gha,scope=${{ inputs.component }}-${{ matrix.arch }}" \ - --cache-to "type=gha,mode=max,scope=${{ inputs.component }}-${{ matrix.arch }}" - - - name: Smoke check ${{ inputs.component }} image - run: | - set -euo pipefail - image="${IMAGE_REGISTRY}/${{ inputs.component }}:${IMAGE_TAG}" - case "${{ inputs.component }}" in - gateway) - output="$(docker run --rm --platform "${{ matrix.platform }}" "$image" --version)" - echo "$output" - grep -q '^openshell-gateway ' <<<"$output" - ;; - supervisor) - output="$(docker run --rm --platform "${{ matrix.platform }}" --entrypoint /openshell-sandbox "$image" --version)" - echo "$output" - grep -q '^openshell-sandbox ' <<<"$output" - ;; - esac - - merge: - name: Merge ${{ inputs.component }} manifest - needs: [resolve, build] - if: ${{ inputs.push && inputs['publish-manifest'] && needs.resolve.outputs.has_image == 'true' }} + component: ${{ inputs.component }} + binary: ${{ inputs.binary }} + triple: ${{ matrix.rust_arch }}-${{ inputs['target-suffix'] }} + arch: ${{ matrix.arch }} + platform: ${{ matrix.platform }} + image-tag: ${{ inputs.image-tag || github.sha }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + manifest: + name: ${{ inputs.component }} manifest + needs: build runs-on: linux-amd64-cpu8 timeout-minutes: 10 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - volumes: - - /var/run/docker.sock:/var/run/docker.sock steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['checkout-ref'] || github.sha }} + - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + shell: bash + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin - - name: Create multi-arch manifest + - name: Create manifest + shell: bash + env: + IMAGE_TAG: ${{ inputs.image-tag || github.sha }} + INPUTS_COMPONENT: ${{ inputs.component }} run: | - set -euo pipefail - image="ghcr.io/nvidia/openshell/${{ inputs.component }}" - refs=() - for arch in ${{ needs.resolve.outputs.arches }}; do - refs+=("${image}:${{ needs.resolve.outputs.image_tag_base }}-${arch}") - done + image=ghcr.io/nvidia/openshell/${INPUTS_COMPONENT} docker buildx imagetools create \ --prefer-index=false \ - -t "${image}:${{ needs.resolve.outputs.image_tag_base }}" \ - "${refs[@]}" + --tag "$image:${IMAGE_TAG}" \ + "$image:${IMAGE_TAG}-amd64" \ + "$image:${IMAGE_TAG}-arm64" + + - name: Verify merged manifest SBOM attestation + shell: bash + env: + IMAGE_REF: ghcr.io/nvidia/openshell/${{ inputs.component }}:${{ inputs.image-tag || github.sha }} + run: tasks/scripts/verify-image-sbom.sh "${IMAGE_REF}" --require-cargo diff --git a/.github/workflows/driver-vm-linux.yml b/.github/workflows/driver-vm-linux.yml deleted file mode 100644 index bee48704de..0000000000 --- a/.github/workflows/driver-vm-linux.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: Driver VM Linux - -on: - workflow_call: - inputs: - cargo-version: - required: false - type: string - default: "" - image-tag: - required: true - type: string - checkout-ref: - required: true - type: string - runtime-platforms: - description: "Comma-separated VM runtime platforms to download: linux-aarch64,linux-x86_64" - required: false - type: string - default: "linux-aarch64,linux-x86_64" - driver-targets: - description: "JSON matrix entries for Linux VM driver targets" - required: false - type: string - default: >- - [{"arch":"arm64","runner":"linux-arm64-cpu8","target":"aarch64-unknown-linux-gnu","zig_target":"aarch64-unknown-linux-gnu.2.28","platform":"linux-aarch64","guest_arch":"aarch64"},{"arch":"amd64","runner":"linux-amd64-cpu8","target":"x86_64-unknown-linux-gnu","zig_target":"x86_64-unknown-linux-gnu.2.28","platform":"linux-x86_64","guest_arch":"x86_64"}] - -permissions: - contents: read - packages: read - -defaults: - run: - shell: bash - -jobs: - download-kernel-runtime: - name: Download Kernel Runtime - runs-on: linux-amd64-cpu8 - timeout-minutes: 10 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - - - name: Download Linux runtime tarballs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RUNTIME_PLATFORMS: ${{ inputs['runtime-platforms'] }} - run: | - set -euo pipefail - mkdir -p runtime-artifacts - - runtime_platforms="${RUNTIME_PLATFORMS//[[:space:]]/}" - IFS=',' read -r -a platforms <<< "$runtime_platforms" - if [ "${#platforms[@]}" -eq 0 ]; then - echo "::error::No VM runtime platforms requested" - exit 1 - fi - for platform in "${platforms[@]}"; do - case "$platform" in - linux-aarch64 | linux-x86_64) ;; - *) - echo "::error::Unsupported VM runtime platform '$platform' in runtime-platforms" - exit 1 - ;; - esac - done - printf '%s\n' "${platforms[@]}" > runtime-artifacts/platforms.txt - - for platform in "${platforms[@]}"; do - asset="vm-runtime-${platform}.tar.zst" - echo "Downloading ${asset}..." - if ! gh release download vm-runtime \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "${asset}" \ - --dir runtime-artifacts \ - --clobber; then - echo "::error::No ${asset} asset found on vm-runtime release" - exit 1 - fi - done - - ls -lah runtime-artifacts/ - - - name: Verify downloads - run: | - set -euo pipefail - while IFS= read -r platform; do - test -f "runtime-artifacts/vm-runtime-${platform}.tar.zst" - done < runtime-artifacts/platforms.txt - - - name: Upload runtime artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: vm-driver-kernel-runtime-tarballs - path: runtime-artifacts/vm-runtime-*.tar.zst - retention-days: 1 - - build-driver-vm-linux: - name: Build Driver VM (Linux ${{ matrix.arch }}) - needs: [download-kernel-runtime] - strategy: - matrix: - include: ${{ fromJSON(inputs['driver-targets']) }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: driver-vm-linux-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Install zstd - run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/* - - - name: Download kernel runtime tarball - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: vm-driver-kernel-runtime-tarballs - path: runtime-download/ - - - name: Stage compressed runtime for embedding - run: | - set -euo pipefail - COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" - VM_RUNTIME_TARBALL="${PWD}/runtime-download/vm-runtime-${{ matrix.platform }}.tar.zst" \ - VM_RUNTIME_PLATFORM="${{ matrix.platform }}" \ - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="$COMPRESSED_DIR" \ - tasks/scripts/vm/compress-vm-runtime.sh - - - name: Build bundled supervisor - run: | - set -euo pipefail - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \ - tasks/scripts/vm/build-supervisor-bundle.sh --arch "${{ matrix.guest_arch }}" - - - name: Verify embedded driver inputs - run: | - set -euo pipefail - for file in libkrun.so.zst libkrunfw.so.5.zst gvproxy.zst umoci.zst openshell-sandbox.zst; do - test -s "target/vm-runtime-compressed/${file}" - done - - - name: Scope workspace to driver-vm crates - run: | - set -euo pipefail - sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-driver-vm", "crates/openshell-core"]|' Cargo.toml - - - name: Patch workspace version - if: ${{ inputs['cargo-version'] != '' }} - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ inputs['cargo-version'] }}"'"/}' Cargo.toml - - - name: Build openshell-driver-vm with glibc 2.28 floor - run: | - set -euo pipefail - mise x -- rustup target add ${{ matrix.target }} - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \ - mise x -- cargo zigbuild --release --target ${{ matrix.zig_target }} -p openshell-driver-vm --bin openshell-driver-vm - mkdir -p artifacts/bin - install -m 0755 target/${{ matrix.target }}/release/openshell-driver-vm artifacts/bin/openshell-driver-vm - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(artifacts/bin/openshell-driver-vm --version)" - echo "$OUTPUT" - grep -q '^openshell-driver-vm ' <<<"$OUTPUT" - - - name: Verify glibc symbol floor - run: tasks/scripts/verify-glibc-symbols.sh 2.28 artifacts/bin/openshell-driver-vm - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf "artifacts/openshell-driver-vm-${{ matrix.target }}.tar.gz" \ - -C artifacts/bin openshell-driver-vm - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: driver-vm-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 diff --git a/.github/workflows/driver-vm-macos.yml b/.github/workflows/driver-vm-macos.yml deleted file mode 100644 index e80ebf37cb..0000000000 --- a/.github/workflows/driver-vm-macos.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: Driver VM macOS - -on: - workflow_call: - inputs: - cargo-version: - required: true - type: string - image-tag: - required: true - type: string - checkout-ref: - required: true - type: string - -permissions: - contents: read - packages: read - -defaults: - run: - shell: bash - -jobs: - download-kernel-runtime: - name: Download Kernel Runtime - runs-on: linux-amd64-cpu8 - timeout-minutes: 10 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - - - name: Download macOS runtime tarball - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - mkdir -p runtime-artifacts - - asset="vm-runtime-darwin-aarch64.tar.zst" - echo "Downloading ${asset}..." - if ! gh release download vm-runtime \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "${asset}" \ - --dir runtime-artifacts \ - --clobber; then - echo "::error::No ${asset} asset found on vm-runtime release" - exit 1 - fi - - ls -lah runtime-artifacts/ - - - name: Verify download - run: test -f runtime-artifacts/vm-runtime-darwin-aarch64.tar.zst - - - name: Upload runtime artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: vm-driver-macos-kernel-runtime-tarball - path: runtime-artifacts/vm-runtime-darwin-aarch64.tar.zst - retention-days: 1 - - build-supervisor-arm64: - name: Build Supervisor Bundle (arm64) - runs-on: linux-arm64-cpu8 - timeout-minutes: 30 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: driver-vm-supervisor-arm64 - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Install zstd - run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/* - - - name: Build bundled supervisor - run: | - set -euo pipefail - tasks/scripts/vm/build-supervisor-bundle.sh --arch aarch64 - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Upload supervisor bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: driver-vm-supervisor-arm64 - path: target/vm-runtime-compressed/openshell-sandbox.zst - retention-days: 1 - - build-driver-vm-macos: - name: Build Driver VM (macOS) - needs: [download-kernel-runtime, build-supervisor-arm64] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Install zstd - run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/* - - - name: Download kernel runtime tarball - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: vm-driver-macos-kernel-runtime-tarball - path: runtime-download/ - - - name: Prepare compressed runtime directory - run: | - set -euo pipefail - COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed-macos" - VM_RUNTIME_TARBALL="${PWD}/runtime-download/vm-runtime-darwin-aarch64.tar.zst" \ - VM_RUNTIME_PLATFORM="darwin-aarch64" \ - OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="$COMPRESSED_DIR" \ - tasks/scripts/vm/compress-vm-runtime.sh - - - name: Download bundled supervisor - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: driver-vm-supervisor-arm64 - path: target/vm-runtime-compressed-macos/ - - - name: Verify bundled supervisor - run: | - set -euo pipefail - test -f target/vm-runtime-compressed-macos/openshell-sandbox.zst - ls -lh target/vm-runtime-compressed-macos/openshell-sandbox.zst - - - name: Verify embedded driver inputs - run: | - set -euo pipefail - for file in libkrun.dylib.zst libkrunfw.5.dylib.zst gvproxy.zst umoci.zst openshell-sandbox.zst; do - test -s "target/vm-runtime-compressed-macos/${file}" - done - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.driver-vm-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ inputs['cargo-version'] }}" \ - --build-arg OPENSHELL_IMAGE_TAG="${{ inputs['image-tag'] }}" \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --build-context vm-runtime-compressed="${PWD}/target/vm-runtime-compressed-macos" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Verify packaged binary shape - run: test -x out/openshell-driver-vm - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-driver-vm-aarch64-apple-darwin.tar.gz \ - -C out openshell-driver-vm - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: driver-vm-macos - path: artifacts/*.tar.gz - retention-days: 5 diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml new file mode 100644 index 0000000000..0b2338e8a6 --- /dev/null +++ b/.github/workflows/e2e-docker-test.yml @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Docker E2E Test + +on: + workflow_call: + inputs: + image-tag: + required: true + type: string + runner: + required: false + type: string + default: linux-amd64-cpu8 + checkout-ref: + required: false + type: string + default: "" + gateway-artifact: + required: false + type: string + default: "" + external-driver-binary: + required: false + type: string + default: "" + conformance-artifact-prefix: + required: false + type: string + default: "" + suite-matrix: + required: false + type: string + default: >- + [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}] + +permissions: + actions: read + contents: read + packages: read + +jobs: + e2e: + name: E2E (${{ matrix.suite }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.suite-matrix) }} + container: + image: ghcr.io/nvidia/openshell/ci:37072ee81cd7b294c714bfa5ecc829b6927b3d70 + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + options: --privileged + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /home/runner/_work:/home/runner/_work + env: + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGE_TAG: ${{ inputs.image-tag }} + OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell + OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref || github.sha }} + persist-credentials: false + + - uses: ./.github/actions/setup-e2e-cli + with: + conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} + + - uses: ./.github/actions/setup-e2e-gateway + with: + artifact-name: ${{ inputs.gateway-artifact }} + + - if: inputs.external-driver-binary != '' + uses: ./.github/actions/setup-e2e-driver + with: + binary: ${{ inputs.external-driver-binary }} + + - name: Check out MCP conformance tests + if: matrix.mcp + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: modelcontextprotocol/conformance + ref: b9041ea41b0188581803459dbae71bc7e02fd995 + path: .cache/mcp-conformance + persist-credentials: false + + - name: Install OS test dependencies + if: matrix.apt_packages != '' + shell: bash + env: + APT_PACKAGES: ${{ matrix.apt_packages }} + run: | + read -ra apt_packages <<< "$APT_PACKAGES" + apt-get update + apt-get install -y "${apt_packages[@]}" + rm -rf /var/lib/apt/lists/* + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + + - name: Install Python dependencies and generate protobuf stubs + if: matrix.python_proto + run: uv sync --frozen && mise run --no-deps python:proto + + - name: Run tests + env: + OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }} + run: ${{ matrix.cmd }} diff --git a/.github/workflows/e2e-gpu-test.yaml b/.github/workflows/e2e-gpu-test.yaml index be2b66e1d4..b363a0509b 100644 --- a/.github/workflows/e2e-gpu-test.yaml +++ b/.github/workflows/e2e-gpu-test.yaml @@ -7,13 +7,8 @@ on: description: "Image tag to test (typically the commit SHA)" required: true type: string - cli-artifact-prefix: - description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-)" - required: false - type: string - default: "" - gateway-artifact-prefix: - description: "Optional prebuilt gateway artifact prefix (artifact suffix is linux-)" + conformance-artifact-prefix: + description: "Optional prebuilt conformance CLI artifact prefix (artifact suffix is )" required: false type: string default: "" @@ -70,16 +65,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' uses: ./.github/actions/setup-e2e-cli with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} + conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - name: Log in to GHCR run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin diff --git a/.github/workflows/e2e-kubernetes-test.yml b/.github/workflows/e2e-kubernetes-test.yml index c40aed6e82..368d902b40 100644 --- a/.github/workflows/e2e-kubernetes-test.yml +++ b/.github/workflows/e2e-kubernetes-test.yml @@ -37,16 +37,36 @@ on: required: false type: string default: "v0.5.0" + e2e-task: + description: "mise task to run for the Kubernetes e2e job" + required: false + type: string + default: "e2e:kubernetes" mise-version: description: "mise version to install on the bare Kubernetes e2e runner" required: false type: string default: "v2026.4.25" - cli-artifact-prefix: - description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-)" + gateway-artifact: + description: "Optional prebuilt gateway artifact used to compose a local test image" + 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 type: string default: "" + cluster-images: + description: "Space-separated published images to preload into kind" + required: false + type: string + default: "gateway supervisor" permissions: actions: read @@ -76,61 +96,33 @@ jobs: ref: ${{ inputs['checkout-ref'] || github.sha }} - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' uses: ./.github/actions/setup-e2e-cli with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - - name: Install mise - run: | - curl https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Install tools - run: mise install --locked + conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} - # The openshell-policy crate transitively pulls in z3-sys, whose - # build script needs the z3 C/C++ headers and clang/bindgen to - # compile. The bare runner doesn't ship them; the CI container - # image used by other Rust e2e jobs does, but we can't run this job - # there (the runner's container handler injects its own --network - # bridge, which conflicts with the --network host we need so kind's - # API server is reachable from the test process). - - name: Install z3 build deps - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libz3-dev clang - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Create kind cluster - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + - name: Use prebuilt OpenShell gateway + if: inputs.gateway-artifact != '' + uses: ./.github/actions/setup-e2e-gateway with: - cluster_name: ${{ env.KIND_CLUSTER_NAME }} - wait: 120s + artifact-name: ${{ inputs.gateway-artifact }} - # mise.toml sets KUBECONFIG="{{config_root}}/kubeconfig"; helm/kind-action - # writes to ~/.kube/config. Materialize the kind context at the mise path - # so `mise run e2e:kubernetes` (and the wrapper's `kubectl --context=...`) - # finds the kind cluster. - - name: Export kind kubeconfig to mise path - run: | - set -euo pipefail - kind get kubeconfig --name "$KIND_CLUSTER_NAME" > "$GITHUB_WORKSPACE/kubeconfig" - chmod 600 "$GITHUB_WORKSPACE/kubeconfig" + - name: Use prebuilt external compute driver + if: inputs.external-driver-binary != '' + uses: ./.github/actions/setup-e2e-driver + with: + binary: ${{ inputs.external-driver-binary }} - - name: Load gateway and supervisor images into kind - run: | - set -euo pipefail - for component in gateway supervisor; do - image="ghcr.io/nvidia/openshell/${component}:${{ inputs.image-tag }}" - archive="${RUNNER_TEMP:-/tmp}/openshell-${component}-linux-amd64.tar" - docker pull --platform linux/amd64 "$image" - docker image save --platform linux/amd64 --output "$archive" "$image" - kind load image-archive "$archive" --name "$KIND_CLUSTER_NAME" - done + - name: Set up kind cluster + uses: ./.github/actions/setup-e2e-kind + with: + cluster-name: ${{ env.KIND_CLUSTER_NAME }} + image-tag: ${{ inputs.image-tag }} + images: ${{ inputs.cluster-images }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} + mise-version: ${{ inputs.mise-version }} - - name: Run Kubernetes E2E (Rust smoke) + - name: Run Kubernetes E2E env: AGENT_SANDBOX_VERSION: ${{ inputs.agent-sandbox-version }} OPENSHELL_E2E_KUBE_CONTEXT: kind-${{ env.KIND_CLUSTER_NAME }} @@ -138,4 +130,5 @@ jobs: OPENSHELL_E2E_KUBE_EXTERNAL_POSTGRES_SECRET: ${{ inputs.external-postgres-secret }} IMAGE_TAG: ${{ inputs.image-tag }} OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell - run: mise run --no-deps --skip-deps e2e:kubernetes + E2E_TASK: ${{ inputs.e2e-task }} + run: mise run --no-deps --skip-deps "$E2E_TASK" diff --git a/.github/workflows/e2e-label-help.yml b/.github/workflows/e2e-label-help.yml index 4c3a1dfe6f..e5158dca3e 100644 --- a/.github/workflows/e2e-label-help.yml +++ b/.github/workflows/e2e-label-help.yml @@ -51,7 +51,7 @@ jobs: status_summary="The matching required CI gate status on this PR will flip green automatically once the run finishes." ;; test:e2e-kubernetes) - suite_summary="Kubernetes HA E2E" + suite_summary="Kubernetes HA and credential-driver E2E" build_summary="gateway and supervisor images" status_summary="This is an optional proof-of-life suite; failures are visible in the workflow run but do not publish a required CI gate status." ;; diff --git a/.github/workflows/e2e-podman-test.yml b/.github/workflows/e2e-podman-test.yml new file mode 100644 index 0000000000..3ec5294c41 --- /dev/null +++ b/.github/workflows/e2e-podman-test.yml @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Podman E2E Test + +on: + workflow_call: + inputs: + image-tag: + required: true + type: string + checkout-ref: + required: false + type: string + default: "" + gateway-artifact: + required: false + type: string + default: "" + external-driver-binary: + required: false + type: string + default: "" + conformance-artifact-prefix: + required: false + type: string + default: "" + suite-matrix: + required: false + type: string + default: >- + [{"suite":"provider-refresh-keycloak","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:provider-refresh-keycloak"}] + +permissions: + actions: read + contents: read + packages: read + +jobs: + e2e: + name: E2E (rust-podman-${{ matrix.suite }}, ${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.suite-matrix) }} + env: + IMAGE_TAG: ${{ inputs.image-tag }} + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell + OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref || github.sha }} + persist-credentials: false + + - uses: ./.github/actions/setup-e2e-cli + with: + conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} + + - uses: ./.github/actions/setup-e2e-gateway + with: + artifact-name: ${{ inputs.gateway-artifact }} + + - if: inputs.external-driver-binary != '' + uses: ./.github/actions/setup-e2e-driver + with: + binary: ${{ inputs.external-driver-binary }} + + - uses: ./.github/actions/setup-e2e-podman + with: + podman-major: ${{ matrix.podman_major }} + podman-package-version: ${{ matrix.podman_package_version }} + conmon-package-version: ${{ matrix.conmon_package_version }} + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + + - name: Run tests + run: ${{ matrix.cmd }} + + - name: Print AppArmor denials + if: always() + run: sudo dmesg | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' | tail -100 || true + + - name: Fail on pasta SIGTERM AppArmor denial + if: always() + run: | + set -euo pipefail + denials="$(sudo dmesg | grep -E 'profile="pasta".*requested_mask="receive".*signal=term.*peer="podman"' || true)" + if [ -n "${denials}" ]; then + echo "::error::pasta denied Podman's SIGTERM; Podman will use its SIGKILL fallback" + printf '%s\n' "${denials}" + exit 1 + fi diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml deleted file mode 100644 index ebe89d1ca8..0000000000 --- a/.github/workflows/e2e-test.yml +++ /dev/null @@ -1,365 +0,0 @@ -name: E2E Test - -on: - workflow_call: - inputs: - image-tag: - description: "Image tag to test (typically the commit SHA)" - required: true - type: string - runner: - description: "GitHub Actions runner label" - required: false - type: string - default: "linux-amd64-cpu8" - checkout-ref: - description: "Git ref to check out for test inputs (defaults to the workflow SHA)" - required: false - type: string - default: "" - cli-artifact-prefix: - description: "Optional prebuilt CLI artifact prefix (artifact suffix is linux-)" - required: false - type: string - default: "" - gateway-artifact-prefix: - description: "Optional prebuilt gateway artifact prefix (artifact suffix is linux-)" - required: false - type: string - default: "" - vm-driver-artifact-name: - description: "Optional prebuilt VM driver artifact name" - required: false - type: string - default: "" - -permissions: - actions: read - contents: read - packages: read - -jobs: - e2e: - name: "E2E (${{ matrix.suite }})" - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - suite: python - cmd: "mise run --no-deps --skip-deps e2e:python" - apt_packages: "" - - suite: oidc-python - cmd: "mise run --no-deps --skip-deps e2e:oidc-python:docker" - apt_packages: "" - - suite: oidc-pkce-docker - cmd: "mise run --no-deps --skip-deps e2e:oidc-pkce:docker" - apt_packages: "openssh-client" - - suite: rust-docker - cmd: "mise run --no-deps --skip-deps e2e:rust" - apt_packages: "openssh-client" - - suite: mcp - cmd: "mise run --no-deps --skip-deps e2e:mcp" - apt_packages: "" - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /home/runner/_work:/home/runner/_work - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMAGE_TAG: ${{ inputs.image-tag }} - OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell - OPENSHELL_REGISTRY_HOST: ghcr.io - OPENSHELL_REGISTRY_NAMESPACE: nvidia/openshell - OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }} - OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - persist-credentials: false - - - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - - - name: Check out MCP conformance tests - if: matrix.suite == 'mcp' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: modelcontextprotocol/conformance - # Pin after v0.1.16 to include the tools_call client scenario fix. - ref: b9041ea41b0188581803459dbae71bc7e02fd995 - path: .cache/mcp-conformance - persist-credentials: false - - - name: Install OS test dependencies - if: matrix.apt_packages != '' - env: - APT_PACKAGES: ${{ matrix.apt_packages }} - run: apt-get update && apt-get install -y ${APT_PACKAGES} && rm -rf /var/lib/apt/lists/* - - - name: Log in to GHCR with Docker - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Install Python dependencies and generate protobuf stubs - if: matrix.suite == 'python' || matrix.suite == 'oidc-python' - run: uv sync --frozen && mise run --no-deps python:proto - - - name: Run tests - env: - OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} - OPENSHELL_MCP_CONFORMANCE_CLIENT_IMAGE: ${{ format('openshell-mcp-conformance-client:{0}', inputs.image-tag) }} - run: ${{ matrix.cmd }} - - e2e-podman-rootless: - name: E2E (rust-podman-rootless, ${{ matrix.runner }}) - # Run directly on the Ubuntu host so the test observes the host's AppArmor - # and unprivileged-user-namespace policy. A privileged job container masks - # the restrictions that production rootless Podman installations enforce. - # Ubuntu 26.04 provides the supported Podman 5.x and pasta combination. - # Re-add older/slirp4netns environments when direct callbacks through a - # rootless-network namespace relay are supported. - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - # Keep package versions explicit so hosted-runner tool overrides - # cannot silently change the supported test environment. - - runner: ubuntu-26.04 - podman_major: "5" - podman_package_version: "5.7.0+ds2-3build1" - conmon_package_version: "2.1.13+ds1-2" - env: - IMAGE_TAG: ${{ inputs.image-tag }} - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell - OPENSHELL_REGISTRY_HOST: ghcr.io - OPENSHELL_REGISTRY_NAMESPACE: nvidia/openshell - OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }} - OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_SUPERVISOR_IMAGE: ${{ format('ghcr.io/nvidia/openshell/supervisor:{0}', inputs.image-tag) }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - persist-credentials: false - - - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - - - name: Install mise - run: | - curl https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Install tools - run: mise install --locked - - - name: Install Podman and build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - clang \ - fuse-overlayfs \ - libssl-dev \ - libz3-dev \ - openssh-client \ - passt \ - pkg-config \ - "conmon=${{ matrix.conmon_package_version }}" \ - "podman=${{ matrix.podman_package_version }}" \ - uidmap - # Hosted runners can place newer Podman and conmon binaries under - # /usr/local ahead of Ubuntu's packages. Select the distro CLI and - # use Podman's supported final config override for its conmon path. - podman_config="${RUNNER_TEMP}/openshell-containers.conf" - printf '%s\n' \ - '[engine]' \ - 'conmon_path = ["/usr/bin/conmon"]' \ - > "${podman_config}" - echo "/usr/bin" >> "${GITHUB_PATH}" - echo "CONTAINERS_CONF_OVERRIDE=${podman_config}" >> "${GITHUB_ENV}" - - - name: Configure rootless Podman - run: | - set -euo pipefail - if ! grep -q "^${USER}:" /etc/subuid; then - sudo usermod --add-subuids 100000-165535 "$USER" - fi - if ! grep -q "^${USER}:" /etc/subgid; then - sudo usermod --add-subgids 100000-165535 "$USER" - fi - runtime_dir="/run/user/$(id -u)" - sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" - echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV" - - - name: Verify rootless Podman environment - run: | - set -euo pipefail - podman_version="$(podman version --format '{{.Client.Version}}')" - case "$podman_version" in - "${{ matrix.podman_major }}".*) ;; - *) echo "ERROR: expected Podman ${{ matrix.podman_major }}.x, found $podman_version" >&2; exit 1 ;; - esac - test "$(dpkg-query -W -f='${Version}' podman)" = "${{ matrix.podman_package_version }}" - test "$(dpkg-query -W -f='${Version}' conmon)" = "${{ matrix.conmon_package_version }}" - test "$(command -v podman)" = "/usr/bin/podman" - test "$(podman info --format '{{.Host.Conmon.Path}}')" = "/usr/bin/conmon" - test "$(podman info --format '{{.Host.Security.Rootless}}')" = "true" - test "$(podman info --format '{{.Host.RootlessNetworkCmd}}')" = "pasta" - test "$(sudo sysctl -n kernel.apparmor_restrict_unprivileged_userns)" = "1" - echo "=== host ===" - uname -a - echo "=== AppArmor ===" - cat /proc/self/attr/current - sudo aa-status || true - echo "=== Podman ===" - podman version - podman info --debug - - - name: Probe rootless capability bounding set - run: | - set -euo pipefail - probe="$RUNNER_TEMP/openshell-capbset-probe" - cc -static -O2 -Wall -Wextra -Werror \ - e2e/support/capbset-probe.c \ - -o "$probe" - podman run --rm \ - --cap-add=SETPCAP \ - --volume "$probe:/openshell-capbset-probe:ro" \ - docker.io/library/alpine:3.22 \ - /openshell-capbset-probe - - - name: Log in to GHCR with Podman - run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Run rootless Podman E2E - run: mise run --no-deps --skip-deps e2e:podman:rootless - - - name: Print AppArmor denials - if: always() - run: sudo dmesg | grep -E 'apparmor=.*DENIED|profile="unprivileged_userns"' | tail -100 || true - - e2e-vm: - name: E2E (rust-vm) - # libkrun needs KVM, so this job must run directly on a GitHub-hosted - # Linux VM. GitHub-hosted macOS runners do not support nested - # virtualization, and a job container would hide the host KVM device. - runs-on: ubuntu-24.04 - timeout-minutes: 90 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - persist-credentials: false - - - name: Use prebuilt OpenShell CLI - if: inputs.cli-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-cli - with: - artifact-prefix: ${{ inputs.cli-artifact-prefix }} - - - name: Use prebuilt OpenShell gateway - if: inputs.gateway-artifact-prefix != '' - uses: ./.github/actions/setup-e2e-gateway - with: - artifact-prefix: ${{ inputs.gateway-artifact-prefix }} - - - name: Use prebuilt OpenShell VM driver - if: inputs.vm-driver-artifact-name != '' - uses: ./.github/actions/setup-e2e-vm-driver - with: - artifact-name: ${{ inputs.vm-driver-artifact-name }} - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - clang \ - cmake \ - libclang-dev \ - libssl-dev \ - libz3-dev \ - openssh-client \ - pkg-config \ - socat \ - zstd - - - name: Enable KVM access - run: | - set -euo pipefail - if [[ ! -c /dev/kvm ]]; then - echo "::error::The GitHub-hosted runner did not expose /dev/kvm" - lscpu - grep -m1 -E '^(flags|Features)' /proc/cpuinfo || true - ls -la /dev - exit 1 - fi - - # Package installation can restart systemd-udevd, which reapplies - # the default root:kvm 0660 mode. Install a persistent rule after - # dependencies so later udev events preserve runner access. - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --settle --name-match=kvm - - ls -l /dev/kvm - exec 3<>/dev/kvm - exec 3>&- - - - name: Validate VM host tools - run: | - command -v mke2fs - command -v mkfs.ext4 - command -v debugfs - - - name: Install mise - run: | - curl https://mise.run | MISE_VERSION=v2026.4.25 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: e2e-vm-linux-amd64 - cache-on-failure: "true" - - - name: Run VM E2E - run: mise run --no-deps --skip-deps e2e:vm diff --git a/.github/workflows/e2e-vm-test.yml b/.github/workflows/e2e-vm-test.yml new file mode 100644 index 0000000000..954de9669e --- /dev/null +++ b/.github/workflows/e2e-vm-test.yml @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: VM E2E Test + +on: + workflow_call: + inputs: + checkout-ref: + required: false + type: string + default: "" + gateway-artifact: + required: false + type: string + default: "" + suite-name: + required: false + type: string + default: managed + e2e-task: + required: false + type: string + default: e2e:vm + conformance-artifact-prefix: + required: false + type: string + default: "" + +permissions: + actions: read + contents: read + packages: read + +jobs: + e2e: + name: E2E (rust-vm-${{ inputs.suite-name }}) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref || github.sha }} + persist-credentials: false + + - uses: ./.github/actions/setup-e2e-cli + with: + conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} + + - uses: ./.github/actions/setup-e2e-gateway + with: + artifact-name: ${{ inputs.gateway-artifact }} + + - uses: ./.github/actions/setup-e2e-vm-driver + + - uses: ./.github/actions/setup-e2e-vm + + - name: Run tests + env: + E2E_TASK: ${{ inputs.e2e-task }} + run: mise run --no-deps --skip-deps "$E2E_TASK" diff --git a/.github/workflows/package-release-binaries.yml b/.github/workflows/package-release-binaries.yml new file mode 100644 index 0000000000..f1900f3c35 --- /dev/null +++ b/.github/workflows/package-release-binaries.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Package Release Binaries + +on: + workflow_call: + +permissions: + actions: read + contents: read + +jobs: + package: + name: Package ${{ matrix.artifact }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - artifact: openshell-x86_64-unknown-linux-musl + binary: openshell + package: cli-linux-amd64 + - artifact: openshell-aarch64-unknown-linux-musl + binary: openshell + package: cli-linux-arm64 + - artifact: openshell-aarch64-apple-darwin + binary: openshell + package: cli-macos + - artifact: openshell-gateway-x86_64-unknown-linux-gnu + binary: openshell-gateway + package: gateway-binary-linux-amd64 + - artifact: openshell-gateway-aarch64-unknown-linux-gnu + binary: openshell-gateway + package: gateway-binary-linux-arm64 + - artifact: openshell-gateway-aarch64-apple-darwin + binary: openshell-gateway + package: gateway-binary-macos + - artifact: openshell-sandbox-x86_64-unknown-linux-musl + binary: openshell-sandbox + package: supervisor-binary-linux-amd64 + - artifact: openshell-sandbox-aarch64-unknown-linux-musl + binary: openshell-sandbox + package: supervisor-binary-linux-arm64 + - artifact: openshell-driver-vm-x86_64-unknown-linux-gnu + binary: openshell-driver-vm + package: driver-vm-linux-amd64 + - artifact: openshell-driver-vm-aarch64-unknown-linux-gnu + binary: openshell-driver-vm + package: driver-vm-linux-arm64 + - artifact: openshell-driver-vm-aarch64-apple-darwin + binary: openshell-driver-vm + package: driver-vm-macos + steps: + - name: Download ${{ matrix.artifact }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact }} + path: raw + + - name: Create archive + env: + ARTIFACT: ${{ matrix.artifact }} + BINARY: ${{ matrix.binary }} + run: | + set -euo pipefail + chmod +x "raw/${BINARY}" + tar -czf "${ARTIFACT}.tar.gz" -C raw "${BINARY}" + + - name: Upload ${{ matrix.package }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ matrix.package }} + path: ${{ matrix.artifact }}.tar.gz + retention-days: 5 + if-no-files-found: error diff --git a/.github/workflows/release-auto-tag.yml b/.github/workflows/release-auto-tag.yml index 8290de4896..b75881b755 100644 --- a/.github/workflows/release-auto-tag.yml +++ b/.github/workflows/release-auto-tag.yml @@ -5,8 +5,8 @@ name: Release Auto-Tag on: workflow_dispatch: {} - schedule: - - cron: "0 14 * * 1-5" # 7 AM PDT, weekdays only +# schedule: +# - cron: "0 14 * * 1-5" # 7 AM PDT, weekdays only permissions: contents: write diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 896d12d190..ee4074142e 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -2,6 +2,11 @@ name: Release Canary on: workflow_dispatch: + inputs: + release-dev-run-id: + description: "Successful Release Dev run ID whose Snap artifact to test" + required: false + type: string workflow_run: workflows: ["Release Dev"] types: [completed] @@ -14,6 +19,9 @@ defaults: run: shell: bash +env: + OPENSHELL_TELEMETRY_ENABLED: "false" + jobs: macos: name: macOS Homebrew @@ -24,6 +32,7 @@ jobs: - name: Ensure VM driver run: | launchctl setenv OPENSHELL_DRIVERS vm + launchctl setenv OPENSHELL_TELEMETRY_ENABLED "$OPENSHELL_TELEMETRY_ENABLED" - name: Install and check status run: | @@ -44,7 +53,8 @@ jobs: fi sudo systemctl start docker || sudo service docker start mkdir -p "${HOME}/.config/openshell" - printf 'OPENSHELL_DRIVERS=docker\n' > "${HOME}/.config/openshell/gateway.env" + printf 'OPENSHELL_DRIVERS=docker\nOPENSHELL_TELEMETRY_ENABLED=%s\n' \ + "$OPENSHELL_TELEMETRY_ENABLED" > "${HOME}/.config/openshell/gateway.env" docker info - name: Install and check status @@ -130,11 +140,13 @@ jobs: HOME=/root \ XDG_RUNTIME_DIR=/run/user/0 \ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/0/bus \ + OPENSHELL_TELEMETRY_ENABLED="$OPENSHELL_TELEMETRY_ENABLED" \ INSTALL_SH_URL="https://raw.githubusercontent.com/NVIDIA/OpenShell/${{ github.event.workflow_run.head_sha || github.sha }}/install.sh" \ bash -s <<'EOF' set -euo pipefail mkdir -p "${HOME}/.config/openshell" - printf 'OPENSHELL_DRIVERS=podman\n' > "${HOME}/.config/openshell/gateway.env" + printf 'OPENSHELL_DRIVERS=podman\nOPENSHELL_TELEMETRY_ENABLED=%s\n' \ + "$OPENSHELL_TELEMETRY_ENABLED" > "${HOME}/.config/openshell/gateway.env" podman info curl -LsSf "${INSTALL_SH_URL}" | sh openshell status @@ -147,7 +159,7 @@ jobs: ubuntu-snap: name: Ubuntu Snap - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event.workflow_run.conclusion == 'success' || inputs.release-dev-run-id != '' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -169,7 +181,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: github-token: ${{ github.token }} - run-id: ${{ github.event.workflow_run.id }} + run-id: ${{ inputs.release-dev-run-id || github.event.workflow_run.id }} pattern: snap-linux-amd64 path: release/ merge-multiple: true @@ -177,6 +189,8 @@ jobs: - name: Install snap (dangerous — from release, not store) run: | set -euo pipefail + sudo systemctl set-environment \ + "OPENSHELL_TELEMETRY_ENABLED=${OPENSHELL_TELEMETRY_ENABLED}" sudo snap install ./release/*.snap --dangerous - name: Connect interfaces @@ -193,7 +207,27 @@ jobs: sudo snap services openshell openshell gateway add http://127.0.0.1:17670 --local --name snap-docker openshell gateway select snap-docker - openshell status + for _ in $(seq 1 30); do + if openshell status; then + exit 0 + fi + sleep 1 + done + echo "Gateway did not become ready within 30 seconds" >&2 + exit 1 + + - name: Collect Snap diagnostics + if: failure() + run: | + set +e + sudo snap services openshell + sudo snap connections openshell + sudo snap changes + sudo systemctl status snap.openshell.gateway.service --no-pager + sudo journalctl -b -u snap.openshell.gateway.service --no-pager -n 300 + sudo journalctl -b -u snapd.service --no-pager -n 300 + sudo snap logs openshell.gateway -n=300 + sudo ss -ltnp '( sport = :17670 )' kubernetes: name: Kubernetes Helm (kind) @@ -205,7 +239,16 @@ jobs: RELEASE_NAME: openshell RELEASE_NAMESPACE: openshell KIND_GATEWAY_NAME: kind + AGENT_SANDBOX_VERSION: v0.5.0 steps: + - name: Checkout Agent Sandbox helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + e2e/support/install-agent-sandbox.sh + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Install Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 @@ -215,6 +258,9 @@ jobs: cluster_name: ${{ env.KIND_CLUSTER_NAME }} wait: 120s + - name: Install Agent Sandbox controller + run: bash e2e/support/install-agent-sandbox.sh + - name: Install OpenShell Helm chart from GHCR OCI run: | set -euo pipefail @@ -222,6 +268,7 @@ jobs: --version 0.0.0-dev \ --namespace "$RELEASE_NAMESPACE" --create-namespace \ --set server.disableTls=true \ + --set "server.telemetryEnabled=${OPENSHELL_TELEMETRY_ENABLED}" \ --wait --timeout 5m - name: Verify gateway pod is Ready diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 70f458d384..18b5f33a2e 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -47,45 +47,148 @@ jobs: id: v run: | set -euo pipefail - echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT" - echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" - echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT" - echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT" - echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT" + echo "python=$(uv run python tasks/scripts/release.py get-version --dev --python)" >> "$GITHUB_OUTPUT" + echo "cargo=$(uv run python tasks/scripts/release.py get-version --dev --cargo)" >> "$GITHUB_OUTPUT" + echo "deb=$(uv run python tasks/scripts/release.py get-version --dev --deb)" >> "$GITHUB_OUTPUT" + echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --dev --rpm-version)" >> "$GITHUB_OUTPUT" + echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --dev --rpm-release)" >> "$GITHUB_OUTPUT" + + build-cli: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-cli-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + + build-conformance: + needs: compute-versions + permissions: + contents: read + strategy: + matrix: + include: + - triple: x86_64-unknown-linux-musl + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.musl + - triple: aarch64-unknown-linux-musl + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.musl + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-conformance-cli + binary: openshell-conformance + artifact-name: openshell-conformance-${{ matrix.triple }} + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + secrets: inherit build-gateway: - needs: [compute-versions] + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-gateway-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + + build-sandbox: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-sandbox-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + + build-vm-driver: + needs: [compute-versions, build-sandbox] + permissions: + contents: read + uses: ./.github/workflows/build-vm-driver.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: dev + secrets: inherit + + package-binaries: + needs: [build-cli, build-gateway, build-sandbox, build-vm-driver] + permissions: + actions: read + contents: read + uses: ./.github/workflows/package-release-binaries.yml + + build-gateway-image: + needs: build-gateway + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: gateway - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + binary: openshell-gateway + target-suffix: unknown-linux-gnu + secrets: inherit - build-supervisor: - needs: [compute-versions] + build-supervisor-image: + needs: build-sandbox + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: supervisor - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + binary: openshell-sandbox + target-suffix: unknown-linux-musl + secrets: inherit - e2e: - needs: [build-gateway, build-supervisor] + docker-e2e: + needs: [build-cli, build-conformance, build-gateway, build-supervisor-image] permissions: actions: read contents: read packages: read - uses: ./.github/workflows/e2e-test.yml + uses: ./.github/workflows/e2e-docker-test.yml with: image-tag: ${{ github.sha }} runner: linux-arm64-cpu8 + conformance-artifact-prefix: openshell-conformance + + podman-e2e: + needs: [build-cli, build-conformance, build-gateway, build-supervisor-image] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-podman-test.yml + with: + image-tag: ${{ github.sha }} + conformance-artifact-prefix: openshell-conformance + + vm-e2e: + needs: [build-cli, build-conformance, build-gateway, build-vm-driver] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-vm-test.yml + with: + conformance-artifact-prefix: openshell-conformance tag-ghcr-dev: name: Tag GHCR Images as Dev - needs: [build-gateway, build-supervisor, release-dev] + needs: [build-gateway-image, build-supervisor-image, release-dev] runs-on: linux-amd64-cpu8 timeout-minutes: 10 steps: - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin - name: Tag images as dev run: | @@ -99,91 +202,24 @@ jobs: "${REGISTRY}/${component}:${{ github.sha }}" done - build-python-wheels-linux: - name: Build Python Wheels (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - artifact: linux-amd64 - task: python:build:linux:amd64 - output_path: target/wheels/linux-amd64/*.whl - - arch: arm64 - runner: linux-arm64-cpu8 - artifact: linux-arm64 - task: python:build:linux:arm64 - output_path: target/wheels/linux-arm64/*.whl - runs-on: ${{ matrix.runner }} - timeout-minutes: 120 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: dev - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Sync Python dependencies - run: uv sync - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: python-wheel-linux-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Build Python wheels - run: | - set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run ${{ matrix.task }} - ls -la ${{ matrix.output_path }} - - - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-wheels-${{ matrix.artifact }} - path: ${{ matrix.output_path }} - retention-days: 5 - - build-python-wheel-macos: - name: Build Python Wheel (macOS) + build-python-wheel: + name: Build Python Wheel needs: [compute-versions] runs-on: linux-amd64-cpu8 - timeout-minutes: 120 + timeout-minutes: 20 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock env: MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: dev + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL: ${{ needs.compute-versions.outputs.python_version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - name: Mark workspace safe for git run: git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -193,466 +229,20 @@ jobs: - name: Build Python wheel run: | set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run python:build:macos + mise run python:build ls -la target/wheels/*.whl - - name: Upload wheel artifacts + - name: Upload wheel artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: python-wheels-macos + name: python-wheel path: target/wheels/*.whl retention-days: 5 - # --------------------------------------------------------------------------- - # Build CLI binaries (Linux musl — static, native on each arch) - # - # Builds run directly on the CI host (glibc Ubuntu). Zig provides musl - # C/C++ toolchains for bundled-z3 and ring, and is also used as the linker. - # --------------------------------------------------------------------------- - build-cli-linux: - name: Build CLI (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-musl - zig_target: x86_64-linux-musl - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-musl - zig_target: aarch64-linux-musl - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: dev - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: cli-musl-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Add Rust musl target - run: mise x -- rustup target add ${{ matrix.target }} - - - name: Set up zig musl wrappers - run: | - set -euo pipefail - ZIG="$(mise which zig)" - ZIG_TARGET="${{ matrix.zig_target }}" - mkdir -p /tmp/zig-musl - - # cc-rs injects --target= (for example - # aarch64-unknown-linux-musl), which zig does not parse. Strip any - # caller-provided --target and use the wrapper's zig-native target. - for tool in cc c++; do - printf '#!/bin/bash\nargs=()\nfor arg in "$@"; do\n case "$arg" in\n --target=*) ;;\n *) args+=("$arg") ;;\n esac\ndone\nexec "%s" %s --target=%s "${args[@]}"\n' \ - "$ZIG" "$tool" "$ZIG_TARGET" > "/tmp/zig-musl/${tool}" - chmod +x "/tmp/zig-musl/${tool}" - done - - TARGET_ENV=$(echo "${{ matrix.target }}" | tr '-' '_') - TARGET_ENV_UPPER=${TARGET_ENV^^} - - # Use zig for C/C++ compilation and final linking. - echo "CC_${TARGET_ENV}=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CXX_${TARGET_ENV}=/tmp/zig-musl/c++" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_LINKER=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - - # Let zig own CRT/startfiles to avoid duplicate _start symbols. - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_RUSTFLAGS=-Clink-self-contained=no" >> "$GITHUB_ENV" - - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build CLI binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-cli-macos: - name: Build CLI (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.cli-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG=dev \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-aarch64-apple-darwin.tar.gz \ - -C out openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-macos - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binaries (Linux GNU — glibc 2.28 floor) - # --------------------------------------------------------------------------- - build-gateway-binary-linux: - name: Build Gateway Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - zig_target: x86_64-unknown-linux-gnu.2.28 - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - zig_target: aarch64-unknown-linux-gnu.2.28 - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: gateway-binary-gnu-${{ matrix.arch }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Set up Zig C/C++ wrappers - run: tasks/scripts/setup-zig-cc-wrapper.sh ${{ matrix.zig_target }} ${{ matrix.zig_target }} /tmp/zig-gnu - - - name: Build ${{ matrix.zig_target }} - env: - OPENSHELL_IMAGE_TAG: ${{ github.sha }} - run: | - set -euo pipefail - mise x -- rustup target add ${{ matrix.target }} - mise x -- cargo zigbuild --release --target ${{ matrix.zig_target }} -p openshell-server --bin openshell-gateway --features bundled-z3 - mkdir -p artifacts/bin - install -m 0755 target/${{ matrix.target }}/release/openshell-gateway artifacts/bin/openshell-gateway - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(artifacts/bin/openshell-gateway --version)" - echo "$OUTPUT" - grep -q '^openshell-gateway ' <<<"$OUTPUT" - ldd artifacts/bin/openshell-gateway || true - if ldd artifacts/bin/openshell-gateway | grep -q 'libz3'; then - echo "gateway binary must not depend on shared libz3; build with bundled-z3" >&2 - exit 1 - fi - - - name: Verify glibc symbol floor - run: tasks/scripts/verify-glibc-symbols.sh 2.28 artifacts/bin/openshell-gateway - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-${{ matrix.target }}.tar.gz \ - -C artifacts/bin openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-gateway-binary-macos: - name: Build Gateway Binary (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.gateway-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG=dev \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Verify packaged binary shape - run: | - set -euo pipefail - test -x out/openshell-gateway - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-aarch64-apple-darwin.tar.gz \ - -C out openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-macos - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone supervisor binaries (Linux GNU — native on each arch) - # --------------------------------------------------------------------------- - build-supervisor-binary-linux: - name: Build Supervisor Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: supervisor-binary-gnu-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: | - set -euo pipefail - mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-sandbox --bin openshell-sandbox - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(target/${{ matrix.target }}/release/openshell-sandbox --version)" - echo "$OUTPUT" - grep -q '^openshell-sandbox ' <<<"$OUTPUT" - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-sandbox-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell-sandbox - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: supervisor-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - build-driver-vm-linux: - name: Build Driver VM Linux - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-linux.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: dev - checkout-ref: ${{ github.sha }} - secrets: inherit - - build-driver-vm-macos: - name: Build Driver VM macOS - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-macos.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: dev - checkout-ref: ${{ github.sha }} - secrets: inherit build-deb: name: Build Debian Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-driver-vm-linux] + needs: [compute-versions, build-cli, build-gateway, build-vm-driver] uses: ./.github/workflows/deb-package.yml with: deb-version: ${{ needs.compute-versions.outputs.deb_version }} @@ -661,7 +251,7 @@ jobs: build-snap: name: Build Snap - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-supervisor-binary-linux] + needs: [compute-versions, build-cli, build-gateway, build-sandbox] uses: ./.github/workflows/snap-package.yml with: checkout-ref: ${{ github.sha }} @@ -672,7 +262,7 @@ jobs: build-rpm: name: Build RPM Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux] + needs: [compute-versions, build-cli, build-gateway] uses: ./.github/workflows/rpm-package.yml with: checkout-ref: ${{ github.sha }} @@ -683,7 +273,7 @@ jobs: smoke-linux-dev-artifacts: name: Smoke Linux Dev Artifacts (${{ matrix.name }}) - needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheels-linux] + needs: [package-binaries, build-deb, build-rpm, build-python-wheel] timeout-minutes: 20 strategy: fail-fast: false @@ -713,18 +303,10 @@ jobs: kind: rpm artifact_arch: arm64 rpm_arch: aarch64 - - name: python-wheel-amd64 + - name: python-wheel runner: linux-amd64-cpu8 image: python:3.12-slim kind: wheel - artifact_arch: amd64 - rpm_arch: x86_64 - - name: python-wheel-arm64 - runner: linux-arm64-cpu8 - image: python:3.12-slim - kind: wheel - artifact_arch: arm64 - rpm_arch: aarch64 runs-on: ${{ matrix.runner }} container: image: ${{ matrix.image }} @@ -763,27 +345,29 @@ jobs: if: matrix.kind == 'wheel' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: python-wheels-linux-${{ matrix.artifact_arch }} + name: python-wheel path: wheel-input/ - name: Smoke Python wheel if: matrix.kind == 'wheel' run: | set -euo pipefail - pip install --no-cache-dir wheel-input/*.whl - python - <<'PY' + python -m venv /tmp/openshell-wheel-smoke + /tmp/openshell-wheel-smoke/bin/pip install --no-cache-dir wheel-input/*.whl + /tmp/openshell-wheel-smoke/bin/python - <<'PY' import importlib for name in ("openshell", "openshell._proto", "openshell.sandbox"): importlib.import_module(name) print(name, "OK") PY + test ! -e /tmp/openshell-wheel-smoke/bin/openshell # --------------------------------------------------------------------------- # Create / update the dev GitHub Release with CLI, gateway, driver, and wheels # --------------------------------------------------------------------------- release-dev: name: Release Dev - needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-dev-artifacts] + needs: [compute-versions, package-binaries, build-python-wheel, docker-e2e, podman-e2e, vm-e2e, build-deb, build-rpm, build-snap, smoke-linux-dev-artifacts] runs-on: linux-amd64-cpu8 timeout-minutes: 10 permissions: @@ -824,12 +408,11 @@ jobs: path: release/ merge-multiple: true - - name: Download wheel artifacts + - name: Download wheel artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: python-wheels-* + name: python-wheel path: release/ - merge-multiple: true - name: Download Debian package artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -881,9 +464,14 @@ jobs: id: wheel_filenames run: | set -euo pipefail - ls -la release/*.whl - WHEEL_FILENAMES=$(ls release/*.whl | xargs -n1 basename | sort | paste -sd, -) - echo "wheel_filenames=${WHEEL_FILENAMES}" >> "$GITHUB_OUTPUT" + shopt -s nullglob + wheels=(release/*.whl) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "expected exactly one Python wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + wheel_filename=$(basename "${wheels[0]}") + echo "wheel_filenames=${wheel_filename}" >> "$GITHUB_OUTPUT" - name: Generate checksums run: | @@ -906,8 +494,8 @@ jobs: openshell-gateway-aarch64-apple-darwin.tar.gz > openshell-gateway-checksums-sha256.txt cat openshell-gateway-checksums-sha256.txt sha256sum \ - openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz \ - openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt + openshell-sandbox-x86_64-unknown-linux-musl.tar.gz \ + openshell-sandbox-aarch64-unknown-linux-musl.tar.gz > openshell-sandbox-checksums-sha256.txt cat openshell-sandbox-checksums-sha256.txt - name: Generate Homebrew formula @@ -1012,8 +600,8 @@ jobs: release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-apple-darwin.tar.gz - release/openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz - release/openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz + release/openshell-sandbox-x86_64-unknown-linux-musl.tar.gz + release/openshell-sandbox-aarch64-unknown-linux-musl.tar.gz release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-apple-darwin.tar.gz diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 8d43e390cf..b3c45c97fb 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -40,6 +40,7 @@ jobs: outputs: python_version: ${{ steps.v.outputs.python }} cargo_version: ${{ steps.v.outputs.cargo }} + npm_version: ${{ steps.v.outputs.npm }} deb_version: ${{ steps.v.outputs.deb }} rpm_version: ${{ steps.v.outputs.rpm_version }} rpm_release: ${{ steps.v.outputs.rpm_release }} @@ -47,6 +48,7 @@ jobs: semver: ${{ steps.v.outputs.semver }} # Commit resolved from RELEASE_TAG, used for image tags and downstream metadata source_sha: ${{ steps.v.outputs.source_sha }} + is_prerelease: ${{ steps.v.outputs.is_prerelease }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -63,158 +65,217 @@ jobs: id: v run: | set -euo pipefail - echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT" - echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" - echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT" - echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT" - echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT" - echo "semver=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" - echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*$ ]]; then + is_prerelease=true + elif [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + is_prerelease=false + else + echo "Unsupported release tag: ${RELEASE_TAG}" >&2 + exit 1 + fi + { + echo "is_prerelease=${is_prerelease}" + echo "python=$(uv run python tasks/scripts/release.py get-version --python)" + echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" + echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)" + echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" + echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" + echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" + echo "semver=${RELEASE_TAG#v}" + echo "source_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + + build-cli: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-cli-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + + build-conformance: + needs: compute-versions + permissions: + contents: read + strategy: + matrix: + include: + - triple: x86_64-unknown-linux-musl + runner: linux-amd64-cpu8 + dev_shell: .#devShells.x86_64-linux.musl + - triple: aarch64-unknown-linux-musl + runner: linux-arm64-cpu8 + dev_shell: .#devShells.aarch64-linux.musl + uses: ./.github/workflows/build-binaries.yml + with: + package: openshell-conformance-cli + binary: openshell-conformance + artifact-name: openshell-conformance-${{ matrix.triple }} + triple: ${{ matrix.triple }} + runner: ${{ matrix.runner }} + dev-shell: ${{ matrix.dev_shell }} + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit build-gateway: - needs: [compute-versions] + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-gateway-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + + build-sandbox: + needs: compute-versions + permissions: + contents: read + uses: ./.github/workflows/build-sandbox-binaries.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + + build-vm-driver: + needs: [compute-versions, build-sandbox] + permissions: + contents: read + uses: ./.github/workflows/build-vm-driver.yml + with: + cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + image-tag: ${{ needs.compute-versions.outputs.semver }} + checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit + + package-binaries: + needs: [build-cli, build-gateway, build-sandbox, build-vm-driver] + permissions: + actions: read + contents: read + uses: ./.github/workflows/package-release-binaries.yml + + build-gateway-image: + needs: [compute-versions, build-gateway] + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: gateway - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + binary: openshell-gateway + target-suffix: unknown-linux-gnu image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit - build-supervisor: - needs: [compute-versions] + build-supervisor-image: + needs: [compute-versions, build-sandbox] + permissions: + contents: read + packages: write uses: ./.github/workflows/docker-build.yml with: component: supervisor - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} + binary: openshell-sandbox + target-suffix: unknown-linux-musl image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} + secrets: inherit - e2e: - needs: [compute-versions, build-gateway, build-supervisor] + docker-e2e: + needs: [compute-versions, build-cli, build-conformance, build-gateway, build-supervisor-image] permissions: actions: read contents: read packages: read - uses: ./.github/workflows/e2e-test.yml + uses: ./.github/workflows/e2e-docker-test.yml with: image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ inputs.tag || github.ref }} runner: linux-arm64-cpu8 + conformance-artifact-prefix: openshell-conformance + + podman-e2e: + needs: [compute-versions, build-cli, build-conformance, build-gateway, build-supervisor-image] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-podman-test.yml + with: + image-tag: ${{ needs.compute-versions.outputs.source_sha }} + checkout-ref: ${{ inputs.tag || github.ref }} + conformance-artifact-prefix: openshell-conformance + + vm-e2e: + needs: [compute-versions, build-cli, build-conformance, build-gateway, build-vm-driver] + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-vm-test.yml + with: + checkout-ref: ${{ inputs.tag || github.ref }} + conformance-artifact-prefix: openshell-conformance tag-ghcr-release: - name: Tag GHCR Images for Release - needs: [compute-versions, build-gateway, build-supervisor, release] + name: Tag GHCR Images + needs: [compute-versions, build-gateway-image, build-supervisor-image, release] runs-on: linux-amd64-cpu8 timeout-minutes: 10 steps: - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin - - name: Tag images with version and latest + - name: Tag images with release version + env: + IS_PRERELEASE: ${{ needs.compute-versions.outputs.is_prerelease }} run: | set -euo pipefail REGISTRY="ghcr.io/nvidia/openshell" VERSION="${{ needs.compute-versions.outputs.semver }}" SOURCE_TAG="${{ needs.compute-versions.outputs.source_sha }}" for component in gateway supervisor; do - echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION} and latest..." + echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION}..." docker buildx imagetools create \ --prefer-index=false \ -t "${REGISTRY}/${component}:${VERSION}" \ "${REGISTRY}/${component}:${SOURCE_TAG}" - docker buildx imagetools create \ - --prefer-index=false \ - -t "${REGISTRY}/${component}:latest" \ - "${REGISTRY}/${component}:${SOURCE_TAG}" + if [[ "${IS_PRERELEASE}" != "true" ]]; then + echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as latest..." + docker buildx imagetools create \ + --prefer-index=false \ + -t "${REGISTRY}/${component}:latest" \ + "${REGISTRY}/${component}:${SOURCE_TAG}" + fi done - build-python-wheels-linux: - name: Build Python Wheels (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - artifact: linux-amd64 - task: python:build:linux:amd64 - output_path: target/wheels/linux-amd64/*.whl - - arch: arm64 - runner: linux-arm64-cpu8 - artifact: linux-arm64 - task: python:build:linux:arm64 - output_path: target/wheels/linux-arm64/*.whl - runs-on: ${{ matrix.runner }} - timeout-minutes: 120 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Sync Python dependencies - run: uv sync - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: python-wheel-linux-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Build Python wheels - run: | - set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run ${{ matrix.task }} - ls -la ${{ matrix.output_path }} - - - name: Upload wheel artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-wheels-${{ matrix.artifact }} - path: ${{ matrix.output_path }} - retention-days: 5 - - build-python-wheel-macos: - name: Build Python Wheel (macOS) + build-python-wheel: + name: Build Python Wheel needs: [compute-versions] runs-on: linux-amd64-cpu8 - timeout-minutes: 120 + timeout-minutes: 20 container: image: ghcr.io/nvidia/openshell/ci:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock env: MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }} + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL: ${{ needs.compute-versions.outputs.python_version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - name: Mark workspace safe for git run: git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -224,471 +285,19 @@ jobs: - name: Build Python wheel run: | set -euo pipefail - OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run python:build:macos + mise run python:build ls -la target/wheels/*.whl - - name: Upload wheel artifacts + - name: Upload wheel artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: python-wheels-macos + name: python-wheel path: target/wheels/*.whl retention-days: 5 - # --------------------------------------------------------------------------- - # Build CLI binaries (Linux musl — static, native on each arch) - # - # Builds run directly on the CI host (glibc Ubuntu). Zig provides musl - # C/C++ toolchains for bundled-z3 and ring, and is also used as the linker. - # --------------------------------------------------------------------------- - build-cli-linux: - name: Build CLI (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-musl - zig_target: x86_64-linux-musl - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-musl - zig_target: aarch64-linux-musl - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: cli-musl-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Add Rust musl target - run: mise x -- rustup target add ${{ matrix.target }} - - - name: Set up zig musl wrappers - run: | - set -euo pipefail - ZIG="$(mise which zig)" - ZIG_TARGET="${{ matrix.zig_target }}" - mkdir -p /tmp/zig-musl - - # cc-rs injects --target= (for example - # aarch64-unknown-linux-musl), which zig does not parse. Strip any - # caller-provided --target and use the wrapper's zig-native target. - for tool in cc c++; do - printf '#!/bin/bash\nargs=()\nfor arg in "$@"; do\n case "$arg" in\n --target=*) ;;\n *) args+=("$arg") ;;\n esac\ndone\nexec "%s" %s --target=%s "${args[@]}"\n' \ - "$ZIG" "$tool" "$ZIG_TARGET" > "/tmp/zig-musl/${tool}" - chmod +x "/tmp/zig-musl/${tool}" - done - - TARGET_ENV=$(echo "${{ matrix.target }}" | tr '-' '_') - TARGET_ENV_UPPER=${TARGET_ENV^^} - - # Use zig for C/C++ compilation and final linking. - echo "CC_${TARGET_ENV}=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CXX_${TARGET_ENV}=/tmp/zig-musl/c++" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_LINKER=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - - # Let zig own CRT/startfiles to avoid duplicate _start symbols. - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_RUSTFLAGS=-Clink-self-contained=no" >> "$GITHUB_ENV" - - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build CLI binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-cli-macos: - name: Build CLI (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.cli-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG="${{ needs.compute-versions.outputs.semver }}" \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-aarch64-apple-darwin.tar.gz \ - -C out openshell - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: cli-macos - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binaries (Linux GNU — glibc 2.28 floor) - # --------------------------------------------------------------------------- - build-gateway-binary-linux: - name: Build Gateway Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - zig_target: x86_64-unknown-linux-gnu.2.28 - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - zig_target: aarch64-unknown-linux-gnu.2.28 - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: gateway-binary-gnu-${{ matrix.arch }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Set up Zig C/C++ wrappers - run: tasks/scripts/setup-zig-cc-wrapper.sh ${{ matrix.zig_target }} ${{ matrix.zig_target }} /tmp/zig-gnu - - - name: Build ${{ matrix.zig_target }} - env: - OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.source_sha }} - run: | - set -euo pipefail - mise x -- rustup target add ${{ matrix.target }} - mise x -- cargo zigbuild --release --target ${{ matrix.zig_target }} -p openshell-server --bin openshell-gateway --features bundled-z3 - mkdir -p artifacts/bin - install -m 0755 target/${{ matrix.target }}/release/openshell-gateway artifacts/bin/openshell-gateway - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(artifacts/bin/openshell-gateway --version)" - echo "$OUTPUT" - grep -q '^openshell-gateway ' <<<"$OUTPUT" - ldd artifacts/bin/openshell-gateway || true - if ldd artifacts/bin/openshell-gateway | grep -q 'libz3'; then - echo "gateway binary must not require shared libz3; keep z3 bundled for portable release artifacts" >&2 - exit 1 - fi - - - name: Verify glibc symbol floor - run: tasks/scripts/verify-glibc-symbols.sh 2.28 artifacts/bin/openshell-gateway - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-${{ matrix.target }}.tar.gz \ - -C artifacts/bin openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone supervisor binaries (Linux GNU — native on each arch) - # --------------------------------------------------------------------------- - build-supervisor-binary-linux: - name: Build Supervisor Binary (Linux ${{ matrix.arch }}) - needs: [compute-versions] - strategy: - matrix: - include: - - arch: amd64 - runner: linux-amd64-cpu8 - target: x86_64-unknown-linux-gnu - - arch: arm64 - runner: linux-arm64-cpu8 - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Install tools - run: mise install --locked - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: supervisor-binary-gnu-${{ matrix.arch }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Patch workspace version - if: needs.compute-versions.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Build ${{ matrix.target }} - run: | - set -euo pipefail - mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-sandbox --bin openshell-sandbox - - - name: Verify packaged binary - run: | - set -euo pipefail - OUTPUT="$(target/${{ matrix.target }}/release/openshell-sandbox --version)" - echo "$OUTPUT" - grep -q '^openshell-sandbox ' <<<"$OUTPUT" - - - name: sccache stats - if: always() - run: mise x -- sccache --show-stats - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-sandbox-${{ matrix.target }}.tar.gz \ - -C target/${{ matrix.target }}/release openshell-sandbox - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: supervisor-binary-linux-${{ matrix.arch }} - path: artifacts/*.tar.gz - retention-days: 5 - - # --------------------------------------------------------------------------- - # Build standalone gateway binary (macOS aarch64 via osxcross) - # --------------------------------------------------------------------------- - build-gateway-binary-macos: - name: Build Gateway Binary (macOS) - needs: [compute-versions] - runs-on: linux-amd64-cpu8 - timeout-minutes: 60 - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - options: --privileged - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Set up Docker Buildx - uses: ./.github/actions/setup-buildx - - - name: Build macOS binary via Docker - run: | - set -euo pipefail - docker buildx build \ - --file deploy/docker/Dockerfile.gateway-macos \ - --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \ - --build-arg OPENSHELL_IMAGE_TAG="${{ needs.compute-versions.outputs.semver }}" \ - --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \ - --target binary \ - --output type=local,dest=out/ \ - . - - - name: Verify packaged binary shape - run: | - set -euo pipefail - test -x out/openshell-gateway - - - name: Package binary - run: | - set -euo pipefail - mkdir -p artifacts - tar -czf artifacts/openshell-gateway-aarch64-apple-darwin.tar.gz \ - -C out openshell-gateway - ls -lh artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: gateway-binary-macos - path: artifacts/*.tar.gz - retention-days: 5 - - build-driver-vm-linux: - name: Build Driver VM Linux - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-linux.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: ${{ needs.compute-versions.outputs.semver }} - checkout-ref: ${{ inputs.tag || github.ref }} - secrets: inherit - - build-driver-vm-macos: - name: Build Driver VM macOS - needs: [compute-versions] - uses: ./.github/workflows/driver-vm-macos.yml - with: - cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} - image-tag: ${{ needs.compute-versions.outputs.semver }} - checkout-ref: ${{ inputs.tag || github.ref }} - secrets: inherit - build-deb: name: Build Debian Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-driver-vm-linux] + needs: [compute-versions, build-cli, build-gateway, build-vm-driver] uses: ./.github/workflows/deb-package.yml with: deb-version: ${{ needs.compute-versions.outputs.deb_version }} @@ -697,18 +306,19 @@ jobs: build-snap: name: Build Snap - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-supervisor-binary-linux] + needs: [compute-versions, build-cli, build-gateway, build-sandbox] uses: ./.github/workflows/snap-package.yml with: checkout-ref: ${{ inputs.tag || github.ref }} - upload-channel: latest/stable - github-environment: latest/stable + upload-channel: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }} + github-environment: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }} + publish: ${{ needs.compute-versions.outputs.is_prerelease != 'true' }} secrets: publish-credentials: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} build-rpm: name: Build RPM Packages - needs: [compute-versions, build-cli-linux, build-gateway-binary-linux] + needs: [compute-versions, build-cli, build-gateway] uses: ./.github/workflows/rpm-package.yml with: checkout-ref: ${{ inputs.tag || github.ref }} @@ -719,7 +329,7 @@ jobs: smoke-linux-release-artifacts: name: Smoke Linux Release Artifacts (${{ matrix.name }}) - needs: [build-gateway-binary-linux, build-driver-vm-linux, build-deb, build-rpm, build-python-wheels-linux] + needs: [package-binaries, build-deb, build-rpm, build-python-wheel] timeout-minutes: 20 strategy: fail-fast: false @@ -771,16 +381,6 @@ jobs: runner: linux-amd64-cpu8 image: python:3.12-slim kind: wheel - artifact_arch: amd64 - rpm_arch: x86_64 - target: x86_64-unknown-linux-gnu - - name: python-wheel-arm64 - runner: linux-arm64-cpu8 - image: python:3.12-slim - kind: wheel - artifact_arch: arm64 - rpm_arch: aarch64 - target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.runner }} container: image: ${{ matrix.image }} @@ -844,27 +444,29 @@ jobs: if: matrix.kind == 'wheel' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: python-wheels-linux-${{ matrix.artifact_arch }} + name: python-wheel path: wheel-input/ - name: Smoke Python wheel if: matrix.kind == 'wheel' run: | set -euo pipefail - pip install --no-cache-dir wheel-input/*.whl - python - <<'PY' + python -m venv /tmp/openshell-wheel-smoke + /tmp/openshell-wheel-smoke/bin/pip install --no-cache-dir wheel-input/*.whl + /tmp/openshell-wheel-smoke/bin/python - <<'PY' import importlib for name in ("openshell", "openshell._proto", "openshell.sandbox"): importlib.import_module(name) print(name, "OK") PY + test ! -e /tmp/openshell-wheel-smoke/bin/openshell # --------------------------------------------------------------------------- # Create a tagged GitHub Release with CLI, gateway, driver, and wheels # --------------------------------------------------------------------------- release: name: Release - needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, e2e, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm, build-snap, smoke-linux-release-artifacts] + needs: [compute-versions, package-binaries, build-python-wheel, docker-e2e, podman-e2e, vm-e2e, build-deb, build-rpm, build-snap, smoke-linux-release-artifacts] runs-on: linux-amd64-cpu8 timeout-minutes: 10 permissions: @@ -907,12 +509,11 @@ jobs: path: release/ merge-multiple: true - - name: Download wheel artifacts + - name: Download wheel artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: python-wheels-* + name: python-wheel path: release/ - merge-multiple: true - name: Download Debian package artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -939,9 +540,14 @@ jobs: id: wheel_filenames run: | set -euo pipefail - ls -la release/*.whl - WHEEL_FILENAMES=$(ls release/*.whl | xargs -n1 basename | sort | paste -sd, -) - echo "wheel_filenames=${WHEEL_FILENAMES}" >> "$GITHUB_OUTPUT" + shopt -s nullglob + wheels=(release/*.whl) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "expected exactly one Python wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + wheel_filename=$(basename "${wheels[0]}") + echo "wheel_filenames=${wheel_filename}" >> "$GITHUB_OUTPUT" - name: Generate checksums run: | @@ -964,11 +570,12 @@ jobs: openshell-gateway-aarch64-apple-darwin.tar.gz > openshell-gateway-checksums-sha256.txt cat openshell-gateway-checksums-sha256.txt sha256sum \ - openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz \ - openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt + openshell-sandbox-x86_64-unknown-linux-musl.tar.gz \ + openshell-sandbox-aarch64-unknown-linux-musl.tar.gz > openshell-sandbox-checksums-sha256.txt cat openshell-sandbox-checksums-sha256.txt - name: Generate Homebrew formula + if: needs.compute-versions.outputs.is_prerelease != 'true' run: | set -euo pipefail python3 tasks/scripts/release.py generate-homebrew-formula \ @@ -987,6 +594,7 @@ jobs: release/*.whl - name: Prune removed VM checksum asset + if: needs.compute-versions.outputs.is_prerelease != 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | @@ -1009,6 +617,7 @@ jobs: } - name: Create GitHub Release + if: needs.compute-versions.outputs.is_prerelease != 'true' uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: OpenShell ${{ env.RELEASE_TAG }} @@ -1034,8 +643,8 @@ jobs: release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz release/openshell-gateway-aarch64-apple-darwin.tar.gz - release/openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz - release/openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz + release/openshell-sandbox-x86_64-unknown-linux-musl.tar.gz + release/openshell-sandbox-aarch64-unknown-linux-musl.tar.gz release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz release/openshell-driver-vm-aarch64-apple-darwin.tar.gz @@ -1045,9 +654,19 @@ jobs: release/openshell-gateway-checksums-sha256.txt release/openshell-sandbox-checksums-sha256.txt + - name: Upload pre-release artifacts + if: needs.compute-versions.outputs.is_prerelease == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: openshell-${{ env.RELEASE_TAG }} + path: release/ + retention-days: 90 + if-no-files-found: error + publish-fern-docs: name: Publish Fern Docs - needs: [release] + needs: [compute-versions, release] + if: needs.compute-versions.outputs.is_prerelease != 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -1071,6 +690,45 @@ jobs: working-directory: ./fern run: fern generate --docs + publish-sdk-typescript: + name: Publish TypeScript SDK + needs: [compute-versions, release] + if: needs.compute-versions.outputs.is_prerelease != 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 15 + permissions: + contents: read + packages: write + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install tools + run: mise install --locked + + - name: Configure npm auth for GitHub Packages + working-directory: ./sdk/typescript + run: | + { + echo "@nvidia:registry=https://npm.pkg.github.com" + echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' + } > .npmrc + + - name: Publish + env: + OPENSHELL_NPM_VERSION: ${{ needs.compute-versions.outputs.npm_version }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: mise run sdk:ts:publish + release-helm: name: Release Helm Chart (OCI) needs: [compute-versions, release, tag-ghcr-release] @@ -1092,6 +750,7 @@ jobs: trigger-wheel-publish: name: Trigger Wheel Publish needs: [compute-versions, release] + if: needs.compute-versions.outputs.is_prerelease != 'true' runs-on: [self-hosted, nv] timeout-minutes: 10 steps: diff --git a/.github/workflows/rpm-package.yml b/.github/workflows/rpm-package.yml index 591d10e619..5cda9a88c3 100644 --- a/.github/workflows/rpm-package.yml +++ b/.github/workflows/rpm-package.yml @@ -37,85 +37,20 @@ jobs: matrix: include: - arch: x86_64 - artifact_arch: amd64 runner: linux-amd64-cpu8 cli_target: x86_64-unknown-linux-musl - gnu_target: x86_64-unknown-linux-gnu + gateway_target: x86_64-unknown-linux-gnu - arch: aarch64 - artifact_arch: arm64 runner: linux-arm64-cpu8 cli_target: aarch64-unknown-linux-musl - gnu_target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - container: - image: fedora:latest - steps: - - name: Install build dependencies - run: | - dnf install -y \ - packit rpm-build \ - rust cargo gcc gcc-c++ make cmake pkg-config \ - clang-devel z3-devel systemd-rpm-macros \ - pandoc python3-devel git-core \ - cargo-rpm-macros - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.checkout-ref }} - fetch-depth: 0 - - - name: Download CLI artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: cli-linux-${{ matrix.artifact_arch }} - path: package-input/ - - - name: Download gateway artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: gateway-binary-linux-${{ matrix.artifact_arch }} - path: package-input/ - - - name: Extract package inputs - run: | - set -euo pipefail - mkdir -p package-binaries - tar -xzf "package-input/openshell-${{ matrix.cli_target }}.tar.gz" -C package-binaries - tar -xzf "package-input/openshell-gateway-${{ matrix.gnu_target }}.tar.gz" -C package-binaries - ls -lah package-binaries - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Build RPMs via Packit - env: - OPENSHELL_RPM_VERSION: ${{ inputs['rpm-version'] }} - OPENSHELL_RPM_RELEASE: ${{ inputs['rpm-release'] }} - OPENSHELL_CARGO_VERSION: ${{ inputs['cargo-version'] }} - OPENSHELL_PREBUILT_BINARIES_DIR: ${{ github.workspace }}/package-binaries - run: packit build locally - - - name: Collect RPM artifacts - run: | - set -euo pipefail - mkdir -p artifacts - mapfile -t rpms < <(find "$GITHUB_WORKSPACE" -maxdepth 3 -type f -name '*.rpm' ! -name '*.src.rpm' | sort) - if [ "${#rpms[@]}" -eq 0 ]; then - echo "::error::No RPM artifacts found under $GITHUB_WORKSPACE" - find "$GITHUB_WORKSPACE" -maxdepth 3 -type f | sort - exit 1 - fi - cp "${rpms[@]}" artifacts/ - echo "=== Built RPMs ===" - ls -lah artifacts/ - - - name: Upload RPM artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: rpm-linux-${{ matrix.arch }} - path: artifacts/*.rpm - retention-days: 5 + gateway_target: aarch64-unknown-linux-gnu + uses: ./.github/workflows/build-rpm.yml + with: + checkout-ref: ${{ inputs.checkout-ref }} + arch: ${{ matrix.arch }} + runner: ${{ matrix.runner }} + cli-target: ${{ matrix.cli_target }} + gateway-target: ${{ matrix.gateway_target }} + rpm-version: ${{ inputs.rpm-version }} + rpm-release: ${{ inputs.rpm-release }} + cargo-version: ${{ inputs.cargo-version }} diff --git a/.github/workflows/rust-cache-seed.yml b/.github/workflows/rust-cache-seed.yml deleted file mode 100644 index 1e76384543..0000000000 --- a/.github/workflows/rust-cache-seed.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Rust Cache Seed - -on: - push: - branches: - - main - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SCCACHE_GHA_ENABLED: "true" - -permissions: - contents: read - packages: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - rust: - name: Rust (${{ matrix.runner }}) - strategy: - fail-fast: false - matrix: - runner: [linux-amd64-cpu8, linux-arm64-cpu8] - runs-on: ${{ matrix.runner }} - env: - SCCACHE_GHA_VERSION: branch-checks-rust-${{ matrix.runner }} - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install tools - run: mise install --locked - - - name: Configure GHA sccache backend - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: rust-checks-${{ matrix.runner }} - cache-on-failure: true - - - name: Format - run: mise run rust:format:check - - - name: Lint - run: mise run rust:lint - - - name: Test - run: mise run test:rust - - - name: sccache stats - if: always() - run: | - set +e - stats_bin="${SCCACHE_PATH:-sccache}" - "$stats_bin" --show-stats - status=$? - if [ "$status" -ne 0 ]; then - echo "::warning::sccache stats unavailable (exit $status)" - fi - exit 0 diff --git a/.github/workflows/rust-native-build.yml b/.github/workflows/rust-native-build.yml deleted file mode 100644 index 8ea4df2f3a..0000000000 --- a/.github/workflows/rust-native-build.yml +++ /dev/null @@ -1,307 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -name: Rust Image Binary Build (openshell-gateway / openshell-sandbox / openshell-cli) - -# Build Rust binaries per Linux architecture before the Docker image build -# consumes them as prebuilt artifacts. Gateway images use GNU-linked binaries -# for the NVIDIA distroless C/C++ runtime; supervisor and cli images use musl/static -# binaries so the final image can remain scratch. Gateway GNU binaries are -# built with an explicit glibc 2.28 floor so image, package, and tarball -# artifacts share the same host portability contract. - -on: - workflow_call: - inputs: - component: - description: "Binary component to build (gateway, sandbox, or cli)" - required: true - type: string - arch: - description: "Linux architecture to build (amd64 or arm64)" - required: true - type: string - cargo-version: - description: "Pre-computed cargo version (skips internal git-based computation)" - required: false - type: string - default: "" - features: - description: "Cargo features to enable" - required: false - type: string - default: "" - retention-days: - description: "Artifact retention period" - required: false - type: number - default: 5 - artifact-name: - description: "Artifact name override" - required: false - type: string - default: "" - checkout-ref: - description: "Git ref to check out for build inputs (defaults to the workflow SHA)" - required: false - type: string - default: "" - image-tag: - description: "Supervisor image tag to bake into gateway binaries" - required: false - type: string - default: "" -permissions: - contents: read - packages: read - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Route sccache (already RUSTC_WRAPPER in mise.toml) to the GHA cache - # backend instead of the EKS memcached used by ARC. - SCCACHE_GHA_ENABLED: "true" - -defaults: - run: - shell: bash - -jobs: - rust-native-build: - name: ${{ inputs.component }} (${{ inputs.arch }}) - runs-on: ${{ inputs.arch == 'arm64' && 'linux-arm64-cpu8' || 'linux-amd64-cpu8' }} - timeout-minutes: 60 - env: - COMPONENT: ${{ inputs.component }} - ARCH: ${{ inputs.arch }} - FEATURES: ${{ inputs.features }} - # Partition the GHA sccache cache per (component, arch). Without this, - # concurrent jobs collide on the same cache key and later-starting - # writers hit 409 Conflict. - SCCACHE_GHA_VERSION: ${{ inputs.component }}-${{ inputs.arch }} - container: - image: ghcr.io/nvidia/openshell/ci:latest - credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs['checkout-ref'] || github.sha }} - fetch-depth: 0 - - - name: Mark workspace safe for git - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Fetch tags - run: git fetch --tags --force - - - name: Configure GHA sccache backend - # Exposes ACTIONS_CACHE_URL / ACTIONS_RUNTIME_TOKEN before `mise install` - # compiles cargo-installed tools through RUSTC_WRAPPER=sccache. - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 - - - name: Install tools - run: mise install --locked - - - name: Resolve build target - id: target - run: | - set -euo pipefail - - case "$COMPONENT" in - gateway) - crate=openshell-server - binary=openshell-gateway - zig_target= - ;; - sandbox) - crate=openshell-sandbox - binary=openshell-sandbox - zig_target= - ;; - cli) - crate=openshell-cli - binary=openshell - zig_target= - ;; - *) - echo "unsupported component: $COMPONENT" >&2 - exit 1 - ;; - esac - - case "$ARCH" in - amd64) - if [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then - target=x86_64-unknown-linux-musl - zig_target=x86_64-linux-musl - else - target=x86_64-unknown-linux-gnu - zig_target=x86_64-unknown-linux-gnu.2.28 - fi - ;; - arm64) - if [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then - target=aarch64-unknown-linux-musl - zig_target=aarch64-linux-musl - else - target=aarch64-unknown-linux-gnu - zig_target=aarch64-unknown-linux-gnu.2.28 - fi - ;; - *) - echo "unsupported arch: $ARCH" >&2 - exit 1 - ;; - esac - - { - echo "crate=$crate" - echo "binary=$binary" - echo "target=$target" - echo "zig_target=$zig_target" - } >> "$GITHUB_OUTPUT" - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 - with: - shared-key: rust-native-${{ inputs.component }}-${{ inputs.arch }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} - cache-directories: .cache/sccache - cache-targets: "true" - - - name: Compute cargo version - id: version - run: | - set -euo pipefail - if [[ -n "${{ inputs['cargo-version'] }}" ]]; then - echo "cargo_version=${{ inputs['cargo-version'] }}" >> "$GITHUB_OUTPUT" - else - echo "cargo_version=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" - fi - - - name: Patch workspace version - if: steps.version.outputs.cargo_version != '' - run: | - set -euo pipefail - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ steps.version.outputs.cargo_version }}"'"/}' Cargo.toml - - - name: Set up zig musl wrappers - if: contains(steps.target.outputs.target, 'musl') - run: | - set -euo pipefail - ZIG="$(mise which zig)" - ZIG_TARGET="${{ steps.target.outputs.zig_target }}" - mkdir -p /tmp/zig-musl - - # cc-rs injects --target=, which zig does not parse. - # Strip caller-provided --target and use the wrapper's zig target. - for tool in cc c++; do - printf '#!/bin/bash\nargs=()\nfor arg in "$@"; do\n case "$arg" in\n --target=*) ;;\n *) args+=("$arg") ;;\n esac\ndone\nexec "%s" %s --target=%s "${args[@]}"\n' \ - "$ZIG" "$tool" "$ZIG_TARGET" > "/tmp/zig-musl/${tool}" - chmod +x "/tmp/zig-musl/${tool}" - done - - TARGET_ENV=$(echo "${{ steps.target.outputs.target }}" | tr '-' '_') - TARGET_ENV_UPPER=${TARGET_ENV^^} - - echo "CC_${TARGET_ENV}=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CXX_${TARGET_ENV}=/tmp/zig-musl/c++" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_LINKER=/tmp/zig-musl/cc" >> "$GITHUB_ENV" - echo "CARGO_TARGET_${TARGET_ENV_UPPER}_RUSTFLAGS=-Clink-self-contained=no" >> "$GITHUB_ENV" - - - name: Set up zig glibc wrappers - if: inputs.component == 'gateway' - run: tasks/scripts/setup-zig-cc-wrapper.sh "${{ steps.target.outputs.zig_target }}" "${{ steps.target.outputs.zig_target }}" /tmp/zig-gnu - - - name: Build ${{ steps.target.outputs.binary }} (${{ steps.target.outputs.zig_target || steps.target.outputs.target }}) - env: - # Preserve the release-codegen setting used by the old Dockerfile - # Rust build path so image artifacts keep the same release profile. - CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "1" - OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} - run: | - set -euo pipefail - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - if [[ "${{ inputs.component }}" == "cli" ]]; then - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - fi - - mise x -- rustup target add "${{ steps.target.outputs.target }}" - - cargo_cmd=(cargo build) - build_target="${{ steps.target.outputs.target }}" - args=() - - if [[ "${{ inputs.component }}" == "gateway" ]]; then - cargo_cmd=(cargo zigbuild) - build_target="${{ steps.target.outputs.zig_target }}" - args+=(--features bundled-z3) - fi - args+=( - --release - --target "$build_target" - -p "${{ steps.target.outputs.crate }}" - --bin "${{ steps.target.outputs.binary }}" - ) - if [[ -n "$FEATURES" ]]; then - args+=(--features "$FEATURES") - fi - if [[ -n "${{ steps.version.outputs.cargo_version }}" ]]; then - export GIT_DIR=/nonexistent - fi - mise x -- "${cargo_cmd[@]}" "${args[@]}" - - - name: Verify packaged binary - run: | - set -euo pipefail - BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" - OUTPUT="$("$BIN" --version)" - echo "$OUTPUT" - grep -q "^${{ steps.target.outputs.binary }} " <<<"$OUTPUT" - # Record linkage so image runtime drift is visible in logs. - ldd --version - ldd "$BIN" || true - if [[ "${{ inputs.component }}" == "gateway" ]] && ldd "$BIN" | grep -q 'libz3'; then - echo "gateway binary must not depend on shared libz3; enable bundled-z3 for image artifacts" >&2 - exit 1 - fi - - - name: Verify glibc symbol floor - if: inputs.component == 'gateway' - run: | - set -euo pipefail - BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" - tasks/scripts/verify-glibc-symbols.sh 2.28 "$BIN" - - - name: Stage binary for prebuilt layout - run: | - set -euo pipefail - STAGE="prebuilt-binaries/$ARCH" - mkdir -p "$STAGE" - install -m 0755 \ - "target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" \ - "$STAGE/${{ steps.target.outputs.binary }}" - ls -lh "$STAGE/" - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: ${{ inputs['artifact-name'] != '' && inputs['artifact-name'] || format('rust-binary-{0}-linux-{1}', inputs.component, inputs.arch) }} - path: prebuilt-binaries/${{ inputs.arch }}/${{ steps.target.outputs.binary }} - retention-days: ${{ inputs['retention-days'] }} - if-no-files-found: error - - - name: sccache stats - if: always() - run: | - set +e - stats_bin="${SCCACHE_PATH:-sccache}" - "$stats_bin" --show-stats - status=$? - if [[ $status -ne 0 ]]; then - echo "::warning::sccache stats unavailable (exit $status)" - fi - exit 0 diff --git a/.github/workflows/snap-package.yml b/.github/workflows/snap-package.yml index ed248b79e5..7eb78d3a27 100644 --- a/.github/workflows/snap-package.yml +++ b/.github/workflows/snap-package.yml @@ -17,6 +17,11 @@ on: required: true type: string description: "GitHub deployment environment for approval gates (e.g., latest/edge, latest/stable)" + publish: + required: false + type: boolean + default: true + description: "Whether to upload the built snap to the Snap Store" secrets: publish-credentials: @@ -37,8 +42,10 @@ jobs: matrix: include: - arch: amd64 + rust_arch: x86_64 runner: linux-amd64-cpu8 - arch: arm64 + rust_arch: aarch64 runner: linux-arm64-cpu8 runs-on: ${{ matrix.runner }} timeout-minutes: 60 @@ -79,35 +86,27 @@ jobs: - name: Download prebuilt CLI binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-linux-${{ matrix.arch }} + name: openshell-${{ matrix.rust_arch }}-unknown-linux-musl path: prebuilt/cli - name: Download prebuilt gateway binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: gateway-binary-linux-${{ matrix.arch }} + name: openshell-gateway-${{ matrix.rust_arch }}-unknown-linux-gnu path: prebuilt/gateway - name: Download prebuilt sandbox binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: supervisor-binary-linux-${{ matrix.arch }} + name: openshell-sandbox-${{ matrix.rust_arch }}-unknown-linux-musl path: prebuilt/sandbox - - name: Extract prebuilt binaries + - name: Configure prebuilt binaries run: | set -euo pipefail - mkdir -p prebuilt/{cli,gateway,sandbox} - - for d in cli gateway sandbox; do - for tarball in prebuilt/$d/*.tar.gz; do - if [ -f "$tarball" ]; then - tar -xzf "$tarball" -C "prebuilt/$d" - else - echo "WARNING: no tarball found in prebuilt/$d/" >&2 - fi - done - done + chmod +x prebuilt/cli/openshell + chmod +x prebuilt/gateway/openshell-gateway + chmod +x prebuilt/sandbox/openshell-sandbox ls -laR prebuilt/ - name: Prepare snap build directory @@ -167,8 +166,10 @@ jobs: retention-days: 5 - name: Upload snap to Snap Store + if: inputs.publish env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.publish-credentials }} + INPUTS_UPLOAD_CHANNEL: ${{ inputs.upload-channel }} run: | set -euo pipefail SNAP_FILE="${{ steps.capture.outputs.snap-file }}" @@ -182,5 +183,5 @@ jobs: COMPONENT_ARGS+=(--component "$comp") done - echo "Uploading $SNAP_FILE to ${{ inputs.upload-channel }}" - snapcraft upload --release "${{ inputs.upload-channel }}" "$SNAP_FILE" "${COMPONENT_ARGS[@]}" + echo "Uploading $SNAP_FILE to ${INPUTS_UPLOAD_CHANNEL}" + snapcraft upload --release "${INPUTS_UPLOAD_CHANNEL}" "$SNAP_FILE" "${COMPONENT_ARGS[@]}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index bfdc13af8f..e45ccbf5eb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,21 +9,22 @@ permissions: contents: read jobs: - stale: + stale-issues: runs-on: ubuntu-latest permissions: issues: write - pull-requests: write steps: - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: stale-issue-label: state:stale - stale-pr-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-pr-stale: 14 - days-before-pr-close: -1 # -1 puts this into dry-run mode. Update to 7 to enable closing. + days-before-pr-stale: -1 + days-before-pr-close: -1 + + operations-per-run: 300 + sort-by: updated exempt-issue-labels: state:triage-needed,state:validated,state:accepted,agent:plan-requested,agent:plan-ready,agent:implementation-requested,agent:in-progress,agent:pr-opened,roadmap close-issue-reason: not_planned @@ -35,6 +36,24 @@ jobs: close-issue-message: > Closing this issue after 7 days with no activity since it was marked stale. Reopen it if the work is still relevant. + stale-pull-requests: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + stale-pr-label: state:stale + + 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. + + 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. diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 586444e26e..db2f29f736 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -93,7 +93,7 @@ jobs: path: docs-website - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: version: "0.10.12" diff --git a/.github/workflows/vouch-command.yml b/.github/workflows/vouch-command.yml index 366dd6a0e3..2c258e8e13 100644 --- a/.github/workflows/vouch-command.yml +++ b/.github/workflows/vouch-command.yml @@ -53,6 +53,16 @@ jobs: await github.graphql(mutation, { discussionId, body }); } + async function closeDiscussion() { + const discussionId = await getDiscussionId(); + const mutation = `mutation($discussionId: ID!) { + closeDiscussion(input: {discussionId: $discussionId}) { + discussion { id } + } + }`; + await github.graphql(mutation, { discussionId }); + } + // --- Authorization --- let isMaintainer = false; @@ -134,6 +144,7 @@ jobs: await postDiscussionComment( `@${discussionAuthor} is already vouched. They can submit pull requests.` ); + await closeDiscussion(); return; } @@ -185,4 +196,6 @@ jobs: 'Please read [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md) before submitting.', ].join('\n')); - console.log(`Vouched ${discussionAuthor} (approved by ${commenter}).`); + await closeDiscussion(); + + console.log(`Vouched ${discussionAuthor} (approved by ${commenter}) and closed the discussion.`); diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml new file mode 100644 index 0000000000..3ad1cc68f0 --- /dev/null +++ b/.github/workflows/windows-msvc.yml @@ -0,0 +1,50 @@ +name: Windows MSVC (build-only) +on: + workflow_dispatch: +jobs: + x64: + runs-on: windows-2025 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + install: false + experimental: true + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.95.0" + targets: x86_64-pc-windows-msvc + - name: Cache Rust target and registry + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: windows-msvc-x64 + cache-targets: "true" + cache-on-failure: "true" + cache-bin: "false" + - run: mise run --skip-tools windows:check:x64 + - run: mise run --skip-tools windows:build:x64 + - run: mise run --skip-tools windows:test:x64 + - run: mise run --skip-tools windows:test:unsupported:x64 + arm64: + # TODO: provision a windows-arm64 self-hosted runner + runs-on: [self-hosted, windows-arm64] + if: false # flip to true once the runner is online + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + install: false + experimental: true + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.95.0" + targets: aarch64-pc-windows-msvc + - name: Cache Rust target and registry + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: windows-msvc-arm64 + cache-targets: "true" + cache-on-failure: "true" + cache-bin: "false" + - run: mise run --skip-tools windows:check:arm64 + - run: mise run --skip-tools windows:build:arm64 diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml new file mode 100644 index 0000000000..7a87e15483 --- /dev/null +++ b/.github/workflows/workflow-security.yml @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Workflow Security Reports + +on: + pull_request: + merge_group: + types: [checks_requested] + push: + branches: [main] + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: nix develop --command bash -euo pipefail {0} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: Actionlint (informational) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Nix + uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} + + - name: Run Actionlint + run: | + set -uo pipefail + mkdir -p reports + echo "::add-matcher::.github/actionlint-matcher.json" + + set +e + actionlint -shellcheck= -pyflakes= 2>&1 | tee reports/actionlint.txt + status=${PIPESTATUS[0]} + + echo "::remove-matcher owner=actionlint::" + actionlint \ + -shellcheck= \ + -pyflakes= \ + -format "$(cat .github/actionlint-sarif-template.txt)" \ + > reports/actionlint.sarif + sarif_status=$? + set -e + + if [ "$status" -le 1 ] && [ "$sarif_status" -ne "$status" ]; then + echo "::error::Actionlint could not produce SARIF (exit $sarif_status)." + exit "$sarif_status" + fi + + { + echo "### Actionlint" + echo + } >> "$GITHUB_STEP_SUMMARY" + + case "$status" in + 0) + echo "No findings." >> "$GITHUB_STEP_SUMMARY" + ;; + 1) + echo "::warning::Actionlint reported findings; this check is informational." + echo "Findings were reported as annotations and do not fail CI." >> "$GITHUB_STEP_SUMMARY" + ;; + 2|3) + echo "::error::Actionlint could not complete (exit $status)." + echo "Actionlint failed to run correctly (exit $status)." >> "$GITHUB_STEP_SUMMARY" + exit "$status" + ;; + *) + echo "::error::Actionlint returned unexpected exit code $status." + echo "Actionlint returned unexpected exit code $status." >> "$GITHUB_STEP_SUMMARY" + exit "$status" + ;; + esac + + - name: Upload Actionlint SARIF to Code Scanning + if: success() + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: reports/actionlint.sarif + category: actionlint + + - name: Upload Actionlint report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: actionlint-${{ github.run_id }} + path: | + reports/actionlint.txt + reports/actionlint.sarif + if-no-files-found: ignore + retention-days: 14 + + zizmor: + name: Zizmor High report (informational) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Nix + uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} + + - name: Run Zizmor + run: | + set -euo pipefail + mkdir -p reports + + zizmor \ + --offline \ + --persona=regular \ + --min-severity=high \ + --no-exit-codes \ + --format=json \ + . > reports/zizmor-high.json + + zizmor \ + --offline \ + --persona=regular \ + --min-severity=high \ + --no-exit-codes \ + --format=sarif \ + . > reports/zizmor-high.sarif + + finding_count=$(jq 'length' reports/zizmor-high.json) + { + echo "### Zizmor high-severity report" + echo + echo "Zizmor has no critical severity; high is its maximum level." + echo + echo "Findings: $finding_count" + if [ "$finding_count" -gt 0 ]; then + echo + jq -r \ + 'group_by(.ident) | .[] | "- `\(.[0].ident)`: \(length)"' \ + reports/zizmor-high.json + echo + echo "These findings are informational and do not fail CI." + fi + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$finding_count" -gt 0 ]; then + echo "::warning::Zizmor reported $finding_count high-severity findings; this check is informational." + fi + + - name: Upload Zizmor SARIF to Code Scanning + if: success() + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: reports/zizmor-high.sarif + category: zizmor-high + + - name: Upload Zizmor reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zizmor-high-${{ github.run_id }} + path: | + reports/zizmor-high.json + reports/zizmor-high.sarif + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000000..7b1e035b77 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +rules: + dangerous-triggers: + ignore: + # These base-branch workflows never check out or execute pull request + # head code. Keep each suppression scoped to its reviewed trigger block. + - dco.yml:3 + - e2e-label-help.yml:13 + - release-canary.yml:3 + - required-ci-gates.yml:3 + - vouch-check.yml:3 diff --git a/.gitignore b/.gitignore index 40307c7a1d..3ef1a37697 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +coverage.out +coverage/ htmlcov/ .tox/ .nox/ @@ -133,6 +135,16 @@ auto-save-list tramp .\#* +# ============================================================================= +# Agent tooling +# ============================================================================= + +# Local agent state and configuration +.codex/ +.pi/ +.claude/settings.local.json +.claude/worktrees/ + # ============================================================================= # OS # ============================================================================= @@ -201,15 +213,9 @@ artifacts/ # Local mise settings mise.local.toml -# Local Codex app state -.codex/ - # Ignore plans for now architecture/plans -# Claude -.claude/settings.local.json -.claude/worktrees/ rfc.md .worktrees .z3-trace @@ -230,6 +236,3 @@ scripts/lint-mermaid/node_modules/ # Nix /result /result-* - -# Bazel -bazel-* diff --git a/.packit.yaml b/.packit.yaml index 6379d8db83..d3b92eafae 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -11,7 +11,6 @@ specfile_path: openshell.spec # Packages needed in the SRPM build environment to create vendor tarball srpm_build_deps: - - rust - cargo - git-core @@ -45,10 +44,6 @@ actions: # dist-info stays at the RPM Version; dev build identity is carried by # Release so Fedora's Python RPM post-processing can normalize metadata. - 'bash -c "if [ -n \"${OPENSHELL_CARGO_VERSION:-}\" ]; then sed -i -r \"s/^%global openshell_cargo_version .*/%global openshell_cargo_version ${OPENSHELL_CARGO_VERSION}/\" openshell.spec; fi"' - # Override image_tag to 'latest' for tagged stable releases. - # For PR and commit-to-main builds the spec default ('dev') is kept, - # matching the :dev images pushed by release-dev.yml. - - 'bash -c "if git describe --exact-match --tags HEAD 2>/dev/null | grep -qE ''^v[0-9]+\.[0-9]+\.[0-9]+$''; then sed -i ''s/^%global image_tag.*/%global image_tag latest/'' openshell.spec; fi"' jobs: # Build on every pull request targeting main for CI validation diff --git a/AGENTS.md b/AGENTS.md index 540a8d9207..9a07ca0b7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,18 +10,23 @@ OpenShell is built agent-first. We design systems and use agents to implement th ## Skills -Agent skills live in `.agents/skills/`. Your harness can discover and load them natively — do not rely on this file for a full inventory. The detailed skills table is in [CONTRIBUTING.md](CONTRIBUTING.md) (for humans). +OpenShell has two skill collections: + +- `skills/` contains public, installable skills for using and operating OpenShell. These skills must work outside a source checkout and use installed CLI help plus published documentation as their sources of truth. +- `.agents/skills/` contains internal contributor and maintainer workflows for developing OpenShell. Your repository-aware harness can discover and load them natively. + +Do not rely on this file for a full inventory. The detailed public and contributor skill tables are in [CONTRIBUTING.md](CONTRIBUTING.md) (for humans). ## Workflow Chains These pipelines connect skills into end-to-end workflows. Individual skill files don't describe these relationships. - **Community inflow:** `triage-issue` → human disposition and roadmap placement → `create-spike` when needed → `build-from-issue` - - Triage establishes facts and marks technically valid issues `state:validated`. A human applies `state:accepted` if the project should pursue the work and separately places it on the roadmap. The `agent:*` labels support unattended agents that scan for queued work: a human queues a plan with `agent:plan-requested`, the agent returns `agent:plan-ready`, and a human queues implementation with `agent:implementation-requested`. A direct user request to an agent authorizes the requested phase without those labels. + - Triage establishes facts and marks technically valid issues `state:validated`. A human signals that the project should pursue the work by applying `state:accepted` or placing the issue on the roadmap. The `agent:*` labels support unattended agents that scan for queued work: a human queues a plan with `agent:plan-requested`, the agent returns `agent:plan-ready`, and a human queues implementation with `agent:implementation-requested`. A direct user request to an agent authorizes the requested phase even when the expected lifecycle or workflow labels are missing or incomplete; the agent warns about the discrepancies and continues without changing the labels. - **Internal development:** `create-spike` → human disposition and roadmap placement → `build-from-issue` - - Spike explores feasibility and marks its issue `state:validated` when sufficient evidence exists. A human accepts it with `state:accepted` or declines it, separately places it on the roadmap, and optionally queues it through the `agent:*` workflow or directs an agent to it. + - Spike explores feasibility and marks its issue `state:validated` when sufficient evidence exists. A human accepts it with `state:accepted` or roadmap placement, or declines it, and optionally queues it through the `agent:*` workflow or directs an agent to it. A direct request proceeds after warning about missing or incomplete expected labels. - **Security:** `review-security-issue` → `fix-security-issue` - - General build agents must not process `topic:security` issues. For unattended processing, a human queues specialized review with `agent:plan-requested`; review produces a severity assessment and remediation plan; a human queues remediation with `agent:implementation-requested`. Direct requests to the specialized skills do not require those labels. + - General build agents must not process `topic:security` issues. For unattended processing, a human queues specialized review with `agent:plan-requested`; review produces a severity assessment and remediation plan; a human queues remediation with `agent:implementation-requested`. On direct requests to the specialized skills, missing workflow labels produce a warning rather than blocking the requested phase. - **Policy iteration:** `openshell-cli` → `generate-sandbox-policy` - CLI manages the sandbox lifecycle; policy generation authors the YAML constraints. @@ -30,22 +35,32 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | Path | Components | Purpose | |------|-----------|---------| | `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 | Distributable `list` and `run` entrypoint for gateway conformance | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Container supervision, policy-enforced egress routing | | `crates/openshell-policy/` | Policy engine | Filesystem, network, process, and inference constraints | | `crates/openshell-router/` | Privacy router | Privacy-aware LLM routing | | `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage | | `crates/openshell-gateway-interceptors/` | Gateway interceptors | Intercepts and transforms configured gRPC requests at the gateway routing boundary | -| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers | +| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.8.0 event types, builders, shorthand/JSONL formatters, tracing layers | | `crates/openshell-otel/` | OpenTelemetry support | Shared OTLP trace provider, resource, and tracing-layer construction | +| `crates/openshell-otel-test-support/` | OpenTelemetry test support | Shared loopback OTLP collector fixture for tracing tests | | `crates/openshell-core/` | Shared core | Common types, configuration, error handling | +| `crates/openshell-extension-core/` | Extension core | Shared extension identity, JWT claims, bearer-token rotation, and TLS transport primitives | +| `crates/openshell-gateway/` | Gateway binary composition | Links selected first-party compute drivers into the backend-agnostic server registry | | `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` | | `crates/openshell-providers/` | Provider management | Credential provider backends | | `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring | +| `crates/openshell-driver-kubernetes-secrets/` | Kubernetes Secrets credential driver | In-process `CredentialDriver` backend for OpenShell-managed K8s Secret storage | +| `crates/openshell-driver-vault/` | Vault credential driver | In-process `CredentialDriver` backend for Vault-compatible KV storage | +| `crates/openshell-driver-db-credstore/` | Database credential driver | In-process `CredentialDriver` backend for gateway database credential storage | | `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods | | `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers | +| `crates/openshell-driver-mxc/` | MXC compute driver | Windows in-process `ComputeDriver` backend for MXC sandbox execution | | `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers | | `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) | +| `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows AppContainer and isolation-session compute backend | | `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | | `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | | `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution | @@ -54,11 +69,13 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-supervisor-process/` | Process supervisor | Process lifecycle, namespace, and bypass monitoring | | `crates/openshell-vfio/` | VFIO support | PCI and GPU passthrough preparation and lifecycle | | `python/openshell/` | Python SDK | Python bindings and CLI packaging | +| `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types | | `proto/` | Protobuf definitions | gRPC service contracts | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | | `docs/` | Published docs | MDX pages, navigation, and content assets | | `fern/` | Docs site config | Fern site config, components, and theme assets | -| `.agents/skills/` | Agent skills | Workflow automation for development | +| `skills/` | Public agent skills | Installable workflows for using and operating OpenShell | +| `.agents/skills/` | Contributor agent skills | Repository-aware workflows for developing OpenShell | | `.agents/agents/` | Agent personas | Sub-agent definitions (e.g., reviewer, doc writer) | | `architecture/` | Architecture docs | Design decisions and component documentation | @@ -71,11 +88,11 @@ These pipelines connect skills into end-to-end workflows. Individual skill files ## Issue and PR Conventions -- **Bug reports** must include an agent diagnostic section — proof that the reporter's agent investigated the issue before filing. See the issue template. -- **Feature requests** must include a design proposal, not just a "please build this" request. See the issue template. +- **Bug reports and feature requests** must include a User Story, Problem Statement, Impact / Why This Matters, and Acceptance Criteria. The impact should explain the consequences of the current behavior, the current workaround, and why that workaround is insufficient. Bug reports additionally require reproduction steps and environment details and may include concise, redacted logs. +- **Feature requests** must also include a Proposed Design and Alternatives Considered. The design should define the user-facing workflow and externally observable behavior while leaving internal implementation choices open. Agent investigation is optional. - **New features** must start as GitHub issues using the feature request template. Open an RFC only after an issue exists; maintainers decide when one is needed and assign RFC numbers from the issue. -- **Issue triage** establishes technical validity and impact evidence. Agents never decide roadmap acceptance, apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. Humans accept or decline validated work and separately place it on the roadmap. The request labels queue work for unattended agents; an explicit user instruction can instead authorize an agent to plan or implement a specific issue. OpenShell has no `priority:*` labels; roadmap association carries sequencing. -- **PRs** must follow the PR template structure: Summary, Related Issue, Changes, Testing, Checklist. +- **Issue triage** establishes technical validity and impact evidence. Agents never decide acceptance, apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. Humans accept or decline validated work; `state:accepted` or roadmap placement records acceptance, and roadmap association additionally carries sequencing. Lifecycle and request labels gate unattended queue pickup. An explicit user instruction authorizes an agent to plan or implement the specified issue even when expected labels are missing or incomplete; the agent warns the user and continues without changing those labels. OpenShell has no `priority:*` labels. +- **PRs** must follow the PR template structure: Summary, Related Issue, Changes, Testing, Checklist. Contributors should use their agent to investigate the current code and behavior for accepted issue-backed work, verify any diagnostics already on the issue, understand the change they submit, and report the resulting implementation and verification—not paste an earlier issue-filing diagnostic. - **PRs for features, user-visible behavior, public APIs, architecture, or multi-PR efforts** must link an accepted issue. Small docs fixes, mechanical maintenance, and obvious localized bug fixes may state why no issue is required. - **PRs from unvouched external contributors** are automatically closed. See the Vouch System section above. - **Security vulnerabilities** must NOT be filed as GitHub issues. Follow [SECURITY.md](SECURITY.md). @@ -169,6 +186,19 @@ ocsf_emit!(event); - If you change sandbox infrastructure, ensure the relevant sandbox e2e path succeeds. +## Network Sockets + +- On latency-sensitive TCP streams, disable Nagle's algorithm so small + request/response frames don't stall on delayed ACKs. Use + `openshell_core::net::set_tcp_nodelay_best_effort` on an accepted or + already-connected stream, or `openshell_core::net::connect_tcp_nodelay_best_effort` + when dialing. +- This applies to loopback/localhost TCP too — the delayed-ACK stall is a timer + behavior, not wire latency. +- You should skip it for unix domain sockets (no Nagle). It's not critical for + test-only connections, though using it on any non-UDS TCP stream — tests + included — is fine and preferred. + ## Commits - Always use [Conventional Commits](https://www.conventionalcommits.org/) format for commit messages @@ -189,6 +219,23 @@ ocsf_emit!(event); - `mise run e2e` — End-to-end tests against a running gateway. Run for infrastructure, sandbox, or policy changes. - `mise run ci` — Full local CI (lint + compile/type checks + tests). Run before opening a PR. +## Go SDK (`sdk/go/`) + +- The Go SDK lives in `sdk/go/` with module path `github.com/NVIDIA/OpenShell/sdk/go`. +- Run `mise run go:ci` for the full SDK CI pipeline (lint, build, test, proto-check, docs-check). +- Proto bindings are generated with `mise run go:proto:gen` from the `.proto` files in `proto/`. +- Domain types in `sdk/go/openshell/v1/types/` must not import proto packages. +- Converters in `sdk/go/openshell/v1/internal/converter/` deep-copy slices and maps at boundaries. +- Tests use bufconn for in-process gRPC and testify for assertions. + +## TypeScript SDK (`sdk/typescript/`) + +- Run `mise run sdk:ts:ci` for codegen, proto lint, Biome lint, type checking, unit tests, coverage, and build validation. +- Proto bindings are generated with `mise run sdk:ts:proto` from the files selected in `sdk/typescript/buf.gen.yaml`. +- Generated files under `sdk/typescript/src/gen/` are build outputs and must not be committed. +- Keep the curated API free of generated wire types; expose full generated messages and RPCs through `@nvidia/openshell-sdk/raw`. +- The release workflow publishes the package to GitHub Packages. Branch checks exercise the publish path with `npm publish --dry-run`. + ## Python - Always use `uv` for Python commands (e.g., `uv pip install`, `uv run`, `uv venv`) @@ -199,7 +246,7 @@ ocsf_emit!(event); ## Cluster Infrastructure Changes -- If you change gateway deployment infrastructure (e.g., Helm values/templates, gateway image packaging, or deploy logic in `openshell-cli`), update the `debug-openshell-cluster` skill in `.agents/skills/debug-openshell-cluster/SKILL.md` to reflect those changes. +- If you change gateway deployment infrastructure (e.g., Helm values/templates, gateway image packaging, or deploy logic in `openshell-cli`), update the `debug-openshell-cluster` skill in `skills/debug-openshell-cluster/SKILL.md` to reflect those changes. ## Skill Maintenance @@ -212,8 +259,8 @@ When behavior, commands, or development workflows change, review the related age - When changing gateway TOML fields, driver-specific config options, config defaults, or Helm rendering of `gateway.toml`, update `docs/reference/gateway-config.mdx` in the same branch. - `fern/` contains the Fern site config, components, preview workflow inputs, and publish settings. - Follow the docs style guide in [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx): active voice, minimal formatting, no filler introductions, `shell` fences for copyable commands, and no duplicate body H1. -- Fern PR previews run through `.github/workflows/branch-docs.yml`, and production publish runs through the `publish-fern-docs` job in `.github/workflows/release-tag.yml`. -- Use the `update-docs` skill to scan recent commits and draft doc updates. +- Fern PR previews run through `.github/workflows/branch-docs.yml`, and production publish runs through the `publish-fern-docs` job in `.github/workflows/release-tag.yml` for stable release tags. +- Use the `update-docs-from-commits` skill to scan recent commits and draft doc updates. ### Architecture Docs diff --git a/BUILD.bazel b/BUILD.bazel deleted file mode 100644 index 007ea9741a..0000000000 --- a/BUILD.bazel +++ /dev/null @@ -1 +0,0 @@ -exports_files(["deploy/rpm/gateway.toml.default"]) diff --git a/CI.md b/CI.md index 2eb3da7571..c29abdba0c 100644 --- a/CI.md +++ b/CI.md @@ -14,17 +14,71 @@ Merge queue validation is a second integration gate for `main`. After a PR has p Three opt-in labels enable the long-running E2E suites: -- `test:e2e` runs the standard Docker, rootless Podman, Kubernetes, and VM E2E - suites in `Branch E2E Checks` +- `test:e2e` runs the Docker, rootless Podman, Kubernetes, and VM E2E suites + with both managed and standalone compute drivers in `Branch E2E Checks` - `test:e2e-gpu` runs GPU E2E in `Branch E2E Checks` - `test:e2e-kubernetes` runs Kubernetes E2E with the HA Helm overlay - (`replicaCount: 2` and bundled PostgreSQL) in `Branch E2E Checks` + (`replicaCount: 2` and bundled PostgreSQL) and the credential-driver suite + (Kubernetes Secrets plus Vault) in `Branch E2E Checks` -When multiple labels are present, `Branch E2E Checks` builds the shared gateway and supervisor images once, builds one CLI artifact per runner architecture, builds the Linux VM driver artifact once, and fans out all enabled suites in parallel. Docker, Podman, GPU, Rust, Python, MCP, and VM E2E jobs reuse the matching prebuilt gateway and CLI binaries instead of compiling additional debug binaries in each job; Kubernetes E2E consumes the gateway image directly and reuses the prebuilt CLI. VM E2E also reuses the prebuilt VM driver artifact and falls back to local VM-driver/runtime preparation for local runs or workflow invocations that omit the artifact. -The `OpenShell / E2E` and `OpenShell / GPU E2E` required statuses are evaluated from separate suite result jobs inside that workflow. `test:e2e-kubernetes` is optional while HA behavior is under active iteration: failures are visible in the workflow run but do not publish a required CI gate status. +When multiple labels are present, `Branch E2E Checks` builds each generic multi-architecture artifact set once and fans out enabled suites in parallel. Runtime-specific reusable workflows define the Docker, Podman, VM, and Kubernetes lanes. Composite actions own the replaceable Podman, KVM, kind, and mise setup. Each lane depends only on the artifact categories it consumes: VM does not wait for container-driver artifacts or supervisor images, and GPU does not wait for the gateway image. Docker, Podman, GPU, Rust, Python, MCP, and VM E2E reuse matching prebuilt gateway and CLI binaries instead of compiling debug binaries in test jobs. Standalone-driver lanes additionally reuse driver-free gateway and compute-driver artifacts. Kubernetes managed-driver lanes consume published gateway and supervisor images, while the standalone-driver lane composes its gateway image from prebuilt binaries. +The `OpenShell / E2E` and `OpenShell / GPU E2E` required statuses are evaluated from separate suite result jobs inside that workflow. `test:e2e-kubernetes` is optional while Kubernetes HA and credential-driver behavior are under active iteration: failures are visible in the workflow run but do not publish a required CI gate status. The GitHub ruleset should require the `OpenShell / ...` statuses published by `Required CI Gates`, not the push-triggered workflow jobs directly. +## Informational security reports + +Security workflow compute runs directly on GitHub-hosted runners instead of +NVIDIA self-hosted runners. The PR-oriented workflows receive no secrets and run +on fork pull requests without waiting for copy-pr-bot. Codex Security release +qualification is the exception: it runs only for maintainer-created +pre-release tags and receives a scoped NVIDIA Inference API key for the scan +step. Scanner jobs request `security-events: write` to publish SARIF to Code +Scanning. GitHub permits Code Scanning uploads from `pull_request` runs even +when fork and Dependabot contexts receive a read-only `GITHUB_TOKEN`, so those +scanners upload results directly: + +- `Workflow Security Reports` runs Actionlint and Zizmor. Actionlint reports + workflow syntax and expression findings. Zizmor reports only High severity, + its maximum level. Nix provides both scanners. They publish SARIF to Code + Scanning and retain report artifacts. +- `Dependency Review` compares the base and head dependency graphs and reports + newly introduced vulnerabilities with a High-or-higher policy. It runs in + warn-only mode. A preflight turns an unavailable GitHub Dependency Graph into + a warning, so the workflow remains neutral until the repository feature is + available. +- `CodeQL` analyzes product Rust code, examples, and the Go, Python, and + TypeScript SDKs. Rust `cfg(test)` blocks, Rust integration-test targets, and + E2E test code are excluded. It runs nightly on `main`, remains manually + dispatchable for diagnostics, uploads results to Code Scanning, and retains + workflow artifacts. +- `Codex Security Release Qualification` analyzes each `vX.Y.Z-pre.N` + candidate against the previous stable release. Every candidate in a release + train therefore rescans the cumulative stable-to-candidate diff. It calls + `https://inference-api.nvidia.com/v1` with + `openai/openai/gpt-5.6-sol` at medium reasoning effort. The + `CODEX_SECURITY_API_KEY` secret must contain an API key authorized for that + NVIDIA endpoint. Results are uploaded against the candidate commit on `main` + under a category shared by the train, for example + `codex-security/v0.1.1`, so later candidates replace earlier analyses. The + slash-qualified NVIDIA model identifier prevents Codex Security 0.1.24 from + enforcing `--max-cost`, so the workflow relies on its timeout, serialized + concurrency, and the inference account's spend controls. Raw reports are not + retained as workflow artifacts. Pre-release creation and stable-promotion + enforcement remain part of RFC 0014 and are not implemented by this workflow. + +Findings do not fail these workflows. Tool startup, configuration, build, and +analysis failures still fail so a broken scanner cannot appear healthy. The +PR-oriented reports also run on merge groups; CodeQL is not a PR or merge-queue +check. None of these reports are required statuses or gate merges. + +Run the workflow-definition scanners locally with: + +```shell +nix develop --command actionlint -shellcheck= -pyflakes= +nix develop --command zizmor --offline --persona=regular --min-severity=high --no-exit-codes . +``` + ## Commit signing copy-pr-bot decides whether to mirror a PR automatically based on whether the author is trusted. For org members and collaborators, "trusted" means **all commits in the PR are cryptographically signed**. Unsigned commits, even from an org member, force the bot to wait for a maintainer's `/ok to test `. @@ -135,12 +189,21 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma | File | Role | |---|---| | `.github/workflows/branch-checks.yml` | Required non-E2E checks. Triggers on `push: pull-request/[0-9]+` for PR mirrors and `merge_group` for queued merges. | -| `.github/workflows/branch-e2e.yml` | Standard, GPU, and Kubernetes HA E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups run core and GPU E2E. | +| `.github/workflows/branch-e2e.yml` | Standard, GPU, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups run core and GPU E2E. | +| `.github/workflows/build-{cli,gateway,sandbox}-binaries.yml` | Independent target matrices used by branch and release workflows without creating skipped jobs. | +| `.github/workflows/package-release-binaries.yml` | Packages raw build artifacts into release tarballs without rebuilding them. | +| `.github/workflows/e2e-docker-test.yml`, `e2e-podman-test.yml`, `e2e-vm-test.yml`, `e2e-kubernetes-test.yml` | Reusable runtime lanes called directly by branch and release workflows. Callers select suites and declare only the artifacts each runtime consumes. | +| `.github/actions/setup-e2e-*` | Shared artifact, Podman, KVM, and kind setup used by the runtime lanes. | | `.github/workflows/helm-lint.yml` | Helm chart validation. PR mirror pushes skip lint jobs unless Helm inputs changed; merge groups always validate Helm because they represent the final integration state. | +| `.github/actions/setup-nix/action.yml` | Installs Nix and configures the OpenShell Cachix cache, using read-only cache access when no authentication token is available. | | `.github/actions/pr-gate/action.yml` | Composite action that resolves PR metadata and verifies the required label is set for PR mirror pushes. Non-push events are allowed through. | | `.github/actions/pr-merge-base/action.yml` | Composite action that resolves and fetches the merge-base commit for `pull-request/` push workflows. | | `.github/workflows/required-ci-gates.yml` | Posts required PR-head and merge-group statuses for gated CI workflows. This is what branch protection and merge queue should require. | | `.github/workflows/e2e-label-help.yml` | When a `test:e2e*` label is applied, posts a PR comment telling the maintainer the next manual step (re-run an existing workflow run, or `/ok to test ` to refresh the mirror). | +| `.github/workflows/workflow-security.yml` | Runs informational Actionlint and High-severity Zizmor reports on GitHub-hosted runners. | +| `.github/workflows/dependency-review.yml` | Reports dependency changes when GitHub Dependency Graph is available; otherwise publishes a neutral warning. | +| `.github/workflows/codeql.yml` | Runs nightly informational CodeQL analysis on `main` for Rust and the Go, Python, and TypeScript SDKs and retains SARIF artifacts. | +| `.github/workflows/codex-security.yml` | Scans the cumulative diff from the previous stable release to each pre-release candidate and publishes train-scoped SARIF on `main`. | ## Release workflows @@ -149,7 +212,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. | File | Role | |---|---| | `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway/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 a tagged public release. | +| `.github/workflows/release-tag.yml` | Publishes a tagged stable release. Its automatic tag trigger excludes `-pre.*`; manual dispatch remains maintainer-controlled. | | `.github/workflows/release-canary.yml` | Smoke-tests published artifacts on `macos`, `ubuntu`, `fedora`, and `kubernetes` (kind + Helm) runners. Triggers automatically when `Release Dev` succeeds, and via `workflow_dispatch` on any branch (`gh workflow run release-canary.yml --ref `). The `kubernetes` job pins to `0.0.0-dev` artifacts; the other jobs install the latest tagged release via `install.sh`. See the `test-release-canary` skill for the manual-dispatch playbook and local kind reproduction. | ## Required status contexts @@ -162,3 +225,6 @@ Require these statuses in the branch ruleset for PR and merge-queue CI: - `OpenShell / Helm Lint` Do not require the underlying workflow jobs directly. PR workflow jobs only appear after copy-pr-bot mirrors trusted code, and merge-group workflow jobs run on temporary queue branches. The stable `OpenShell / ...` contexts prove the expected workflow completed for the commit that GitHub is about to merge. + +Do not add the informational Actionlint, Zizmor, Dependency Review, or CodeQL +jobs to the required status list while they remain in observation mode. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd21137c50..ab1fa2e7a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to OpenShell -OpenShell is built agent-first. We design systems and use agents to implement them. Your agent is your first collaborator — point it at this repo before opening issues, asking questions, or submitting code. +OpenShell is built agent-first. We use agents to design and implement systems, while humans manage product decisions and the project roadmap. ## The Critical Rule @@ -25,7 +25,8 @@ We use a vouch system. This exists because AI makes it trivial to generate plaus 1. Open a [Vouch Request](https://github.com/NVIDIA/OpenShell/discussions/new?category=vouch-request) discussion. 2. Describe what you want to change and why. 3. Write in your own words. AI-generated vouch requests will be denied. -4. A maintainer will comment `/vouch` if approved. +4. A maintainer will comment `/vouch` if approved, and the request discussion + will close automatically. 5. 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. @@ -34,57 +35,78 @@ We use a vouch system. This exists because AI makes it trivial to generate plaus Issues labeled [`good first issue`](https://github.com/NVIDIA/OpenShell/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) are scoped, well-documented, and friendly to new contributors. Start there. If you need guidance, comment on the issue. -An open issue is not necessarily accepted or ready to be worked on. Human contributors should look for `state:accepted`, `good first issue`, or `help wanted`, or ask a maintainer before starting. Unattended agents additionally require the appropriate human-applied `agent:*` request label; an agent directly asked to work on a specific issue does not. Roadmap placement describes sequencing and does not authorize work. +An open issue is not necessarily accepted or ready to be worked on. Human contributors should look for `state:accepted`, roadmap placement, `good first issue`, or `help wanted`, or ask a maintainer before starting. Unattended agents require the expected lifecycle state and the appropriate human-applied `agent:*` request label. An agent directly asked to work on a specific issue warns about missing or incomplete expected labels and continues with the requested phase without changing them. ## Before You Open an Issue -This project ships with [agent skills](#agent-skills-for-contributors) that can diagnose problems, explore the codebase, generate policies, and walk you through common workflows. Before filing an issue: +Search open and closed issues for the same need. Bug reports and feature requests must include: -1. Clone the repo and point your coding agent at it. -2. Load the relevant skill - `debug-openshell-cluster` for gateway or deployment problems, `debug-inference` for inference setup problems, `openshell-cli` for usage questions, `generate-sandbox-policy` for policy help. -3. Have your agent investigate. Let it run diagnostics, read the architecture docs, and attempt a fix. -4. If the agent cannot resolve it, open an issue **with the agent's diagnostic output attached**. The issue template requires this. +1. **User Story:** who needs the change and what they need to do. +2. **Problem Statement:** a concise summary of what is broken or missing in the current behavior. +3. **Impact / Why This Matters:** the consequences of the current behavior, the current workaround, and why that workaround is insufficient. +4. **Acceptance Criteria:** specific, observable outcomes that define success. + +Feature requests must also propose a user-facing workflow and describe alternatives considered. Define the externally observable behavior and leave internal implementation choices open. Bug reports instead include minimal reproduction steps, the OpenShell version and relevant environment, and a small, redacted log excerpt when it materially clarifies the behavior. + +The project includes optional [agent skills](#agent-skills) for using OpenShell and contributing to the repository. Use them when they help you, but summarize any useful result in your own words rather than pasting a diagnostic transcript. ### When to Open an Issue -- A real bug that your agent confirmed and could not fix. -- A feature proposal with a design — not a "please build this" request. -- An infrastructure problem that the gateway deployment troubleshooting skill could not resolve. -- An inference setup problem that the `debug-inference` skill could not resolve. +- A workflow behaves differently from what you need or reasonably expect. +- OpenShell does not support an outcome that matters to your workflow. +- The available documentation or configuration does not explain how to complete a supported workflow. - Security vulnerabilities must follow [SECURITY.md](SECURITY.md) — **not** GitHub issues. ### When NOT to Open an Issue -- Questions about how things work — your agent can answer these from the codebase and architecture docs. -- Configuration problems - your agent can diagnose these with `openshell-cli`, `debug-openshell-cluster`, and `debug-inference`. -- "How do I..." requests — the skills cover CLI usage, policy generation, TUI development, and more. +- General questions or open-ended discussion — use [GitHub Discussions](https://github.com/NVIDIA/OpenShell/discussions). +- Security vulnerabilities — follow [SECURITY.md](SECURITY.md) instead. + +## Before You Submit a Change + +Do not start substantial issue-backed work until a maintainer has accepted the issue, unless a maintainer directly asks you to investigate or implement it. Once the work is authorized, use your agent to investigate the current code and behavior. If the issue contains earlier diagnostics, verify them rather than relying on them. + +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. + +## Agent Skills + +OpenShell keeps skills for using the product separate from skills for developing the repository. + +### Skills for Using OpenShell + +Public skills live in `skills/` and work without an OpenShell source checkout. Install them with `npx skills add NVIDIA/OpenShell`. + +| Skill | Purpose | +| --- | --- | +| `openshell-cli` | CLI usage, sandbox lifecycle, provider management, and BYOC workflows | +| `debug-openshell-cluster` | Diagnose gateway deployment and health issues | +| `debug-inference` | Diagnose managed, system, local, and direct external inference issues | +| `generate-sandbox-policy` | Generate YAML sandbox policies from requirements or API documentation | + +Public skills use `openshell --help` for installed command syntax and published OpenShell documentation for product concepts and configuration. They must not depend on repository-relative source or documentation files. -## Agent Skills for Contributors +### Agent Skills for Contributors -Skills live in `.agents/skills/`. Your agent's harness can discover and load them natively. Here is the full inventory: +Contributor and maintainer skills live in `.agents/skills/`. They are marked internal so the Agent Skills CLI excludes them from ordinary public discovery, but repository-aware agent harnesses can discover and load them natively. Internal metadata is a discovery filter, not an access-control boundary. | Category | Skill | Purpose | | --------------- | ------------------------- | --------------------------------------------------------------------------------------------------- | -| Getting Started | `openshell-cli` | CLI usage, sandbox lifecycle, provider management, BYOC workflows | -| Getting Started | `debug-openshell-cluster` | Diagnose gateway deployment and health issues | -| Getting Started | `debug-inference` | Diagnose `inference.local`, host-backed local inference, and direct external inference setup issues | | Contributing | `create-spike` | Investigate a problem, produce a structured GitHub issue | | Contributing | `create-rfc` | Create RFC proposals from the repository template | | Contributing | `build-from-issue` | Plan and implement work from a GitHub issue (maintainer workflow) | | Contributing | `create-github-issue` | Create well-structured GitHub issues | | Contributing | `create-github-pr` | Create pull requests with proper conventions | | Reviewing | `review-github-pr` | Summarize PR diffs and key design decisions | -| Reviewing | `review-security-changes` | Review code changes for security vulnerabilities and boundary regressions | | Reviewing | `review-security-issue` | Assess security issues for severity and remediation | | Reviewing | `fix-security-issue` | Implement an approved security remediation plan | | Reviewing | `watch-github-actions` | Monitor CI pipeline status and logs | | Reviewing | `launch-openshell-gator` | Launch and supervise OpenShell gator agents for issue and PR monitoring | | Reviewing | `test-release-canary` | Dispatch and iterate on the Release Canary workflow that smoke-tests published artifacts | | Triage | `triage-issue` | Assess, classify, and route community-filed issues | -| Platform | `generate-sandbox-policy` | Generate YAML sandbox policies from requirements or API docs | | Platform | `helm-dev-environment` | Start and manage the local Kubernetes development environment | | Platform | `tui-development` | Development guide for the ratatui-based terminal UI | -| Documentation | `update-docs` | Scan recent commits and draft doc updates for user-facing changes | +| Platform | `build-openshell-mxc-windows` | Maintain and validate the build-only x64 and ARM64 Windows MSVC lane | +| Documentation | `update-docs-from-commits` | Scan recent commits and draft doc updates for user-facing changes | | Maintenance | `sync-agent-infra` | Detect and fix drift across agent-first infrastructure files | | Reference | `sbom` | Generate SBOMs and resolve dependency licenses | @@ -110,11 +132,11 @@ Each issue can require four independent decisions: | Decision | Question | Recorded by | |---|---|---| | Assessment | Is the report technically valid, and is there enough evidence to act on it? | `state:*` | -| Disposition | Should OpenShell pursue the work? | `state:accepted` or closure as not planned | +| Disposition | Should OpenShell pursue the work? | `state:accepted`, roadmap placement, or closure as not planned | | Sequencing | Where does accepted work sit relative to everything else? | Placement on the [OpenShell Roadmap](https://github.com/orgs/NVIDIA/projects/233) | | Ownership | Will a human implement the issue, will a user directly instruct an agent, or will a maintainer queue it for an unattended agent? | Direct instruction or optional `agent:*` workflow | -Completing one decision does not imply the others. `state:validated` confirms that the factual assessment is complete, but it does not mean the project has accepted the work. Roadmap placement communicates sequencing, but it does not authorize an agent to begin. +`state:validated` confirms that the factual assessment is complete, but it does not mean the project has accepted the work. A maintainer signals acceptance with `state:accepted` or roadmap placement. Roadmap placement also communicates sequencing, but it does not assign an owner or queue an unattended agent. #### Who Controls Each Decision @@ -125,7 +147,7 @@ Agents investigate issues, collect evidence, and report technical findings. Huma | Assess technical validity and impact | Triage agent or human triager | | Request missing evidence | Triage agent or human triager | | Mark the assessment complete with `state:validated` | Triage agent or human triager | -| Accept or decline the work | Maintainer | +| Accept or decline the work with `state:accepted`, roadmap placement, or closure | Maintainer | | Place the issue on the roadmap or move it | Maintainer | | Directly request an agent plan | User | | Queue an agent plan with `agent:plan-requested` | Maintainer | @@ -133,7 +155,7 @@ Agents investigate issues, collect evidence, and report technical findings. Huma | Directly request agent implementation | User | | Queue approved implementation with `agent:implementation-requested` | Maintainer | -Agents do not apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. +Agents do not apply `state:accepted`, place issues on the roadmap, or apply `agent:plan-requested` or `agent:implementation-requested`. A direct request may authorize work outside the recorded workflow, but it does not alter the issue's disposition or make the labels accurate. #### Issue State @@ -152,7 +174,7 @@ Keep one of these states on an open issue. When new evidence resolves a `state:n #### Assessing an Incoming Issue -Triage checks the report, its diagnostic evidence, related issues, current releases, and the relevant code paths. The assessment ends in one of these outcomes: +Triage checks the user story, reproduction or workflow, environment, related issues, current releases, and the relevant code paths. The assessment ends in one of these outcomes: | Outcome | State or resolution | |---|---| @@ -171,17 +193,17 @@ Triage establishes facts and impact. It does not decide whether the project shou When an issue reaches `state:validated`, a maintainer chooses one of three paths: -- **Accept:** replace `state:validated` with `state:accepted` and place it on the roadmap. +- **Accept:** apply `state:accepted`, place the issue on the roadmap, or do both. Either action signals that OpenShell should pursue the work; roadmap placement additionally records sequencing. - **Decline:** close it as not planned and record the rationale. - **Await more evidence:** replace `state:validated` with `state:needs-info` and leave it off the roadmap. -Do not use `state:accepted` as shorthand for technical validity, roadmap sequencing, or agent authorization. It records only the human decision that OpenShell should pursue the work. +Do not use `state:accepted` as shorthand for technical validity, roadmap sequencing, or agent authorization. It records the human decision that OpenShell should pursue the work. Roadmap placement records the same acceptance decision plus sequencing. #### Roadmap -OpenShell does not use priority labels. Sequencing comes from the [OpenShell Roadmap](https://github.com/orgs/NVIDIA/projects/233): a maintainer associates an accepted issue with a roadmap item, and the roadmap item's own timing carries the urgency. Issues tracked on the roadmap carry the `roadmap` label. +OpenShell does not use priority labels. Sequencing comes from the [OpenShell Roadmap](https://github.com/orgs/NVIDIA/projects/233): a maintainer associates an issue with a roadmap item, signaling acceptance and giving it timing. Issues tracked on the roadmap carry the `roadmap` label. -An accepted issue with no roadmap association is real work the project intends to do, but it is not scheduled. Ask a maintainer before starting on one. +An issue with `state:accepted` and no roadmap association is real work the project intends to do, but it is not scheduled. Ask a maintainer before starting on one. Roadmap placement does not assign an owner. A roadmap issue still needs a human contributor, a direct user instruction to an agent, or an unattended-agent queue label. @@ -191,7 +213,7 @@ Roadmap placement does not assign an owner. A roadmap issue still needs a human A human contributor may implement an accepted issue without any `agent:*` label. Before starting, check for an assignee, linked pull request, active branch, or comment that shows someone else is already working on it. -Maintainers use the `agent:*` workflow to queue work for always-on or unattended agents that scan issues. Keep exactly one agent-workflow label on the issue at a time. When a user directly asks an agent to plan or implement a specific issue, that instruction authorizes the requested phase and the corresponding request label is not required. +Maintainers use the `agent:*` workflow to queue work for always-on or unattended agents that scan issues. Keep exactly one agent-workflow label on the issue at a time. When a user directly asks an agent to plan or implement a specific issue, that instruction authorizes the requested phase even if the issue does not match the normal lifecycle or agent-workflow state. The agent warns about each missing or incomplete expected label and continues without changing the labels. | Agent workflow | Applied by | Meaning | |---|---|---| @@ -204,7 +226,7 @@ Maintainers use the `agent:*` workflow to queue work for always-on or unattended The normal delegated workflow is: ```text -state:accepted +(state:accepted OR roadmap placement) | +-- agent:plan-requested | @@ -241,18 +263,18 @@ Maintainers use the specialized security review and remediation workflow for an 3. A maintainer reviews the plan and applies `agent:implementation-requested`. 4. The remediation agent implements the approved plan. -A user may instead directly request review or remediation from the specialized skill. The direct request replaces the corresponding queue label, but a request for review still does not authorize remediation. General implementation agents do not process issues labeled `topic:security`. +A user may instead directly request review or remediation from the specialized skill. If the corresponding queue label is missing, the agent warns and continues without changing it, but a request for review still does not authorize remediation. General implementation agents do not process issues labeled `topic:security`. #### When an Issue Is Ready for Work | You are | Ready when | |---|---| -| A human contributor | The issue has `state:accepted`, invites contribution or has maintainer confirmation, and has no conflicting owner or implementation. | -| An unattended agent scanning for planning work | The issue has `state:accepted` and the human-applied `agent:plan-requested` label. | -| An unattended agent scanning for implementation work | The issue has `state:accepted`, an approved plan, and the human-applied `agent:implementation-requested` label. | -| An agent directly instructed by a user | The issue has `state:accepted`, no conflicting owner or implementation, and the instruction explicitly requests the phase the agent will perform. | +| A human contributor | The issue has `state:accepted`, roadmap placement, an invitation to contribute, or maintainer confirmation, and has no conflicting owner or implementation. | +| An unattended agent scanning for planning work | The issue has `state:accepted` or roadmap placement, plus the human-applied `agent:plan-requested` label. | +| An unattended agent scanning for implementation work | The issue has `state:accepted` or roadmap placement, plus an approved plan and the human-applied `agent:implementation-requested` label. | +| An agent directly instructed by a user | The instruction explicitly requests the phase the agent will perform and the issue has no conflicting owner or implementation. Missing or incomplete workflow labels produce a warning, not a stop. | -Issues with `state:triage-needed`, `state:needs-info`, or `state:validated` are not ready for implementation. Roadmap placement alone never makes an issue ready. +For unattended agents, `state:needs-info` blocks work until the requested evidence arrives, and `state:triage-needed` or `state:validated` blocks work unless a maintainer has separately placed the issue on the roadmap or applied `state:accepted`. For a directly instructed agent, these labels require a warning but do not themselves block the requested work. If information actually needed to do the work is unavailable, the agent reports that concrete blocker rather than treating the label as the blocker. #### Stale Issues @@ -287,30 +309,16 @@ Project requirements: - Rust 1.90+ - Python 3.11+ - Docker (running) -- Z3 solver library (for the policy prover crate) - -### macOS build tools - -Install Apple Command Line Tools before building locally: - -```bash -xcode-select --install -``` - -If Cargo fails while building `protobuf-src` with an error such as -`fatal error: 'utility' file not found`, `fatal error: 'cstdlib' file not -found`, or `A compiler with support for C++11 language features is required`, -your Command Line Tools install may not expose the libc++ headers on the -compiler's default include path. Reinstall Command Line Tools to correct the error: - -```bash -sudo rm -rf /Library/Developer/CommandLineTools -xcode-select --install -``` +- CMake 3.16+ (only required when building with the `bundled-z3` feature) ### Z3 installation -The `openshell-prover` crate links against the system Z3 library via pkg-config. +The `openshell-prover` crate links directly against Z3. The `openshell-server` +crate depends on the prover, and the `openshell-gateway` binary crate depends +on `openshell-server` in turn; both forward a `bundled-z3` feature down to +`openshell-prover/bundled-z3`. The `openshell-cli` crate does not depend on +Z3. On macOS and Linux, install the system Z3 development package; `z3-sys` +discovers it through `pkg-config`. ```bash # macOS @@ -323,12 +331,59 @@ sudo apt install libz3-dev sudo dnf install z3-devel ``` -If you prefer not to install Z3 system-wide, you can compile it from source as a one-time step: +If you prefer not to install Z3 system-wide, use the bundled Z3 feature. This +compiles Z3 from source during the Rust build and requires CMake 3.16+: ```bash cargo build -p openshell-prover --features bundled-z3 ``` +For x86-64 Windows MSVC builds, use one of these Z3 paths: + +- System Z3: point `Z3_LIBRARY_PATH_OVERRIDE` at the directory containing the + 64-bit MSVC Z3 library and `Z3_SYS_Z3_HEADER` at the full path to `z3.h`. + The `windows:*` tasks use this path automatically when `Z3_LIBRARY_PATH_OVERRIDE` + is set. +- Bundled Z3: pass `--features bundled-z3` so `z3-sys` builds Z3 from source. + +`openshell-prover` itself has no `bindgen`/`libclang` dependency, so building +just this crate does not require `LIBCLANG_PATH`: + +```powershell +cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundled-z3 +``` + +### Windows full build + +To build the full set of Windows binaries, including `openshell-gateway.exe` +and `openshell.exe`, use the `windows:build:x64` mise task instead of a +single-crate `cargo build`. It builds Z3 from source (bundled) by default. A +full build also compiles crates that use `bindgen` (e.g. the MXC driver on +Windows), so it requires `libclang.dll`; if LLVM is not on the default search +path, set `LIBCLANG_PATH` to the directory containing `libclang.dll`: + +```powershell +$env:LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\\VC\Tools\Llvm\x64\bin' +mise run --skip-tools windows:build:x64 +``` + +To use a local x64 Z3 release instead of the bundled build, set +`Z3_LIBRARY_PATH_OVERRIDE` and `Z3_SYS_Z3_HEADER` before running the task: + +```powershell +$env:Z3_LIBRARY_PATH_OVERRIDE='C:\path\to\z3-4.16.0-x64-win\bin' +$env:Z3_SYS_Z3_HEADER='C:\path\to\z3-4.16.0-x64-win\include\z3.h' +mise run --skip-tools windows:build:x64 +``` + +### macOS build tools + +Install Apple Command Line Tools before building locally: + +```bash +xcode-select --install +``` + ## Getting Started ```bash @@ -396,6 +451,8 @@ These are the primary `mise` tasks for day-to-day development: | --------------- | --------------------------------------------- | | `crates/` | Rust crates | | `python/` | Python SDK and bindings | +| `sdk/go/` | Go SDK (types, gRPC clients, converters) | +| `sdk/typescript/` | TypeScript SDK (Connect client and generated protobuf bindings) | | `proto/` | Protocol buffer definitions | | `tasks/` | `mise` task definitions and build scripts | | `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests | @@ -403,7 +460,8 @@ These are the primary `mise` tasks for day-to-day development: | `fern/` | Fern site config, components, and theme assets | | `architecture/` | Architecture docs and plans | | `rfc/` | Request for Comments proposals | -| `.agents/` | Agent skills and persona definitions | +| `skills/` | Public skills for using and operating OpenShell | +| `.agents/` | Contributor skills and persona definitions | ## RFCs @@ -413,7 +471,7 @@ New features always start as GitHub issues using the feature request template. F If your change affects user-facing behavior (new flags, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR and adjust `docs/index.yml` if navigation changes. For explicit navigation entries, keep `page:` aligned with `sidebar-title` when present and put relative `slug:` values in `docs/index.yml`. Reserve frontmatter `slug` for folder-discovered pages or absolute URL overrides. -To ensure your doc changes follow NVIDIA documentation style, use the `update-docs` skill. +To ensure your doc changes follow NVIDIA documentation style, use the `update-docs-from-commits` skill. It scans commits, identifies doc pages that need updates, and drafts content that follows the style guide in `docs/CONTRIBUTING.mdx`. To preview Fern docs locally: @@ -430,7 +488,7 @@ mise run docs PRs that touch `docs/**` or `fern/**` are validated by `.github/workflows/branch-docs.yml`, and they get a preview when `FERN_TOKEN` is available to the workflow. -Fern docs publishing is handled by the `publish-fern-docs` job in `.github/workflows/release-tag.yml` when a release tag is created. +Fern docs publishing is handled by the `publish-fern-docs` job in `.github/workflows/release-tag.yml` when a stable release tag is created. `docs/` is the source-of-truth docs tree. `fern/` contains the site config, components, and theme assets that publish those pages. diff --git a/Cargo.lock b/Cargo.lock index c03be7c032..edc940ea68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,18 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout 0.2.2", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "inout", ] [[package]] @@ -44,7 +33,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cipher 0.5.2", + "cipher", "cpubits", "cpufeatures 0.3.0", "zeroize", @@ -57,8 +46,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" dependencies = [ "aead", - "aes 0.9.2", - "cipher 0.5.2", + "aes", + "cipher", "ctr", "ghash", "subtle", @@ -271,15 +260,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "autotools" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" -dependencies = [ - "cc", -] - [[package]] name = "aws-config" version = "1.8.15" @@ -319,9 +299,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -330,14 +310,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -453,23 +434,17 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", + "h2", "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.9.0", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower 0.5.3", "tracing", ] @@ -634,7 +609,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "itoa", "matchit", @@ -710,6 +685,12 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -751,28 +732,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ "blowfish", - "pbkdf2 0.13.0", + "pbkdf2", "sha2 0.11.0", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.11.1", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.2", - "shlex", - "syn 2.0.117", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -832,7 +795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" dependencies = [ "byteorder", - "cipher 0.5.2", + "cipher", ] [[package]] @@ -849,7 +812,7 @@ dependencies = [ "hex", "http 1.4.0", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -917,15 +880,6 @@ dependencies = [ "either", ] -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - [[package]] name = "capctl" version = "0.2.4" @@ -958,7 +912,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -979,15 +933,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -1007,7 +952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cipher 0.5.2", + "cipher", "cpufeatures 0.3.0", "rand_core 0.10.1", "zeroize", @@ -1027,16 +972,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - [[package]] name = "cipher" version = "0.5.2" @@ -1045,21 +980,10 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.0", "crypto-common 0.2.2", - "inout 0.2.2", + "inout", "zeroize", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.6.1" @@ -1211,12 +1135,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - [[package]] name = "core-foundation" version = "0.10.1" @@ -1287,6 +1205,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1361,6 +1285,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -1405,7 +1341,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "rand_core 0.10.1", ] @@ -1415,7 +1351,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -1430,15 +1366,32 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "5.0.0-rc.0" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", "cpufeatures 0.3.0", "curve25519-dalek-derive", "digest 0.11.2", - "fiat-crypto", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", "subtle", "zeroize", @@ -1514,12 +1467,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - [[package]] name = "delegate" version = "0.13.5" @@ -1624,7 +1571,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -1695,19 +1642,43 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" -version = "0.17.0-rc.18" +version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ "der 0.8.0", "digest 0.11.2", - "elliptic-curve", - "rfc6979", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", "signature 3.0.0", "spki 0.8.0", "zeroize", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -1720,12 +1691,26 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-rc.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", "rand_core 0.10.1", "serde", "sha2 0.11.0", @@ -1745,23 +1730,43 @@ dependencies = [ [[package]] name = "elliptic-curve" -version = "0.14.0-rc.33" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", + "hkdf 0.12.4", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 1.0.0", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", "digest 0.11.2", - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hkdf 0.13.0", "hybrid-array", - "once_cell", "pem-rfc7468 1.0.0", "pkcs8 0.11.0", "rand_core 0.10.1", - "sec1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -1817,7 +1822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1848,6 +1853,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.14.0" @@ -1858,6 +1873,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -1895,7 +1916,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -2058,6 +2078,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2091,11 +2112,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -2161,39 +2180,31 @@ dependencies = [ [[package]] name = "group" -version = "0.14.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", - "rand_core 0.10.1", + "ff 0.13.1", + "rand_core 0.6.4", "subtle", ] [[package]] -name = "h2" -version = "0.3.27" +name = "group" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", ] [[package]] name = "h2" -version = "0.4.13" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2277,6 +2288,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2410,30 +2440,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.9.0" @@ -2444,7 +2450,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -2463,7 +2469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.9.0", + "hyper", "hyper-util", "pin-project-lite", "tokio", @@ -2471,21 +2477,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -2493,15 +2484,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", - "hyper 1.9.0", + "hyper", "hyper-util", "log", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", ] [[package]] @@ -2510,7 +2500,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper", "hyper-util", "pin-project-lite", "tokio", @@ -2529,12 +2519,12 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper 1.9.0", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2", "tokio", "tower-service", "tracing", @@ -2548,7 +2538,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "pin-project-lite", "tokio", @@ -2758,15 +2748,6 @@ dependencies = [ "libc", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array 0.14.7", -] - [[package]] name = "inout" version = "0.2.2" @@ -2884,6 +2865,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2960,33 +2971,28 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "jsonwebtoken" version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ + "aws-lc-rs", "base64 0.22.1", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", + "hmac 0.12.1", "js-sys", + "p256 0.13.2", + "p384 0.13.1", + "pem", + "rand 0.8.6", + "rsa 0.9.10", "serde", "serde_json", + "sha2 0.10.9", "signature 2.2.0", + "simple_asn1", ] [[package]] @@ -3085,15 +3091,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-timeout", "hyper-util", "jsonpath-rust", "k8s-openapi", "kube-core", "pem", - "rustls 0.23.38", + "rustls", "rustls-pemfile", "secrecy", "serde", @@ -3189,12 +3195,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "libbz2-rs-sys" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" - [[package]] name = "libc" version = "0.2.189" @@ -3298,15 +3298,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rust2" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" -dependencies = [ - "sha2 0.10.9", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3362,7 +3353,7 @@ checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda" dependencies = [ "base64 0.22.1", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "indexmap", "ipnet", @@ -3477,7 +3468,7 @@ dependencies = [ "module-lattice", "pkcs8 0.11.0", "rand_core 0.10.1", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -3576,20 +3567,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -3616,15 +3593,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.1" @@ -3651,17 +3619,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3694,7 +3651,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -3728,7 +3685,7 @@ dependencies = [ "futures-util", "http 1.4.0", "http-auth", - "jsonwebtoken 10.3.0", + "jsonwebtoken", "lazy_static", "oci-spec", "olpc-cjson", @@ -3785,6 +3742,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -3821,12 +3782,13 @@ dependencies = [ "chrono", "clap", "clap_complete", + "console", "crossterm 0.28.1", "dialoguer", "futures", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "indicatif", "miette", @@ -3842,7 +3804,7 @@ dependencies = [ "prost-types", "rcgen", "reqwest 0.12.28", - "rustls 0.23.38", + "rustls", "rustls-pemfile", "serde", "serde_json", @@ -3852,7 +3814,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", @@ -3862,25 +3824,56 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-conformance" +version = "0.0.0" +dependencies = [ + "rand 0.9.4", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", +] + +[[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" dependencies = [ + "async-trait", "base64 0.22.1", "chrono", "glob", "ipnet", "miette", "nix 0.29.0", + "openshell-extension-core", "prost", "prost-types", - "protobuf-src", + "protoc-bin-vendored", + "rcgen", "reqwest 0.12.28", + "rustix 1.1.4", + "rustls", + "rustls-pemfile", "serde", "serde_json", + "tar", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", @@ -3888,14 +3881,39 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-driver-db-credstore" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "openshell-core", + "ring", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "toml", + "tonic", +] + [[package]] name = "openshell-driver-docker" version = "0.0.0" dependencies = [ "bollard", "bytes", + "clap", "futures", + "http 1.4.0", + "miette", "openshell-core", + "openshell-otel", + "openshell-otel-test-support", + "opentelemetry", + "opentelemetry_sdk", "prost-types", "serde", "serde_json", @@ -3904,8 +3922,12 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "toml", "tonic", + "tower-http 0.6.8", "tracing", + "tracing-opentelemetry", + "tracing-subscriber", "url", ] @@ -3913,14 +3935,22 @@ dependencies = [ name = "openshell-driver-kubernetes" version = "0.0.0" dependencies = [ + "bytes", "clap", "futures", + "http 1.4.0", + "http-body-util", "k8s-openapi", "kube", "kube-runtime", "miette", + "notify", "openshell-core", + "openshell-otel", + "openshell-otel-test-support", "openshell-policy", + "opentelemetry", + "opentelemetry_sdk", "prost", "prost-types", "serde", @@ -3929,23 +3959,69 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "toml", + "tonic", + "tower 0.5.3", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "openshell-driver-kubernetes-secrets" +version = "0.0.0" +dependencies = [ + "clap", + "futures", + "k8s-openapi", + "kube", + "miette", + "openshell-core", + "serde", + "sha2 0.10.9", + "tokio", + "toml", "tonic", "tracing", "tracing-subscriber", ] +[[package]] +name = "openshell-driver-mxc" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "futures", + "openshell-core", + "openshell-policy", + "serde", + "serde_json", + "serde_yml", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "uuid", +] + [[package]] name = "openshell-driver-podman" version = "0.0.0" dependencies = [ "clap", "futures", + "http 1.4.0", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "miette", "nix 0.29.0", "openshell-core", + "openshell-otel", + "openshell-otel-test-support", + "opentelemetry", + "opentelemetry_sdk", "prost-types", "rustix 1.1.4", "serde", @@ -3955,11 +4031,34 @@ dependencies = [ "tokio", "tokio-stream", "tonic", + "tower-http 0.6.8", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "url", ] +[[package]] +name = "openshell-driver-vault" +version = "0.0.0" +dependencies = [ + "clap", + "futures", + "miette", + "openshell-core", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "toml", + "tonic", + "tracing", + "tracing-subscriber", + "wiremock", +] + [[package]] name = "openshell-driver-vm" version = "0.0.0" @@ -3975,11 +4074,12 @@ dependencies = [ "nix 0.29.0", "oci-client", "openshell-core", + "openshell-driver-podman", "openshell-otel", + "openshell-otel-test-support", "openshell-policy", "openshell-vfio", "opentelemetry", - "opentelemetry-proto", "opentelemetry_sdk", "polling", "prost", @@ -4003,13 +4103,54 @@ dependencies = [ ] [[package]] -name = "openshell-gateway-interceptors" +name = "openshell-extension-core" version = "0.0.0" dependencies = [ + "http 1.4.0", "hyper-util", + "rcgen", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tower 0.5.3", +] + +[[package]] +name = "openshell-gateway" +version = "0.0.0" +dependencies = [ + "async-trait", + "hyper-util", + "miette", + "nix 0.29.0", + "openshell-core", + "openshell-driver-docker", + "openshell-driver-kubernetes", + "openshell-driver-mxc", + "openshell-driver-podman", + "openshell-otel", + "openshell-server", + "rustix 1.1.4", + "serde", + "tempfile", + "tokio", + "tonic", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "openshell-gateway-interceptors" +version = "0.0.0" +dependencies = [ "json-patch", "metrics", "openshell-core", + "openshell-extension-core", "prost", "prost-reflect", "prost-types", @@ -4018,7 +4159,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tonic", - "tower 0.5.3", "tracing", "tracing-subscriber", ] @@ -4039,6 +4179,7 @@ dependencies = [ name = "openshell-otel" version = "0.0.0" dependencies = [ + "futures", "http 1.4.0", "opentelemetry", "opentelemetry-otlp", @@ -4046,9 +4187,22 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tonic", - "tower-http 0.6.8", + "tower-http 0.6.8", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + +[[package]] +name = "openshell-otel-test-support" +version = "0.0.0" +dependencies = [ + "openshell-otel", + "opentelemetry-proto", + "tokio", + "tokio-stream", + "tonic", "tracing", - "tracing-opentelemetry", "tracing-subscriber", ] @@ -4056,6 +4210,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "hickory-proto", "miette", "openshell-core", "prost-types", @@ -4118,6 +4273,7 @@ dependencies = [ "miette", "nix 0.29.0", "openshell-core", + "openshell-extension-core", "openshell-ocsf", "openshell-policy", "openshell-supervisor-middleware", @@ -4126,7 +4282,7 @@ dependencies = [ "openshell-supervisor-process", "prost", "prost-types", - "rustls 0.23.38", + "rustls", "serde", "serde_json", "temp-env", @@ -4137,6 +4293,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] @@ -4145,18 +4302,18 @@ version = "0.0.0" dependencies = [ "async-trait", "futures", - "hyper 1.9.0", + "hyper", "hyper-util", "miette", "oauth2", "openshell-core", "reqwest 0.12.28", - "rustls 0.23.38", + "rustls", "serde", "serde_json", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", @@ -4175,6 +4332,7 @@ dependencies = [ "aws-config", "aws-sdk-sts", "axum", + "base64 0.22.1", "bytes", "clap", "futures", @@ -4185,11 +4343,11 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "ipnet", - "jsonwebtoken 9.3.1", + "jsonwebtoken", "k8s-openapi", "kube", "metrics", @@ -4199,9 +4357,10 @@ dependencies = [ "notify", "openshell-bootstrap", "openshell-core", - "openshell-driver-docker", - "openshell-driver-kubernetes", - "openshell-driver-podman", + "openshell-driver-db-credstore", + "openshell-driver-kubernetes-secrets", + "openshell-driver-vault", + "openshell-extension-core", "openshell-gateway-interceptors", "openshell-ocsf", "openshell-otel", @@ -4221,19 +4380,22 @@ dependencies = [ "rand 0.9.4", "rcgen", "reqwest 0.12.28", + "ring", + "rsa 0.9.10", "russh", "rustix 1.1.4", - "rustls 0.23.38", + "rustls", "rustls-pemfile", "serde", "serde_json", "sha2 0.10.9", - "socket2 0.6.3", + "socket2", + "spiffe", "sqlx", "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite 0.26.2", "toml", @@ -4262,8 +4424,10 @@ dependencies = [ name = "openshell-supervisor-middleware" version = "0.0.0" dependencies = [ + "futures", "miette", "openshell-core", + "openshell-extension-core", "openshell-supervisor-middleware-builtins", "prost", "prost-types", @@ -4276,6 +4440,7 @@ dependencies = [ name = "openshell-supervisor-middleware-builtins" version = "0.0.0" dependencies = [ + "async-trait", "miette", "openshell-core", "prost-types", @@ -4283,6 +4448,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-stream", "tonic", ] @@ -4300,6 +4466,7 @@ dependencies = [ "futures", "glob", "hex", + "hickory-proto", "http 1.4.0", "ipnet", "libc", @@ -4314,7 +4481,8 @@ dependencies = [ "rcgen", "regorus", "reqwest 0.12.28", - "rustls 0.23.38", + "rustls", + "rustls-native-certs", "rustls-pemfile", "serde", "serde_json", @@ -4326,14 +4494,15 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", + "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", "tower-mcp-types", "tracing", "tracing-subscriber", "uuid", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -4342,8 +4511,10 @@ version = "0.0.0" dependencies = [ "anyhow", "base64 0.22.1", + "bytes", "capctl", "hex", + "ipnet", "landlock", "libc", "miette", @@ -4357,6 +4528,7 @@ dependencies = [ "seccompiler", "serde_json", "sha2 0.10.9", + "socket2", "tempfile", "tokio", "tokio-stream", @@ -4503,42 +4675,66 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p256" -version = "0.14.0-rc.10" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p256" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] [[package]] name = "p384" -version = "0.14.0-rc.10" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" dependencies = [ - "ecdsa", - "elliptic-curve", - "fiat-crypto", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "fiat-crypto 0.3.0", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] [[package]] name = "p521" -version = "0.14.0-rc.10" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" dependencies = [ - "base16ct", - "ecdsa", - "elliptic-curve", + "base16ct 1.0.0", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -4605,16 +4801,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - [[package]] name = "pbkdf2" version = "0.13.0" @@ -4796,11 +4982,11 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ - "aes 0.9.2", + "aes", "aes-gcm", "cbc", "der 0.8.0", - "pbkdf2 0.13.0", + "pbkdf2", "rand_core 0.10.1", "scrypt", "sha2 0.11.0", @@ -4898,12 +5084,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppmd-rust" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4929,9 +5109,9 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", "zeroize", @@ -4939,11 +5119,24 @@ dependencies = [ [[package]] name = "primeorder" -version = "0.14.0-rc.10" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -5044,14 +5237,69 @@ dependencies = [ ] [[package]] -name = "protobuf-src" -version = "1.1.0+21.5" +name = "protoc-bin-vendored" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ac8852baeb3cc6fb83b93646fb93c0ffe5d14bf138c945ceb4b9948ee0e3c1" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" dependencies = [ - "autotools", + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", ] +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "pulldown-cmark" version = "0.13.3" @@ -5099,8 +5347,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", - "rustls 0.23.38", - "socket2 0.5.10", + "rustls", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -5109,18 +5357,19 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash 2.1.2", - "rustls 0.23.38", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -5138,7 +5387,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -5240,6 +5489,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -5377,15 +5635,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-pki-types", "serde", @@ -5393,7 +5651,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower 0.5.3", "tower-http 0.6.8", "tower-service", @@ -5401,7 +5659,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -5417,15 +5674,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.38", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -5433,7 +5690,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower 0.5.3", "tower-http 0.6.8", @@ -5447,14 +5704,24 @@ dependencies = [ [[package]] name = "rfc6979" -version = "0.5.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.13.0", + "hmac 0.12.1", "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -5508,7 +5775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ "const-oid 0.10.2", - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-primes", "digest 0.11.2", "pkcs1 0.8.0-rc.4", @@ -5522,28 +5789,28 @@ dependencies = [ [[package]] name = "russh" -version = "0.61.2" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0" +checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" dependencies = [ - "aes 0.9.2", + "aes", "aws-lc-rs", "bitflags 2.11.1", "block-padding", "byteorder", "bytes", "cbc", - "cipher 0.5.2", - "crypto-bigint", + "cipher", + "crypto-bigint 0.7.5", "ctr", - "curve25519-dalek", + "curve25519-dalek 5.0.0", "data-encoding", "delegate", "der 0.8.0", "digest 0.11.2", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", + "ecdsa 0.17.0", + "ed25519-dalek 3.0.0", + "elliptic-curve 0.14.1", "enum_dispatch", "flate2", "futures", @@ -5552,7 +5819,7 @@ dependencies = [ "ghash", "hex-literal", "hmac 0.13.0", - "inout 0.2.2", + "inout", "internal-russh-num-bigint", "keccak", "log", @@ -5560,11 +5827,11 @@ dependencies = [ "ml-kem", "module-lattice", "num-bigint", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "p521", "pageant", - "pbkdf2 0.13.0", + "pbkdf2", "pkcs1 0.8.0-rc.4", "pkcs5", "pkcs8 0.11.0", @@ -5576,10 +5843,10 @@ dependencies = [ "russh-util", "salsa20", "scrypt", - "sec1", + "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", - "sha3", + "sha3 0.12.0", "signature 3.0.0", "spki 0.8.0", "ssh-encoding", @@ -5594,9 +5861,9 @@ dependencies = [ [[package]] name = "russh-cryptovec" -version = "0.61.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443f6bbcfacb34a1aab2b12b99bf08e0c63abdc5a0db261901365df9d57fff51" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" dependencies = [ "log", "nix 0.31.3", @@ -5675,19 +5942,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "windows-sys 0.59.0", ] [[package]] @@ -5701,7 +5956,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] @@ -5745,17 +6000,17 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", + "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5764,16 +6019,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted 0.9.0", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -5805,7 +6050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" dependencies = [ "cfg-if", - "cipher 0.5.2", + "cipher", ] [[package]] @@ -5863,19 +6108,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ "cfg-if", - "pbkdf2 0.13.0", + "pbkdf2", "salsa20", "sha2 0.11.0", ] [[package]] -name = "sct" -version = "0.7.1" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "ring", - "untrusted 0.9.0", + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8 0.10.2", + "subtle", + "zeroize", ] [[package]] @@ -5884,7 +6133,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct", + "base16ct 1.0.0", "ctutils", "der 0.8.0", "hybrid-array", @@ -6081,7 +6330,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -6139,6 +6388,17 @@ dependencies = [ "keccak", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.2", + "keccak", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -6224,6 +6484,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -6257,16 +6533,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -6288,6 +6554,7 @@ dependencies = [ "fastrand", "futures", "hyper-util", + "jsonwebtoken", "log", "prost", "prost-types", @@ -6334,6 +6601,12 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sqlx" version = "0.8.6" @@ -6370,7 +6643,8 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.38", + "rustls", + "rustls-native-certs", "serde", "serde_json", "sha2 0.10.9", @@ -6380,7 +6654,6 @@ dependencies = [ "tokio-stream", "tracing", "url", - "webpki-roots 0.26.11", ] [[package]] @@ -6413,7 +6686,6 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx-core", - "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn 2.0.117", @@ -6452,7 +6724,6 @@ dependencies = [ "percent-encoding", "rand 0.8.6", "rsa 0.9.10", - "serde", "sha1 0.10.6", "sha2 0.10.9", "smallvec", @@ -6526,17 +6797,15 @@ dependencies = [ [[package]] name = "ssh-cipher" -version = "0.3.0-rc.9" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" dependencies = [ "aead", - "aes 0.9.2", + "aes", "aes-gcm", - "cbc", "chacha20", - "cipher 0.5.2", - "ctr", + "cipher", "ctutils", "des", "poly1305", @@ -6546,13 +6815,13 @@ dependencies = [ [[package]] name = "ssh-encoding" -version = "0.3.0-rc.9" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" dependencies = [ "base64ct", "bytes", - "crypto-bigint", + "crypto-bigint 0.7.5", "ctutils", "digest 0.11.2", "pem-rfc7468 1.0.0", @@ -6561,22 +6830,22 @@ dependencies = [ [[package]] name = "ssh-key" -version = "0.7.0-rc.10" +version = "0.7.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" dependencies = [ "argon2", "bcrypt-pbkdf", "ctutils", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "hmac 0.13.0", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "p521", "rand_core 0.10.1", "rsa 0.10.0-rc.18", - "sec1", + "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", "signature 3.0.0", @@ -6741,9 +7010,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -6769,7 +7038,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6881,7 +7150,6 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", - "js-sys", "num-conv", "powerfmt", "serde_core", @@ -6942,7 +7210,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -6958,23 +7226,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.38", + "rustls", "tokio", ] @@ -6997,11 +7255,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls 0.23.38", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite 0.26.2", ] @@ -7082,20 +7340,20 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", "rustls-native-certs", - "socket2 0.6.3", + "socket2", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tower 0.5.3", "tower-layer", @@ -7373,7 +7631,7 @@ dependencies = [ "httparse", "log", "rand 0.9.4", - "rustls 0.23.38", + "rustls", "rustls-pki-types", "sha1 0.10.6", "thiserror 2.0.18", @@ -7396,12 +7654,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "typed-path" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" - [[package]] name = "typenum" version = "1.20.1" @@ -7768,15 +8020,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - [[package]] name = "webpki-roots" version = "1.0.7" @@ -8255,7 +8498,7 @@ dependencies = [ "futures", "http 1.4.0", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-util", "log", "once_cell", @@ -8360,6 +8603,17 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.3" @@ -8439,27 +8693,31 @@ dependencies = [ [[package]] name = "z3" -version = "0.19.15" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "107cca65ed27d28b11f7c492298a51383333fd48ba6ebe49a432aba96162f678" +checksum = "80c4de445f5c9e3013703a6b8a40c80b4a64c925f0a19e7d0a23a7a9b70e854d" dependencies = [ "log", - "num", "z3-sys", ] [[package]] -name = "z3-sys" -version = "0.10.9" +name = "z3-src" +version = "416.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c82b97329d02d87da6802ed9fda083f1b255d822ab13d5b1fb961196b58a69a1" +checksum = "f2af0c6527de39877cf55cb87f233016573eeeb7cf77afdc1469e4b32faef832" dependencies = [ - "bindgen", "cmake", +] + +[[package]] +name = "z3-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c18b0a91a13522d21b3414847667de2b2056a721a3edcb5b6ee6858352d58db4" +dependencies = [ "pkg-config", - "reqwest 0.12.28", - "serde_json", - "zip", + "z3-src", ] [[package]] @@ -8556,57 +8814,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "8.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" -dependencies = [ - "aes 0.8.4", - "bzip2", - "constant_time_eq", - "crc32fast", - "deflate64", - "flate2", - "getrandom 0.4.2", - "hmac 0.12.1", - "indexmap", - "lzma-rust2", - "memchr", - "pbkdf2 0.12.2", - "ppmd-rust", - "sha1 0.10.6", - "time", - "typed-path", - "zeroize", - "zopfli", - "zstd", -] - -[[package]] -name = "zlib-rs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" - [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index e3043dc322..6936439a3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ rustls = { version = "0.23", default-features = false, features = ["std", "loggi rustls-pemfile = "2" rcgen = { version = "0.13", features = ["crypto", "pem"] } webpki-roots = "1" +rustls-native-certs = "0.8" # CLI clap = { version = "4.5", features = ["derive", "env"] } @@ -89,11 +90,11 @@ regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } # AWS SDK -aws-config = { version = "1", default-features = false, features = ["rustls", "rt-tokio", "behavior-version-latest"] } -aws-sdk-sts = { version = "1", default-features = false, features = ["rustls", "rt-tokio", "behavior-version-latest"] } +aws-config = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } # WebSocket -tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } +tokio-tungstenite = { version = "0.26", default-features = false, features = ["connect", "rustls-tls-native-roots"] } # Clipboard (OSC 52) base64 = "0.22" @@ -101,9 +102,10 @@ base64 = "0.22" # Crypto / Auth sha2 = "0.10" rand = "0.9" -jsonwebtoken = "9" +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } getrandom = "0.3" -spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "tracing"] } +ring = "0.17" +spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "jwt-verify-rust-crypto", "tracing"] } # Filesystem embedding include_dir = "0.7" @@ -114,17 +116,18 @@ glob = "0.3" # Utilities futures = "0.3" bytes = "1" +hickory-proto = "0.26.1" pin-project-lite = "0.2" tokio-stream = "0.1" -protobuf-src = "1.1.0" +protoc-bin-vendored = "3.2.0" url = "2" indexmap = "2" # Database -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "sqlite", "migrate"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "sqlite", "migrate", "macros"] } # Kubernetes -kube = { version = "0.90", features = ["runtime", "derive"] } +kube = { version = "0.90", default-features = false, features = ["client", "runtime", "derive", "rustls-tls"] } kube-runtime = "0.90" k8s-openapi = { version = "0.21.1", features = ["v1_26"] } @@ -132,7 +135,7 @@ k8s-openapi = { version = "0.21.1", features = ["v1_26"] } uuid = { version = "1.10", features = ["v4"] } # SMT solver (uses system libz3; enable z3/bundled via the prover's bundled-z3 feature for local dev without system z3) -z3 = "0.19" +z3 = "0.20" [workspace.lints.rust] unsafe_code = "warn" diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000000..3ecde4ded4 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,19 @@ +# Maintainers + +The current maintainers of OpenShell are listed below. + +| Name | GitHub ID | Company/Organization | +| --- | --- | --- | +| Derek Carr | [@derekwaynecarr](https://github.com/derekwaynecarr) | Red Hat | +| Matthew Grossman | [@matthewgrossman](https://github.com/matthewgrossman) | NVIDIA | +| Kris Hicks | [@krishicks](https://github.com/krishicks) | NVIDIA | +| Seth Jennings | [@sjenning](https://github.com/sjenning) | Red Hat | +| Adrien Langou | [@alangou](https://github.com/alangou) | NVIDIA | +| Evan Lezar | [@elezar](https://github.com/elezar) | NVIDIA | +| Jim Meyer | [@purp](https://github.com/purp) | NVIDIA | +| Piotr Mlocek | [@pimlock](https://github.com/pimlock) | NVIDIA | +| Taylor Mutch | [@TaylorMutch](https://github.com/TaylorMutch) | NVIDIA | +| John T. Myers | [@johntmyers](https://github.com/johntmyers) | NVIDIA | +| Drew Newberry | [@drew](https://github.com/drew) | NVIDIA | +| Mrunal Patel | [@mrunalp](https://github.com/mrunalp) | Red Hat | +| Simon Scatton | [@SDAChess](https://github.com/SDAChess) | NVIDIA | diff --git a/MODULE.bazel b/MODULE.bazel deleted file mode 100644 index 5946d072f7..0000000000 --- a/MODULE.bazel +++ /dev/null @@ -1,75 +0,0 @@ -bazel_dep(name = "rules_rs", version = "0.0.96") -bazel_dep(name = "bazel_lib", version = "3.2.2") -bazel_dep(name = "llvm", version = "0.8.11") -bazel_dep(name = "platforms", version = "1.1.0") -bazel_dep(name = "rules_cc", version = "0.2.20") -bazel_dep(name = "rules_proto", version = "7.1.0") -bazel_dep(name = "protobuf", version = "34.0.bcr.1") - -include("//bazel/annotations:aws-lc-sys.MODULE.bazel") -include("//bazel/annotations:z3-sys.MODULE.bazel") -include("//bazel/annotations:zstd-sys.MODULE.bazel") - -z3_repository = use_repo_rule("//third_party/z3:repositories.bzl", "z3_repository") - -z3_repository( - name = "z3", - build_file = "//third_party/z3:BUILD.z3.bazel", - integrity = "sha256-YGAaZ0II/2EDgM8NVhIayGeMRcXE6sOtFNv+kZmOzIo=", - strip_prefix = "z3-z3-4.15.2", - urls = ["https://github.com/Z3Prover/z3/archive/refs/tags/z3-4.15.2.zip"], -) - -osx = use_extension("@llvm//extensions:osx.bzl", "osx") -osx.frameworks( - names = [ - "CFNetwork", - "CoreFoundation", - "CoreServices", - "DiskArbitration", - "Foundation", - "IOKit", - "Kernel", - "OSLog", - "Security", - "SystemConfiguration", - ], -) - -workspace_version_repository = use_repo_rule("//bazel:cargo_version.bzl", "workspace_version_repository") - -workspace_version_repository( - name = "workspace_version", - manifest = "//:Cargo.toml", -) - -toolchains = use_extension("@rules_rs//rs/toolchains:module_extension.bzl", "toolchains") -toolchains.toolchain( - edition = "2024", - version = "1.95.0", -) -use_repo(toolchains, "default_rust_toolchains") - -rules_rust = use_extension("@rules_rs//rs:rules_rust.bzl", "rules_rust") -use_repo(rules_rust, "rules_rust") - -register_toolchains( - "@default_rust_toolchains//:all", - "@llvm//toolchain:all", - "@rules_rust//extensions/prost:default_prost_toolchain", -) - -crate = use_extension("@rules_rs//rs:extensions.bzl", "crate") -crate.from_cargo( - name = "crates", - cargo_lock = "//:Cargo.lock", - cargo_toml = "//:Cargo.toml", - platform_triples = [ - "aarch64-apple-darwin", - "aarch64-unknown-linux-gnu", - "aarch64-unknown-linux-musl", - "x86_64-unknown-linux-gnu", - "x86_64-unknown-linux-musl", - ], -) -use_repo(crate, "crates") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock deleted file mode 100644 index 9276b10fb9..0000000000 --- a/MODULE.bazel.lock +++ /dev/null @@ -1,1502 +0,0 @@ -{ - "lockFileVersion": 26, - "registryFileHashes": { - "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", - "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", - "https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589", - "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0", - "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb", - "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", - "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", - "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", - "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", - "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", - "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", - "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", - "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", - "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", - "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", - "https://bcr.bazel.build/modules/apple_support/1.22.1/MODULE.bazel": "90bd1a660590f3ceffbdf524e37483094b29352d85317060b2327fff8f3f4458", - "https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8", - "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", - "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", - "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183", - "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/source.json": "605086bbc197743a0d360f7ddc550a1d4dfa0441bc807236e17170f636153348", - "https://bcr.bazel.build/modules/aws-lc/5.1.0/MODULE.bazel": "ae810ea6e051f576c2727a238ae999cd8851081400364f75b0ffce2f6d96d75c", - "https://bcr.bazel.build/modules/aws-lc/5.1.0/source.json": "dbd78690ada005489f31a590a8cdefd8789117ca7089a04a853c94329a2a7290", - "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", - "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", - "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", - "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", - "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", - "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", - "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", - "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", - "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", - "https://bcr.bazel.build/modules/bazel_features/1.24.0/MODULE.bazel": "4796b4c25b47053e9bbffa792b3792d07e228ff66cd0405faef56a978708acd4", - "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", - "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", - "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", - "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", - "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", - "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", - "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", - "https://bcr.bazel.build/modules/bazel_features/1.42.0/MODULE.bazel": "e8ca15cb2639c5f12183db6dcb678735555d0cdd739b32a0418b6532b5e565f8", - "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080", - "https://bcr.bazel.build/modules/bazel_features/1.45.0/MODULE.bazel": "7daec6d87ab0703417486d4cb948af0b06f55d4d7c08cbb5978c80e79b538edf", - "https://bcr.bazel.build/modules/bazel_features/1.47.0/MODULE.bazel": "e34df3cb35b1684cfa69923a61ae3803595babd3942cd306a488d51400886b30", - "https://bcr.bazel.build/modules/bazel_features/1.50.0/MODULE.bazel": "2083ef9c7a469f520890483ccf8e0189d6e71e2117e7752e15e6554433d5ae3e", - "https://bcr.bazel.build/modules/bazel_features/1.50.0/source.json": "e0ee3debde2789ff56e4452e612d126925ba9ab64d4bde79c67f099d2902df9b", - "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", - "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", - "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", - "https://bcr.bazel.build/modules/bazel_lib/3.1.0/MODULE.bazel": "6809765c14e3c766a9b9286c7b0ec56ed87a73326e48fe01749f0c0fdcfe3287", - "https://bcr.bazel.build/modules/bazel_lib/3.2.2/MODULE.bazel": "e2c890c8a515d6bca9c66d47718aa9e44b458fde64ec7204b8030bf2d349058c", - "https://bcr.bazel.build/modules/bazel_lib/3.2.2/source.json": "9e84e115c20e14652c5c21401ae85ff4daa8702e265b5c0b3bf89353f17aa212", - "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", - "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", - "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", - "https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a", - "https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5", - "https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d", - "https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651", - "https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138", - "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", - "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", - "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", - "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", - "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0", - "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f", - "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", - "https://bcr.bazel.build/modules/bzip2/1.0.8.bcr.3/MODULE.bazel": "29ecf4babfd3c762be00d7573c288c083672ab60e79c833ff7f49ee662e54471", - "https://bcr.bazel.build/modules/bzip2/1.0.8.bcr.3/source.json": "8be4a3ef2599693f759e5c0990a4cc5a246ac08db4c900a38f852ba25b5c39be", - "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/MODULE.bazel": "f1b7bb2dd53e8f2ef984b39485ec8a44e9076dda5c4b8efd2fb4c6a6e856a31d", - "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/source.json": "ebe931bfe362e4b41e59ee00a528db6074157ff2ced92eb9e970acab2e1089c9", - "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", - "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", - "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", - "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", - "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", - "https://bcr.bazel.build/modules/googletest/1.17.0.bcr.2/MODULE.bazel": "827f54f492a3ce549c940106d73de332c2b30cebd0c20c0bc5d786aba7f116cb", - "https://bcr.bazel.build/modules/googletest/1.17.0.bcr.2/source.json": "3664514073a819992320ffbce5825e4238459df344d8b01748af2208f8d2e1eb", - "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", - "https://bcr.bazel.build/modules/hermetic_launcher/0.0.8/MODULE.bazel": "3be7b0faca6f1e69e89197999e0b01ce058c42f3e764ef028466f7e0ff77c761", - "https://bcr.bazel.build/modules/hermetic_launcher/0.0.8/source.json": "8403718636114198fca6ea04b437fa001ac7167b4d485102defb0a6f7d11bc08", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", - "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", - "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", - "https://bcr.bazel.build/modules/llvm/0.8.11/MODULE.bazel": "0f8c30b74be64f0e91764e925d0f562c70e8d85b6cea912f724d6b3753d6a33f", - "https://bcr.bazel.build/modules/llvm/0.8.11/source.json": "b40edb2bb2ed271bf613b396d245cb473c42fb057a2b26a3bc7d7e8bfcf6aa71", - "https://bcr.bazel.build/modules/llvm/0.8.9/MODULE.bazel": "9e35ff5bcac996f9edc1b44b8f8baa58c7855e742c5608b07d925dcdbe642100", - "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", - "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83", - "https://bcr.bazel.build/modules/package_metadata/0.0.7/MODULE.bazel": "7adb03933fc8401f495800cf4eafcff0edc6da0ff55c7db223ef69d19f689486", - "https://bcr.bazel.build/modules/package_metadata/0.0.7/source.json": "50639625e937b56115012674c797cca7a05a96b4878c87d803c13dc2b31de8a0", - "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", - "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", - "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", - "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", - "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", - "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", - "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", - "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", - "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", - "https://bcr.bazel.build/modules/platforms/1.1.0/MODULE.bazel": "1c0c09f5bdcf4b3f924720d2478a3711cb39f4977019ca5988685e5b7e18b3d2", - "https://bcr.bazel.build/modules/platforms/1.1.0/source.json": "fcf351c47596c939140ab0d333dfdd08ed1ea6ce33c2fe70c12493a301cf1344", - "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", - "https://bcr.bazel.build/modules/protobuf/23.1/MODULE.bazel": "88b393b3eb4101d18129e5db51847cd40a5517a53e81216144a8c32dfeeca52a", - "https://bcr.bazel.build/modules/protobuf/24.4/MODULE.bazel": "7bc7ce5f2abf36b3b7b7c8218d3acdebb9426aeb35c2257c96445756f970eb12", - "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", - "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", - "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", - "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", - "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", - "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", - "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", - "https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/MODULE.bazel": "74e541b0ba877813da786a11707d4e394433c157841d5111a36be0d44b907931", - "https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/source.json": "fc174b3d6215aa14197d1bd779f98bb72d9fd666ee5ec0d6bba6ae986baa4535", - "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", - "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", - "https://bcr.bazel.build/modules/pybind11_bazel/2.13.6/MODULE.bazel": "2d746fda559464b253b2b2e6073cb51643a2ac79009ca02100ebbc44b4548656", - "https://bcr.bazel.build/modules/pybind11_bazel/2.13.6/source.json": "6aa0703de8efb20cc897bbdbeb928582ee7beaf278bcd001ac253e1605bddfae", - "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", - "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", - "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", - "https://bcr.bazel.build/modules/re2/2025-08-12.bcr.1/MODULE.bazel": "e09b434b122bfb786a69179f9b325e35cb1856c3f56a7a81dd61609260ed46e1", - "https://bcr.bazel.build/modules/re2/2025-08-12.bcr.1/source.json": "a8ae7c09533bf67f9f6e5122d884d5741600b09d78dca6fc0f2f8d2ee0c2d957", - "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", - "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", - "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", - "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", - "https://bcr.bazel.build/modules/rules_autoconf/0.0.14/MODULE.bazel": "ea2e63f6d25a40adf67daa25a2bb78b868cceb7a67d67c8d3110bfd5f51a35dc", - "https://bcr.bazel.build/modules/rules_autoconf/0.0.14/source.json": "fc30be09bee23541d4a17c5bd654ab45c6d0cadd0434dbcabb166a60112294b8", - "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", - "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", - "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", - "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", - "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", - "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", - "https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c", - "https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f", - "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", - "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", - "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", - "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", - "https://bcr.bazel.build/modules/rules_cc/0.1.4/MODULE.bazel": "bb03a452a7527ac25a7518fb86a946ef63df860b9657d8323a0c50f8504fb0b9", - "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", - "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", - "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", - "https://bcr.bazel.build/modules/rules_cc/0.2.19/MODULE.bazel": "d5e0f05b63273281a16654eb6b1a8742a75ec153ac8b4f0419949d6e401e46f0", - "https://bcr.bazel.build/modules/rules_cc/0.2.20/MODULE.bazel": "f5c07bce5ddcb99be21a0812ff5aadb439e688b7449c6542152363b2fd859c1a", - "https://bcr.bazel.build/modules/rules_cc/0.2.20/source.json": "1155433dc6b8161bc339ce94095b337ed95feb1f048b014e10b62339d4b4239c", - "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", - "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", - "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", - "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", - "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", - "https://bcr.bazel.build/modules/rules_java/7.1.0/MODULE.bazel": "30d9135a2b6561c761bd67bd4990da591e6bdc128790ce3e7afd6a3558b2fb64", - "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", - "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", - "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", - "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", - "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", - "https://bcr.bazel.build/modules/rules_java/8.6.0/MODULE.bazel": "9c064c434606d75a086f15ade5edb514308cccd1544c2b2a89bbac4310e41c71", - "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", - "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d", - "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6", - "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", - "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", - "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", - "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", - "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", - "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", - "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", - "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", - "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", - "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", - "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", - "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", - "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", - "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", - "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", - "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", - "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", - "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", - "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", - "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", - "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", - "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", - "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", - "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", - "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", - "https://bcr.bazel.build/modules/rules_python/0.34.0/MODULE.bazel": "1d623d026e075b78c9fde483a889cda7996f5da4f36dffb24c246ab30f06513a", - "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", - "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", - "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", - "https://bcr.bazel.build/modules/rules_python/1.5.1/MODULE.bazel": "acfe65880942d44a69129d4c5c3122d57baaf3edf58ae5a6bd4edea114906bf5", - "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", - "https://bcr.bazel.build/modules/rules_python/1.6.3/MODULE.bazel": "a7b80c42cb3de5ee2a5fa1abc119684593704fcd2fec83165ebe615dec76574f", - "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", - "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", - "https://bcr.bazel.build/modules/rules_rs/0.0.96/MODULE.bazel": "678fdcee5a9847611276770eab47190dda04a41973b7bf6037e50a3f98e49e09", - "https://bcr.bazel.build/modules/rules_rs/0.0.96/source.json": "2b52d3d209324bd4aaa4896aa0e8b28d0123856b4ade32ebd7bf24b4fded6f12", - "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", - "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", - "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", - "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", - "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", - "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", - "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", - "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", - "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", - "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", - "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", - "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", - "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", - "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", - "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", - "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", - "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", - "https://bcr.bazel.build/modules/tar.bzl/0.10.4/source.json": "20143442376c03426f6135292ba02d825cb75308aa47e6bf42dd4cc5a435c2ff", - "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", - "https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9", - "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", - "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", - "https://bcr.bazel.build/modules/xz/5.4.5.bcr.8/MODULE.bazel": "e48a69bd54053c2ec5fffc2a29fb70122afd3e83ab6c07068f63bc6553fa57cc", - "https://bcr.bazel.build/modules/xz/5.4.5.bcr.8/source.json": "bd7e928ccd63505b44f4784f7bbf12cc11f9ff23bf3ca12ff2c91cd74846099e", - "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", - "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", - "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", - "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198", - "https://bcr.bazel.build/modules/zstd/1.5.7.bcr.1/MODULE.bazel": "c5977176dd8555be7a9d598512ae0cae11831259c06f00a5e5e0037d5db4e3f5", - "https://bcr.bazel.build/modules/zstd/1.5.7.bcr.1/source.json": "aa95e0b5aac9d80195b9047d223beaf26b1948be55d49f0e803e180e9ccc6e75" - }, - "selectedYankedVersions": {}, - "moduleExtensions": { - "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { - "general": { - "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=", - "usagesDigest": "Miy0EWu0H7wJMzfdekpzuz3TBOzJz0l6aNIq2pL6k2g=", - "recordedInputs": [ - "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", - "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" - ], - "generatedRepoSpecs": { - "aspect_tools_telemetry_report": { - "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", - "attributes": { - "deps": { - "rules_rs": "0.0.96", - "aspect_tools_telemetry": "0.3.3" - } - } - } - } - } - }, - "@@protobuf+//python/dist:system_python.bzl%system_python_extension": { - "general": { - "bzlTransitiveDigest": "pmsA+awieucfllLc2n7k8xEoPp0i5LF9Hw6mGX0cqSQ=", - "usagesDigest": "A+RWmbKdBBwZcBbNGNvfPbqG2vYZRjVrFp6x1iRUrAk=", - "recordedInputs": [], - "generatedRepoSpecs": { - "system_python": { - "repoRuleId": "@@protobuf+//python/dist:system_python.bzl%system_python", - "attributes": { - "minimum_python_version": "3.9" - } - } - } - } - }, - "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { - "general": { - "bzlTransitiveDigest": "Ilz4hu4VWEbx3OM4ZIpgYmYXuPq6ewOVgzv5F0ziWS8=", - "usagesDigest": "tVQNvLoXMWAbiK39am3yovKGpwINdftfn7RpDyN+JZc=", - "recordedInputs": [ - "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools" - ], - "generatedRepoSpecs": { - "pybind11": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", - "strip_prefix": "pybind11-2.13.6", - "url": "https://github.com/pybind/pybind11/archive/refs/tags/v2.13.6.tar.gz", - "integrity": "sha256-4Iy4f0dz2pf6e18DXeh2OrxlbYfVdz5i9toFh9Hw7CA=" - } - } - } - } - }, - "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { - "general": { - "bzlTransitiveDigest": "Ga4z8lQy1YQ5rAMy+dOl0dqcCEBnYNCXku8x3YQmDZI=", - "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedInputs": [ - "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" - ], - "generatedRepoSpecs": { - "com_github_jetbrains_kotlin_git": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", - "attributes": { - "urls": [ - "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" - ], - "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" - } - }, - "com_github_jetbrains_kotlin": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", - "attributes": { - "git_repository_name": "com_github_jetbrains_kotlin_git", - "compiler_version": "1.9.23" - } - }, - "com_github_google_ksp": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", - "attributes": { - "urls": [ - "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" - ], - "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", - "strip_version": "1.9.23-1.0.20" - } - }, - "com_github_pinterest_ktlint": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", - "attributes": { - "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", - "urls": [ - "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" - ], - "executable": true - } - }, - "rules_android": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", - "strip_prefix": "rules_android-0.1.1", - "urls": [ - "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" - ] - } - } - } - } - }, - "@@rules_python+//python/extensions:config.bzl%config": { - "general": { - "bzlTransitiveDigest": "iibnRYgg8LpcfmH7EAnVwYePC3jsVaJ6Id8XxUjSZps=", - "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", - "recordedInputs": [ - "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", - "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", - "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", - "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", - "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", - "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", - "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", - "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", - "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", - "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", - "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", - "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", - "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", - "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", - "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", - "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" - ], - "generatedRepoSpecs": { - "rules_python_internal": { - "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", - "attributes": { - "transition_setting_generators": {}, - "transition_settings": [] - } - }, - "pypi__build": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl", - "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__click": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", - "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__colorama": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", - "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__importlib_metadata": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", - "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__installer": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", - "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__more_itertools": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", - "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__packaging": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", - "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pep517": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl", - "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pip": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl", - "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pip_tools": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", - "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__pyproject_hooks": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl", - "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__setuptools": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", - "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__tomli": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", - "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__wheel": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", - "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - }, - "pypi__zipp": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl", - "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e", - "type": "zip", - "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" - } - } - } - } - }, - "@@rules_python+//python/uv:uv.bzl%uv": { - "general": { - "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", - "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", - "recordedInputs": [ - "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", - "REPO_MAPPING:rules_python+,platforms platforms" - ], - "generatedRepoSpecs": { - "uv": { - "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", - "attributes": { - "toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'", - "toolchain_names": [ - "none" - ], - "toolchain_implementations": { - "none": "'@@rules_python+//python:none'" - }, - "toolchain_compatible_with": { - "none": [ - "@platforms//:incompatible" - ] - }, - "toolchain_target_settings": {} - } - } - } - } - } - }, - "facts": { - "@@rules_rs+//rs:extensions.bzl%crate": { - "addr2line_0.25.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"backtrace\",\"req\":\"^0.3.13\"},{\"features\":[\"wrap_help\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.3.21\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"cpp_demangle\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"findshlibs\",\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"read\"],\"name\":\"gimli\",\"req\":\"^0.32.0\"},{\"kind\":\"dev\",\"name\":\"libtest-mimic\",\"req\":\"^0.8.1\"},{\"name\":\"memmap2\",\"optional\":true,\"req\":\"^0.9.4\"},{\"default_features\":false,\"features\":[\"read\",\"compression\"],\"name\":\"object\",\"optional\":true,\"req\":\"^0.37.0\"},{\"name\":\"rustc-demangle\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"typed-arena\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"all\":[\"bin\",\"wasm\"],\"bin\":[\"loader\",\"rustc-demangle\",\"cpp_demangle\",\"fallible-iterator\",\"smallvec\",\"dep:clap\"],\"cargo-all\":[],\"default\":[\"rustc-demangle\",\"cpp_demangle\",\"loader\",\"fallible-iterator\",\"smallvec\"],\"loader\":[\"std\",\"dep:object\",\"dep:memmap2\",\"dep:typed-arena\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"gimli/rustc-dep-of-std\"],\"std\":[\"gimli/std\"],\"wasm\":[\"object/wasm\"]}}", - "adler2_2.0.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[]}}", - "aead_0.6.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.11.1\"},{\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"inout\",\"req\":\"^0.2.2\"}],\"features\":{\"alloc\":[],\"default\":[\"rand_core\"],\"dev\":[\"blobby\",\"alloc\"],\"getrandom\":[\"common/getrandom\",\"rand_core\"],\"rand_core\":[\"common/rand_core\"]}}", - "aes-gcm_0.11.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"alloc\",\"dev\"],\"kind\":\"dev\",\"name\":\"aead\",\"req\":\"^0.6\"},{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"cipher\",\"req\":\"^0.5\"},{\"name\":\"ctr\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ghash\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"arrayvec\":[\"aead/arrayvec\"],\"bytes\":[\"aead/bytes\"],\"default\":[\"aes\",\"alloc\",\"getrandom\"],\"getrandom\":[\"aead/getrandom\"],\"hazmat\":[],\"rand_core\":[\"aead/rand_core\"]}}", - "aes_0.8.4": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"aarch64\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5.6\",\"target\":\"cfg(all(aes_armv8, target_arch = \\\"aarch64\\\"))\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.6.0\",\"target\":\"cfg(not(all(aes_armv8, target_arch = \\\"aarch64\\\")))\"}],\"features\":{\"hazmat\":[]}}", - "aes_0.9.2": "{\"dependencies\":[{\"name\":\"cipher\",\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"},{\"name\":\"cpubits\",\"req\":\"^0.1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.9\"}],\"features\":{\"hazmat\":[]}}", - "ahash_0.8.12": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"const-random\",\"optional\":true,\"req\":\"^0.1.17\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.2\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"once_cell\",\"req\":\"^1.18.0\",\"target\":\"cfg(not(all(target_arch = \\\"arm\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"pcg-mwc\",\"req\":\"^0.2.1\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^4.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.59\"},{\"kind\":\"dev\",\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"kind\":\"build\",\"name\":\"version_check\",\"req\":\"^0.9.4\"},{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.24\"}],\"features\":{\"atomic-polyfill\":[\"dep:portable-atomic\",\"once_cell/critical-section\"],\"compile-time-rng\":[\"const-random\"],\"default\":[\"std\",\"runtime-rng\"],\"nightly-arm-aes\":[],\"no-rng\":[],\"runtime-rng\":[\"getrandom\"],\"std\":[]}}", - "aho-corasick_1.1.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\",\"perf-literal\"],\"logging\":[\"dep:log\"],\"perf-literal\":[\"dep:memchr\"],\"std\":[\"memchr?/std\"]}}", - "alloc-no-stdlib_2.0.4": "{\"dependencies\":[],\"features\":{\"unsafe\":[]}}", - "alloc-stdlib_0.2.2": "{\"dependencies\":[{\"name\":\"alloc-no-stdlib\",\"req\":\"^2.0.4\"}],\"features\":{\"unsafe\":[]}}", - "allocator-api2_0.2.21": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"fresh-rust\":[],\"nightly\":[],\"std\":[\"alloc\"]}}", - "android_system_properties_0.1.5": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.126\"}],\"features\":{}}", - "anstream_1.0.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"name\":\"anstyle-parse\",\"req\":\"^1.0.0\"},{\"name\":\"anstyle-query\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"anstyle-wincon\",\"optional\":true,\"req\":\"^3.0.5\",\"target\":\"cfg(windows)\"},{\"name\":\"colorchoice\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.16\"},{\"name\":\"is_terminal_polyfill\",\"req\":\"^1.48\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"owo-colors\",\"req\":\"^4.0.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"strip-ansi-escapes\",\"req\":\"^0.2.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2.2\"}],\"features\":{\"auto\":[\"dep:anstyle-query\"],\"default\":[\"auto\",\"wincon\"],\"test\":[],\"wincon\":[\"dep:anstyle-wincon\"]}}", - "anstyle-parse_1.0.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.7.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"codegenrs\",\"req\":\"^3.0.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.16\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.23\"},{\"name\":\"utf8parse\",\"optional\":true,\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"vte_generate_state_changes\",\"req\":\"^0.1.2\"}],\"features\":{\"core\":[\"dep:arrayvec\"],\"default\":[\"utf8\"],\"utf8\":[\"dep:utf8parse\"]}}", - "anstyle-query_1.1.5": "{\"dependencies\":[{\"features\":[\"Win32_System_Console\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.60.2, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "anstyle-wincon_3.0.11": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"name\":\"once_cell_polyfill\",\"req\":\"^1.56.1\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_System_Console\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.60.2, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "anstyle_1.0.14": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.23\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "anyhow_1.0.102": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"backtrace\":[],\"default\":[\"std\"],\"std\":[]}}", - "apollo-parser_0.8.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"annotate-snippets\",\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.66\"},{\"kind\":\"dev\",\"name\":\"ariadne\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"expect-test\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"name\":\"memchr\",\"req\":\"^2.6.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3.0\"},{\"name\":\"rowan\",\"req\":\"^0.16.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"unindent\",\"req\":\"^0.2.1\"}],\"features\":{}}", - "arc-swap_1.9.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", - "argon2_0.6.0-rc.8": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1.7\"},{\"default_features\":false,\"name\":\"blake2\",\"req\":\"^0.11.0-rc.5\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"phc\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.7\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"password-hash?/alloc\"],\"default\":[\"alloc\",\"getrandom\",\"password-hash\"],\"getrandom\":[\"password-hash/getrandom\"],\"kdf\":[\"alloc\",\"dep:kdf\"],\"parallel\":[\"dep:rayon\"],\"password-hash\":[\"dep:password-hash\"],\"rand_core\":[\"password-hash/rand_core\"],\"zeroize\":[\"dep:zeroize\"]}}", - "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "asn1-rs-derive_0.5.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"synstructure\",\"req\":\"^0.13\"}],\"features\":{}}", - "asn1-rs-impl_0.2.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "asn1-rs_0.6.2": "{\"dependencies\":[{\"name\":\"asn1-rs-derive\",\"req\":\"^0.5\"},{\"name\":\"asn1-rs-impl\",\"req\":\"^0.2\"},{\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"colored\",\"optional\":true,\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^2.0\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"displaydoc\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pem\",\"req\":\"^3.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.25\"},{\"features\":[\"macros\",\"parsing\",\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"bigint\":[\"num-bigint\"],\"bits\":[\"bitvec\"],\"datetime\":[\"time\"],\"debug\":[\"colored\"],\"default\":[\"std\"],\"serialize\":[\"cookie-factory\"],\"std\":[],\"trace\":[\"debug\"]}}", - "assert-json-diff_2.0.2": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.8\"}],\"features\":{}}", - "async-trait_0.1.89": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\",\"proc-macro\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"kind\":\"dev\",\"name\":\"tracing-attributes\",\"req\":\"^0.1.27\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", - "atoi_2.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.14\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"num-traits/std\"]}}", - "atomic-waker_1.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7.0\"}],\"features\":{}}", - "autocfg_1.5.0": "{\"dependencies\":[],\"features\":{}}", - "autotools_0.2.7": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.3\"}],\"features\":{}}", - "aws-config_1.8.15": "{\"dependencies\":[{\"features\":[\"test-util\"],\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"name\":\"aws-runtime\",\"req\":\"^1.7.2\"},{\"default_features\":false,\"name\":\"aws-sdk-signin\",\"optional\":true,\"req\":\"^1.7.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sso\",\"optional\":true,\"req\":\"^1.96.0\"},{\"default_features\":false,\"name\":\"aws-sdk-ssooidc\",\"optional\":true,\"req\":\"^1.98.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sts\",\"req\":\"^1.100.0\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.63.6\"},{\"features\":[\"default-client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.12\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.62.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.10.3\"},{\"features\":[\"client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.10.3\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"name\":\"aws-types\",\"req\":\"^1.3.14\"},{\"name\":\"base64-simd\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"p256\",\"optional\":true,\"req\":\"^0.13.2\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.9\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.13.1\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"fmt\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.18.1\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"allow-compilation\":[],\"behavior-version-latest\":[],\"client-hyper\":[\"aws-smithy-runtime/default-https-client\"],\"credentials-login\":[\"dep:aws-sdk-signin\",\"dep:sha2\",\"dep:zeroize\",\"dep:hex\",\"dep:base64-simd\",\"dep:uuid\",\"uuid?/v4\",\"dep:p256\",\"p256?/arithmetic\",\"p256?/pem\",\"dep:rand\"],\"credentials-process\":[\"tokio/process\"],\"default\":[\"default-https-client\",\"rt-tokio\",\"credentials-process\",\"sso\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-runtime/rt-tokio\",\"tokio/rt\"],\"rustls\":[\"client-hyper\"],\"sso\":[\"dep:aws-sdk-sso\",\"dep:aws-sdk-ssooidc\",\"dep:sha1\",\"dep:hex\",\"dep:zeroize\",\"aws-smithy-runtime-api/http-auth\"],\"test-util\":[\"aws-runtime/test-util\"]}}", - "aws-credential-types_1.2.14": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1.74\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"client\",\"http-auth\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"features\":[\"full\",\"test-util\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"name\":\"zeroize\",\"req\":\"^1.7.0\"}],\"features\":{\"hardcoded-credentials\":[],\"test-util\":[\"aws-smithy-runtime-api/test-util\"]}}", - "aws-lc-rs_1.16.3": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.40.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"dev-tests-only\":[],\"fips\":[\"dep:aws-lc-fips-sys\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", - "aws-lc-sys_0.40.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"fips\":[\"dep:bindgen\"],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", - "aws-runtime_1.7.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.3\"},{\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"features\":[\"http0-compat\"],\"name\":\"aws-sigv4\",\"req\":\"^1.4.2\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.20\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.63.6\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.14\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.10.3\"},{\"features\":[\"client\",\"http-1x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"test-util\",\"http-1x\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"http-body-1-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"name\":\"aws-types\",\"req\":\"^1.3.14\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"bytes-utils\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"convert_case\",\"req\":\"^0.6.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-04x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1x\",\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.3\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2\"},{\"name\":\"regex-lite\",\"optional\":true,\"req\":\"^0.1.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"event-stream\":[\"dep:aws-smithy-eventstream\",\"aws-sigv4/sign-eventstream\"],\"http-02x\":[\"dep:http-02x\",\"dep:http-body-04x\"],\"http-1x\":[],\"sigv4a\":[\"aws-sigv4/sigv4a\"],\"test-util\":[\"dep:regex-lite\"]}}", - "aws-sdk-sts_1.102.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"name\":\"aws-runtime\",\"req\":\"^1.7.2\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-runtime\",\"req\":\"^1.7.2\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.63.6\"},{\"features\":[\"test-util\",\"wire-mock\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.12\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.62.5\"},{\"name\":\"aws-smithy-observability\",\"req\":\"^0.2.6\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.14\"},{\"name\":\"aws-smithy-query\",\"req\":\"^0.60.15\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.10.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.10.3\"},{\"features\":[\"client\",\"http-1x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"http-body-1-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.7\"},{\"features\":[\"http-body-1-x\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.4.7\"},{\"name\":\"aws-smithy-xml\",\"req\":\"^0.60.15\"},{\"name\":\"aws-types\",\"req\":\"^1.3.14\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.25\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"sigv4a\",\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"sigv4a\":[\"aws-runtime/sigv4a\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", - "aws-sigv4_1.4.2": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"features\":[\"test-util\",\"hardcoded-credentials\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.20\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.63.6\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crypto-bigint\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.2.1\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"name\":\"hmac\",\"req\":\"^0.12\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.3.1\"},{\"name\":\"http0\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.12\"},{\"kind\":\"dev\",\"name\":\"httparse\",\"req\":\"^1.10.1\"},{\"features\":[\"ecdsa\"],\"name\":\"p256\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.5\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.5\",\"target\":\"cfg(not(any(target_arch = \\\"powerpc\\\", target_arch = \\\"powerpc64\\\")))\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.104\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.5.0\"},{\"name\":\"time\",\"req\":\"^0.3.5\"},{\"features\":[\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.5\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7.0\"}],\"features\":{\"default\":[\"sign-http\",\"http1\"],\"http0-compat\":[\"dep:http0\"],\"http1\":[\"dep:http\"],\"sign-eventstream\":[\"dep:aws-smithy-eventstream\"],\"sign-http\":[\"dep:http0\",\"dep:percent-encoding\",\"dep:form_urlencoded\"],\"sigv4a\":[\"dep:p256\",\"dep:crypto-bigint\",\"dep:subtle\",\"dep:zeroize\",\"dep:ring\"]}}", - "aws-smithy-async_1.2.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"features\":[\"rt\",\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.2\"}],\"features\":{\"rt-tokio\":[\"tokio/time\"],\"test-util\":[\"rt-tokio\",\"tokio/rt\"]}}", - "aws-smithy-http-client_1.1.13": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-protocol-test\",\"optional\":true,\"req\":\"^0.63.14\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.9\"},{\"features\":[\"http-body-0-4-x\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.4.9\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"h2\",\"req\":\"^0.4.11\"},{\"name\":\"h2-0-3\",\"optional\":true,\"package\":\"h2\",\"req\":\"^0.3.24\"},{\"name\":\"http-02x\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-04x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.3\"},{\"features\":[\"client\",\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.6.0\"},{\"default_features\":false,\"features\":[\"client\",\"http1\",\"http2\",\"tcp\",\"stream\"],\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"default_features\":false,\"features\":[\"http2\",\"http1\",\"native-tokio\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.16\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.16\"},{\"features\":[\"serde\"],\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.10.0\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\",\"logging\",\"acceptor\",\"tokio-runtime\",\"http2\"],\"name\":\"legacy-hyper-rustls\",\"optional\":true,\"package\":\"hyper-rustls\",\"req\":\"^0.24.2\"},{\"name\":\"legacy-rustls\",\"optional\":true,\"package\":\"rustls\",\"req\":\"^0.21.8\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.31\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.1\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2.2.0\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.12.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rustls-pki-types\",\"req\":\"^1.12.0\"},{\"name\":\"s2n-tls\",\"optional\":true,\"req\":\"^0.3.33\"},{\"name\":\"s2n-tls-hyper\",\"optional\":true,\"req\":\"^0.1.0\"},{\"name\":\"s2n-tls-tokio\",\"optional\":true,\"req\":\"^0.3.33\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.228\"},{\"features\":[\"preserve_order\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.146\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.2\"},{\"name\":\"tokio\",\"req\":\"^1.49\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.2\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26.2\"},{\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5.2\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"}],\"features\":{\"__rustls\":[\"dep:rustls\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"default-client\":[\"aws-smithy-runtime-api/http-1x\",\"aws-smithy-types/http-body-1-x\",\"dep:hyper\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"hyper-util?/client-proxy\",\"dep:http-1x\",\"dep:tower\",\"dep:rustls-pki-types\",\"dep:rustls-native-certs\"],\"hyper-014\":[\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\",\"dep:http-02x\",\"dep:http-body-04x\",\"dep:hyper-0-14\",\"dep:h2-0-3\"],\"legacy-rustls-ring\":[\"dep:legacy-hyper-rustls\",\"dep:legacy-rustls\",\"dep:rustls-native-certs\",\"hyper-014\"],\"legacy-test-util\":[\"test-util\",\"dep:http-02x\",\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\"],\"rustls-aws-lc\":[\"__rustls\",\"rustls?/aws_lc_rs\",\"rustls?/prefer-post-quantum\"],\"rustls-aws-lc-fips\":[\"__rustls\",\"rustls?/fips\",\"rustls?/prefer-post-quantum\"],\"rustls-ring\":[\"__rustls\",\"rustls?/ring\"],\"s2n-tls\":[\"dep:s2n-tls\",\"dep:s2n-tls-hyper\",\"dep:s2n-tls-tokio\",\"default-client\"],\"test-util\":[\"dep:aws-smithy-protocol-test\",\"dep:serde\",\"dep:serde_json\",\"dep:indexmap\",\"dep:bytes\",\"dep:http-1x\",\"aws-smithy-runtime-api/http-1x\",\"dep:http-body-1x\",\"aws-smithy-types/http-body-1-x\",\"tokio/rt\"],\"wire-mock\":[\"test-util\",\"default-client\",\"hyper-util?/server\",\"hyper-util?/server-auto\",\"hyper-util?/service\",\"hyper-util?/server-graceful\",\"tokio/macros\",\"dep:http-body-util\"]}}", - "aws-smithy-http_0.63.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.20\"},{\"features\":[\"client\",\"http-1x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"byte-stream-poll-next\",\"http-body-1-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1\"},{\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-1x\",\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"}],\"features\":{\"event-stream\":[\"aws-smithy-eventstream\"],\"rt-tokio\":[\"aws-smithy-types/rt-tokio\"]}}", - "aws-smithy-json_0.62.7": "{\"dependencies\":[{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.3\"},{\"name\":\"aws-smithy-schema\",\"req\":\"^0.1.0\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"=1.0.146\"}],\"features\":{}}", - "aws-smithy-observability_0.2.6": "{\"dependencies\":[{\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.1.1\"}],\"features\":{}}", - "aws-smithy-query_0.60.15": "{\"dependencies\":[{\"features\":[\"http-body-1-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"}],\"features\":{}}", - "aws-smithy-runtime-api-macros_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.114\"}],\"features\":{}}", - "aws-smithy-runtime-api_1.12.3": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-runtime-api-macros\",\"req\":\"^1.0.0\"},{\"features\":[\"http-body-1-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.9\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7.0\"}],\"features\":{\"client\":[],\"default\":[],\"http-02x\":[],\"http-1x\":[],\"http-auth\":[\"dep:zeroize\"],\"test-util\":[\"aws-smithy-types/test-util\",\"http-1x\"]}}", - "aws-smithy-runtime_1.11.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.63.6\"},{\"name\":\"aws-smithy-http-client\",\"optional\":true,\"req\":\"^1.1.12\"},{\"name\":\"aws-smithy-observability\",\"req\":\"^0.2.6\"},{\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.1\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.1\"},{\"name\":\"aws-smithy-schema\",\"req\":\"^0.1.0\"},{\"features\":[\"http-body-0-4-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.8\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.4.8\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1x\",\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.3\"},{\"features\":[\"client\",\"server\",\"tcp\",\"http1\",\"http2\"],\"kind\":\"dev\",\"name\":\"hyper_0_14\",\"package\":\"hyper\",\"req\":\"^0.14.27\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"},{\"features\":[\"env-filter\",\"fmt\",\"json\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3.22\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.22\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.1\"}],\"features\":{\"client\":[\"aws-smithy-runtime-api/client\",\"aws-smithy-types/http-body-1-x\"],\"connector-hyper-0-14-x\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/hyper-014\"],\"default-https-client\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/rustls-aws-lc\"],\"http-auth\":[\"aws-smithy-runtime-api/http-auth\"],\"legacy-test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"connector-hyper-0-14-x\",\"aws-smithy-http-client/legacy-test-util\"],\"rt-tokio\":[\"tokio/rt\"],\"test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"legacy-test-util\"],\"tls-rustls\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/legacy-rustls-ring\",\"connector-hyper-0-14-x\"],\"wire-mock\":[\"legacy-test-util\",\"aws-smithy-http-client/wire-mock\"]}}", - "aws-smithy-schema_0.1.0": "{\"dependencies\":[{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.1\"},{\"default_features\":false,\"name\":\"aws-smithy-types\",\"req\":\"^1.4.8\"},{\"name\":\"http\",\"req\":\"^1.3.1\"}],\"features\":{}}", - "aws-smithy-types_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.13.0\"},{\"name\":\"base64-simd\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"ciborium\",\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-0-4\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1-0\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"name\":\"itoa\",\"req\":\"^1.0.17\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ryu\",\"req\":\"^1.0.22\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.228\",\"target\":\"cfg(aws_sdk_unstable)\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.16.0\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.49.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"fs\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1.5\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.18\"}],\"features\":{\"byte-stream-poll-next\":[],\"http-body-0-4-x\":[\"dep:http-body-0-4\",\"dep:http\"],\"http-body-1-x\":[\"dep:http-body-1-0\",\"dep:http-body-util\",\"dep:http-body-0-4\",\"dep:http\"],\"hyper-0-14-x\":[\"dep:hyper-0-14\"],\"rt-tokio\":[\"dep:http-body-0-4\",\"dep:tokio-util\",\"dep:tokio\",\"tokio?/rt\",\"tokio?/fs\",\"tokio?/io-util\",\"tokio-util?/io\",\"dep:futures-core\",\"dep:http\"],\"serde-deserialize\":[],\"serde-serialize\":[],\"test-util\":[]}}", - "aws-smithy-xml_0.60.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"xmlparser\",\"req\":\"^0.13.5\"}],\"features\":{}}", - "aws-types_1.3.16": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-runtime\",\"optional\":true,\"req\":\"^1.11.3\"},{\"features\":[\"client\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.11.3\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.1\"},{\"features\":[\"http-02x\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.12.1\"},{\"name\":\"aws-smithy-schema\",\"req\":\"^0.1.0\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.8\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"http2\",\"webpki-roots\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.24.2\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.16.0\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.5\"}],\"features\":{\"examples\":[\"dep:hyper-rustls\",\"aws-smithy-runtime/client\",\"aws-smithy-runtime/connector-hyper-0-14-x\",\"aws-smithy-runtime/tls-rustls\"]}}", - "axum-core_0.5.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.0\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"limit\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{\"__private_docs\":[\"dep:tower-http\"],\"tracing\":[\"dep:tracing\"]}}", - "axum_0.8.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"axum-core\",\"req\":\"^0.5.5\"},{\"name\":\"axum-macros\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.1.0\"},{\"features\":[\"client\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\"},{\"features\":[\"tokio\",\"server\",\"service\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"matchit\",\"req\":\"=0.8.4\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"multer\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.211\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"serde-human-readable\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.44\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"net\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"package\":\"tokio\",\"req\":\"^1.44.2\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.28.0\"},{\"kind\":\"dev\",\"name\":\"tokio-tungstenite\",\"req\":\"^0.28.0\"},{\"default_features\":false,\"features\":[\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"util\",\"timeout\",\"limit\",\"load-shed\",\"steer\",\"filter\"],\"kind\":\"dev\",\"name\":\"tower\",\"package\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.2\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"serde\",\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"__private\":[\"tokio\",\"http1\",\"dep:reqwest\"],\"__private_docs\":[\"axum-core/__private_docs\",\"tower/full\",\"dep:serde\",\"dep:tower-http\"],\"default\":[\"form\",\"http1\",\"json\",\"matched-path\",\"original-uri\",\"query\",\"tokio\",\"tower-log\",\"tracing\"],\"form\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"http1\":[\"dep:hyper\",\"hyper?/http1\",\"hyper-util?/http1\"],\"http2\":[\"dep:hyper\",\"hyper?/http2\",\"hyper-util?/http2\"],\"json\":[\"dep:serde_json\",\"dep:serde_path_to_error\"],\"macros\":[\"dep:axum-macros\"],\"matched-path\":[],\"multipart\":[\"dep:multer\"],\"original-uri\":[],\"query\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"tokio\":[\"dep:hyper-util\",\"dep:tokio\",\"tokio/net\",\"tokio/rt\",\"tower/make\",\"tokio/macros\"],\"tower-log\":[\"tower/log\"],\"tracing\":[\"dep:tracing\",\"axum-core/tracing\"],\"ws\":[\"dep:hyper\",\"tokio\",\"dep:tokio-tungstenite\",\"dep:sha1\",\"dep:base64\"]}}", - "axum_0.8.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"axum-core\",\"req\":\"^0.5.5\"},{\"name\":\"axum-macros\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.1.0\"},{\"features\":[\"client\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\"},{\"features\":[\"tokio\",\"server\",\"service\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"matchit\",\"req\":\"=0.8.4\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"multer\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.211\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"serde-human-readable\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.44\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"net\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"package\":\"tokio\",\"req\":\"^1.44.2\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.29.0\"},{\"kind\":\"dev\",\"name\":\"tokio-tungstenite\",\"req\":\"^0.29.0\"},{\"default_features\":false,\"features\":[\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"util\",\"timeout\",\"limit\",\"load-shed\",\"steer\",\"filter\"],\"kind\":\"dev\",\"name\":\"tower\",\"package\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.8\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.8\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.2\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"serde\",\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"__private\":[\"tokio\",\"http1\",\"dep:reqwest\"],\"__private_docs\":[\"axum-core/__private_docs\",\"tower/full\",\"dep:serde\",\"dep:tower-http\"],\"default\":[\"form\",\"http1\",\"json\",\"matched-path\",\"original-uri\",\"query\",\"tokio\",\"tower-log\",\"tracing\"],\"form\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"http1\":[\"dep:hyper\",\"hyper?/http1\",\"hyper-util?/http1\"],\"http2\":[\"dep:hyper\",\"hyper?/http2\",\"hyper-util?/http2\"],\"json\":[\"dep:serde_json\",\"dep:serde_path_to_error\"],\"macros\":[\"dep:axum-macros\"],\"matched-path\":[],\"multipart\":[\"dep:multer\"],\"original-uri\":[],\"query\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"tokio\":[\"dep:hyper-util\",\"dep:tokio\",\"tokio/net\",\"tokio/rt\",\"tower/make\",\"tokio/macros\"],\"tower-log\":[\"tower/log\"],\"tracing\":[\"dep:tracing\",\"axum-core/tracing\"],\"ws\":[\"dep:hyper\",\"tokio\",\"dep:tokio-tungstenite\",\"dep:sha1\",\"dep:base64\"]}}", - "backoff_0.4.0": "{\"dependencies\":[{\"name\":\"async_std_1\",\"optional\":true,\"package\":\"async-std\",\"req\":\"^1.9\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async_std_1\",\"package\":\"async-std\",\"req\":\"^1.6\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.8\"},{\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3\"},{\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"name\":\"instant\",\"req\":\"^0.1\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.7\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"features\":[\"json\",\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"features\":[\"time\"],\"name\":\"tokio_1\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"macros\",\"time\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio_1\",\"package\":\"tokio\",\"req\":\"^1.0\"}],\"features\":{\"async-std\":[\"futures\",\"async_std_1\"],\"default\":[],\"futures\":[\"futures-core\",\"pin-project-lite\"],\"tokio\":[\"futures\",\"tokio_1\"],\"wasm-bindgen\":[\"instant/wasm-bindgen\",\"getrandom/js\"]}}", - "backtrace-ext_0.2.1": "{\"dependencies\":[{\"name\":\"backtrace\",\"req\":\"^0.3.61\"},{\"features\":[\"fancy\"],\"kind\":\"dev\",\"name\":\"miette\",\"req\":\"^5.6.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.40\"}],\"features\":{}}", - "backtrace_0.3.76": "{\"dependencies\":[{\"default_features\":false,\"name\":\"addr2line\",\"req\":\"^0.25.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"cpp_demangle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.156\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libloading\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"miniz_oxide\",\"req\":\"^0.8\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"default_features\":false,\"features\":[\"read_core\",\"elf\",\"macho\",\"pe\",\"xcoff\",\"unaligned\",\"archive\"],\"name\":\"object\",\"req\":\"^0.37.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"rustc-demangle\",\"req\":\"^0.1.24\"},{\"default_features\":false,\"name\":\"ruzstd\",\"optional\":true,\"req\":\"^0.8.1\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(any(windows, target_os = \\\"cygwin\\\"))\"}],\"features\":{\"coresymbolication\":[],\"dbghelp\":[],\"default\":[\"std\"],\"dl_iterate_phdr\":[],\"dladdr\":[],\"kernel32\":[],\"libunwind\":[],\"ruzstd\":[\"dep:ruzstd\"],\"serialize-serde\":[\"serde\"],\"std\":[],\"unix-backtrace\":[]}}", - "base16ct_1.0.0": "{\"dependencies\":[],\"features\":{\"alloc\":[]}}", - "base64-simd_0.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.20.0\"},{\"kind\":\"dev\",\"name\":\"const-str\",\"req\":\"^0.5.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.8\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"outref\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"name\":\"vsimd\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.33\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"alloc\":[\"vsimd/alloc\"],\"default\":[\"std\",\"detect\"],\"detect\":[\"vsimd/detect\"],\"std\":[\"alloc\",\"vsimd/std\"],\"unstable\":[\"vsimd/unstable\"]}}", - "base64_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"=0.3.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.6.1\"},{\"kind\":\"dev\",\"name\":\"structopt\",\"req\":\"^0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", - "base64_0.21.7": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.2.25\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.25\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "base64_0.22.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.2.25\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.25\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "base64ct_1.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", - "bcrypt-pbkdf_0.11.0": "{\"dependencies\":[{\"features\":[\"bcrypt\"],\"name\":\"blowfish\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"pbkdf2\",\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"zeroize\":[\"dep:zeroize\"]}}", - "bindgen_0.72.1": "{\"dependencies\":[{\"name\":\"annotate-snippets\",\"optional\":true,\"req\":\"^0.11.4\"},{\"name\":\"bitflags\",\"req\":\"^2.2.1\"},{\"name\":\"cexpr\",\"req\":\"^0.6\"},{\"features\":[\"clang_11_0\"],\"name\":\"clang-sys\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4\"},{\"name\":\"clap_complete\",\"optional\":true,\"req\":\"^4\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\">=0.10, <0.14\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"verbatim\"],\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.7\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-perl\"],\"name\":\"regex\",\"req\":\"^1.5.3\"},{\"name\":\"rustc-hash\",\"req\":\"^2.1.0\"},{\"name\":\"shlex\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"__cli\":[\"dep:clap\",\"dep:clap_complete\"],\"__testing_only_extra_assertions\":[],\"__testing_only_libclang_16\":[],\"__testing_only_libclang_9\":[],\"default\":[\"logging\",\"prettyplease\",\"runtime\"],\"experimental\":[\"dep:annotate-snippets\"],\"logging\":[\"dep:log\"],\"runtime\":[\"clang-sys/runtime\"],\"static\":[\"clang-sys/static\"]}}", - "bitflags_1.3.2": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3\"}],\"features\":{\"default\":[],\"example_generated\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\"]}}", - "bitflags_2.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", - "bitflags_2.11.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", - "blake2_0.11.0-rc.6": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\"],\"reset\":[],\"size_opt\":[],\"zeroize\":[\"digest/zeroize\"]}}", - "block-buffer_0.10.4": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{}}", - "block-buffer_0.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{}}", - "block-padding_0.4.2": "{\"dependencies\":[{\"name\":\"hybrid-array\",\"req\":\"^0.4.3\"}],\"features\":{}}", - "blowfish_0.10.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"byteorder\",\"req\":\"^1.1\"},{\"name\":\"cipher\",\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"}],\"features\":{\"bcrypt\":[],\"zeroize\":[\"cipher/zeroize\"]}}", - "bollard-stubs_1.52.1-rc.29.1.3": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bollard-buildkit-proto\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"clock\",\"serde\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.14\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1\"},{\"features\":[\"formatting\",\"parsing\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"buildkit\":[\"base64\",\"bytes\",\"bollard-buildkit-proto\",\"prost\"]}}", - "bollard_0.20.2": "{\"dependencies\":[{\"name\":\"async-stream\",\"optional\":true,\"req\":\"^0.3.5\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.6.0\"},{\"name\":\"bollard-buildkit-proto\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"bollard-stubs\",\"req\":\"=1.52.1-rc.29.1.3\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"clock\",\"serde\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"home\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"req\":\"^1.3\"},{\"name\":\"hyper-named-pipe\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"http1\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"http1\",\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"hyperlocal\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"num\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openssh\",\"optional\":true,\"req\":\"^0.11.5\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.7\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"termion\",\"req\":\"^4.0\",\"target\":\"cfg(unix)\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"formatting\",\"parsing\",\"serde\",\"serde-well-known\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"time\",\"net\",\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.47\"},{\"features\":[\"fs\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"features\":[\"net\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"handshake\"],\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.28\"},{\"features\":[\"codec\"],\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"features\":[\"io\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"channel\"],\"name\":\"tonic\",\"optional\":true,\"req\":\"^0.14\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"url\",\"req\":\"^2.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"winerror\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"yup-hyper-mock\",\"req\":\"^8.0.0\"}],\"features\":{\"aws-lc-rs\":[\"ssl_providerless\",\"rustls/aws-lc-rs\"],\"buildkit\":[\"buildkit_providerless\",\"ssl\"],\"buildkit_providerless\":[\"num\",\"rand\",\"tokio/fs\",\"tokio-stream\",\"tokio-util/io\",\"tonic\",\"tower-service\",\"ssl_providerless\",\"bollard-stubs/buildkit\",\"bollard-buildkit-proto\",\"dep:async-stream\",\"dep:bitflags\"],\"chrono\":[\"dep:chrono\",\"bollard-stubs/chrono\"],\"default\":[\"http\",\"pipe\"],\"http\":[\"hyper-util\"],\"json_data_content\":[],\"pipe\":[\"hyper-util\",\"hyperlocal\",\"hyper-named-pipe\"],\"ssh\":[\"hyper-util\",\"openssh\",\"tower-service\"],\"ssl\":[\"ssl_providerless\",\"rustls/ring\"],\"ssl_providerless\":[\"home\",\"hyper-rustls\",\"rustls\",\"rustls-native-certs\",\"rustls-pki-types\",\"http\"],\"test_aws_lc_rs\":[\"test_ssl\",\"aws-lc-rs\"],\"test_checkpoint\":[],\"test_http\":[],\"test_macos\":[],\"test_ring\":[\"test_ssl\",\"ssl\"],\"test_ssh\":[\"ssh\"],\"test_sshforward\":[],\"test_ssl\":[\"dep:webpki-roots\",\"ssl_providerless\"],\"test_swarm\":[],\"test_websocket\":[\"websocket\"],\"time\":[\"dep:time\",\"bollard-stubs/time\"],\"webpki\":[\"ssl\",\"dep:webpki-roots\"],\"websocket\":[\"tokio-tungstenite\"]}}", - "brotli-decompressor_4.0.3": "{\"dependencies\":[{\"name\":\"alloc-no-stdlib\",\"req\":\"~2.0\"},{\"name\":\"alloc-stdlib\",\"optional\":true,\"req\":\"~0.2\"}],\"features\":{\"benchmark\":[],\"default\":[\"std\"],\"disable-timer\":[],\"ffi-api\":[],\"pass-through-ffi-panics\":[],\"seccomp\":[],\"std\":[\"alloc-stdlib\"],\"unsafe\":[\"alloc-no-stdlib/unsafe\",\"alloc-stdlib/unsafe\"]}}", - "bstr_1.12.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.7.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"dfa-search\"],\"name\":\"regex-automata\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.85\"},{\"kind\":\"dev\",\"name\":\"ucd-parse\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"unicode-segmentation\",\"req\":\"^1.2.1\"}],\"features\":{\"alloc\":[\"memchr/alloc\",\"serde?/alloc\"],\"default\":[\"std\",\"unicode\"],\"serde\":[\"dep:serde\"],\"std\":[\"alloc\",\"memchr/std\",\"serde?/std\"],\"unicode\":[\"dep:regex-automata\"]}}", - "buf_redux_0.8.4": "{\"dependencies\":[{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"name\":\"safemem\",\"req\":\"^0.3\"},{\"name\":\"slice-deque\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(any(unix, windows))\"}],\"features\":{\"default\":[\"slice-deque\"],\"nightly\":[\"slice-deque/unstable\"]}}", - "bumpalo_3.20.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.8\"},{\"kind\":\"dev\",\"name\":\"blink-alloc\",\"req\":\"=0.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"=1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"=1.10.0\"},{\"kind\":\"dev\",\"name\":\"rayon-core\",\"req\":\"=1.12.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.171\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.197\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.115\"}],\"features\":{\"allocator_api\":[],\"bench_allocator_api\":[\"allocator_api\",\"blink-alloc/nightly\"],\"boxed\":[],\"collections\":[],\"default\":[],\"serde\":[\"dep:serde\"],\"std\":[]}}", - "byteorder_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"i128\":[],\"std\":[]}}", - "bytes-utils_0.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.144\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde\",\"bytes/serde\"],\"std\":[\"bytes/default\"]}}", - "bytes_1.11.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"extra-platforms\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "bzip2_0.6.1": "{\"dependencies\":[{\"name\":\"bzip2-sys\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"features\":[\"rust-allocator\"],\"name\":\"libbz2-rs-sys\",\"optional\":true,\"req\":\"^0.2.1\"},{\"features\":[\"quickcheck1\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5.4\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"bzip2-sys\":[\"dep:bzip2-sys\"],\"default\":[\"dep:libbz2-rs-sys\"],\"static\":[\"bzip2-sys?/static\"]}}", - "camino_1.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.223\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.8\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"proptest1\":[\"dep:proptest\"],\"serde1\":[\"dep:serde_core\"]}}", - "capctl_0.2.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.3\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"sc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "cassowary_0.3.0": "{\"dependencies\":[],\"features\":{}}", - "castaway_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "cbc_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aes\",\"req\":\"^0.9\"},{\"name\":\"cipher\",\"req\":\"^0.5.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"cipher/alloc\"],\"block-padding\":[\"cipher/block-padding\"],\"default\":[\"block-padding\"],\"zeroize\":[\"cipher/zeroize\"]}}", - "cc_1.2.60": "{\"dependencies\":[{\"name\":\"find-msvc-tools\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"jobserver\",\"optional\":true,\"req\":\"^0.1.30\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(unix)\"},{\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"jobserver\":[],\"parallel\":[\"dep:libc\",\"dep:jobserver\"]}}", - "cc_1.2.62": "{\"dependencies\":[{\"name\":\"find-msvc-tools\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"jobserver\",\"optional\":true,\"req\":\"^0.1.30\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(unix)\"},{\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"jobserver\":[],\"parallel\":[\"dep:libc\",\"dep:jobserver\"]}}", - "cesu8_1.1.0": "{\"dependencies\":[],\"features\":{\"unstable\":[]}}", - "cexpr_0.6.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"clang-sys\",\"req\":\">=0.13.0, <0.29.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7\"}],\"features\":{}}", - "cfg-if_1.0.4": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"rustc-dep-of-std\":[\"core\"]}}", - "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", - "chacha20_0.10.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"stream-wrapper\"],\"name\":\"cipher\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8.1\"}],\"features\":{\"default\":[\"cipher\"],\"legacy\":[\"cipher\"],\"rng\":[\"dep:rand_core\"],\"xchacha\":[\"cipher\"]}}", - "chrono_0.4.44": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0.1\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.66\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"defmt\":[\"dep:defmt\",\"pure-rust-locales?/defmt\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}", - "chunked_transfer_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{}}", - "cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}", - "cipher_0.5.2": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"inout\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"getrandom\":[\"common/getrandom\"],\"rand_core\":[\"common/rand_core\"],\"stream-wrapper\":[\"block-buffer\"],\"zeroize\":[\"dep:zeroize\",\"common/zeroize\",\"block-buffer?/zeroize\"]}}", - "clang-sys_1.8.1": "{\"dependencies\":[{\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"build\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.39\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\">=3.0.0, <3.7.0\"}],\"features\":{\"clang_10_0\":[\"clang_9_0\"],\"clang_11_0\":[\"clang_10_0\"],\"clang_12_0\":[\"clang_11_0\"],\"clang_13_0\":[\"clang_12_0\"],\"clang_14_0\":[\"clang_13_0\"],\"clang_15_0\":[\"clang_14_0\"],\"clang_16_0\":[\"clang_15_0\"],\"clang_17_0\":[\"clang_16_0\"],\"clang_18_0\":[\"clang_17_0\"],\"clang_3_5\":[],\"clang_3_6\":[\"clang_3_5\"],\"clang_3_7\":[\"clang_3_6\"],\"clang_3_8\":[\"clang_3_7\"],\"clang_3_9\":[\"clang_3_8\"],\"clang_4_0\":[\"clang_3_9\"],\"clang_5_0\":[\"clang_4_0\"],\"clang_6_0\":[\"clang_5_0\"],\"clang_7_0\":[\"clang_6_0\"],\"clang_8_0\":[\"clang_7_0\"],\"clang_9_0\":[\"clang_8_0\"],\"libcpp\":[],\"runtime\":[\"libloading\"],\"static\":[]}}", - "clap_4.6.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.16\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.6.0\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.6.0\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.23\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.22\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.27\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.116\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^1.1.1\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", - "clap_4.6.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.16\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.6.0\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.6.1\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.23\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.22\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.27\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.116\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^1.2.0\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", - "clap_builder_4.6.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"anstyle\",\"req\":\"^1.0.13\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.76\"},{\"name\":\"clap_lex\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"color-print\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.1\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.9.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.2\"}],\"features\":{\"cargo\":[],\"color\":[\"dep:anstream\"],\"debug\":[\"dep:backtrace\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[],\"env\":[],\"error-context\":[],\"help\":[],\"std\":[\"anstyle/std\"],\"string\":[],\"suggestions\":[\"dep:strsim\",\"error-context\"],\"unicode\":[\"dep:unicode-width\",\"dep:unicase\"],\"unstable-doc\":[\"cargo\",\"wrap_help\",\"env\",\"unicode\",\"string\",\"unstable-ext\"],\"unstable-ext\":[],\"unstable-styles\":[\"color\"],\"unstable-v5\":[\"deprecated\"],\"usage\":[],\"wrap_help\":[\"help\",\"dep:terminal_size\"]}}", - "clap_complete_4.6.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.16\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"default_features\":false,\"features\":[\"std\",\"derive\",\"help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"name\":\"clap_lex\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"completest\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"completest-pty\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"is_executable\",\"optional\":true,\"req\":\"^1.0.5\"},{\"name\":\"shlex\",\"optional\":true,\"req\":\"^1.3.0\"},{\"features\":[\"diff\",\"dir\",\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^1.2.0\"}],\"features\":{\"debug\":[\"clap/debug\"],\"default\":[],\"unstable-doc\":[\"unstable-dynamic\"],\"unstable-dynamic\":[\"dep:clap_lex\",\"dep:shlex\",\"dep:is_executable\",\"clap/unstable-ext\"],\"unstable-shell-tests\":[\"dep:completest\",\"dep:completest-pty\"]}}", - "clap_derive_4.6.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.13\"},{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.1\"},{\"name\":\"quote\",\"req\":\"^1.0.45\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.117\"}],\"features\":{\"debug\":[],\"default\":[],\"deprecated\":[],\"raw-deprecated\":[\"deprecated\"],\"unstable-markdown\":[\"dep:pulldown-cmark\",\"dep:anstyle\"],\"unstable-v5\":[\"deprecated\"]}}", - "clap_derive_4.6.1": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.14\"},{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.3\"},{\"name\":\"quote\",\"req\":\"^1.0.45\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.117\"}],\"features\":{\"debug\":[],\"default\":[],\"deprecated\":[],\"raw-deprecated\":[\"deprecated\"],\"unstable-markdown\":[\"dep:pulldown-cmark\",\"dep:anstyle\"],\"unstable-v5\":[\"deprecated\"]}}", - "clap_lex_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.16\"}],\"features\":{}}", - "cmake_0.1.58": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", - "cmov_0.5.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\",\"target\":\"cfg(any(unix, windows))\"}],\"features\":{}}", - "colorchoice_1.0.5": "{\"dependencies\":[],\"features\":{}}", - "combine_4.6.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"bytes_05\",\"optional\":true,\"package\":\"bytes\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"bytes_05\",\"package\":\"bytes\",\"req\":\"^0.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-03-dep\",\"package\":\"futures\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-core-03\",\"optional\":true,\"package\":\"futures-core\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-io-03\",\"optional\":true,\"package\":\"futures-io\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.0\"},{\"features\":[\"tokio\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.3\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quick-error\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.6\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio-02-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.2.3\"},{\"features\":[\"fs\",\"io-driver\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio-02-dep\",\"package\":\"tokio\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"tokio-03-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.3\"},{\"features\":[\"fs\",\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio-03-dep\",\"package\":\"tokio\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tokio-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"rt\",\"rt-multi-thread\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio-dep\",\"package\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"codec\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"futures-03\":[\"pin-project\",\"std\",\"futures-core-03\",\"futures-io-03\",\"pin-project-lite\"],\"mp4\":[],\"pin-project\":[\"pin-project-lite\"],\"std\":[\"memchr/std\",\"bytes\",\"alloc\"],\"tokio\":[\"tokio-dep\",\"tokio-util/io\",\"futures-core-03\",\"pin-project-lite\"],\"tokio-02\":[\"pin-project\",\"std\",\"tokio-02-dep\",\"futures-core-03\",\"pin-project-lite\",\"bytes_05\"],\"tokio-03\":[\"pin-project\",\"std\",\"tokio-03-dep\",\"futures-core-03\",\"pin-project-lite\"]}}", - "compact_str_0.7.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"castaway\",\"req\":\"^0.2\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"1.0.*\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.6.0\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.2\"}],\"features\":{}}", - "concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}", - "const-oid_0.10.2": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"}],\"features\":{\"db\":[]}}", - "const-oid_0.9.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"}],\"features\":{\"db\":[],\"std\":[]}}", - "const_format_0.2.36": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.0\"},{\"name\":\"const_format_proc_macros\",\"req\":\"=0.2.34\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^1.3.5\"},{\"default_features\":false,\"features\":[\"rust_1_64\"],\"name\":\"konst\",\"req\":\"^0.2.20\"}],\"features\":{\"__debug\":[\"const_format_proc_macros/debug\"],\"__docsrs\":[],\"__inline_const_pat_tests\":[\"__test\",\"fmt\"],\"__only_new_tests\":[\"__test\"],\"__test\":[],\"all\":[\"fmt\",\"derive\",\"rust_1_64\",\"assert\"],\"assert\":[\"assertc\"],\"assertc\":[\"fmt\",\"assertcp\"],\"assertcp\":[\"rust_1_51\"],\"const_generics\":[\"rust_1_51\"],\"constant_time_as_str\":[\"fmt\"],\"default\":[],\"derive\":[\"fmt\",\"const_format_proc_macros/derive\"],\"fmt\":[\"rust_1_83\"],\"more_str_macros\":[\"rust_1_64\"],\"nightly_const_generics\":[\"const_generics\"],\"rust_1_51\":[],\"rust_1_64\":[\"rust_1_51\"],\"rust_1_83\":[\"rust_1_64\"]}}", - "const_format_proc_macros_0.2.34": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^1.3.4\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.19\"},{\"name\":\"quote\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^1.0.38\"},{\"name\":\"unicode-xid\",\"req\":\"^0.2\"}],\"features\":{\"all\":[\"derive\"],\"debug\":[\"syn/extra-traits\"],\"default\":[],\"derive\":[\"syn\",\"syn/derive\",\"syn/printing\"]}}", - "constant_time_eq_0.4.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"count_instructions\",\"req\":\"^0.2.0\"},{\"features\":[\"cargo_bench_support\",\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"}],\"features\":{\"count_instructions_test\":[],\"default\":[\"std\"],\"std\":[]}}", - "core-foundation-sys_0.8.7": "{\"dependencies\":[],\"features\":{\"default\":[\"link\"],\"link\":[],\"mac_os_10_7_support\":[],\"mac_os_10_8_features\":[]}}", - "core-foundation_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"link\"],\"link\":[\"core-foundation-sys/link\"],\"mac_os_10_7_support\":[\"core-foundation-sys/mac_os_10_7_support\"],\"mac_os_10_8_features\":[\"core-foundation-sys/mac_os_10_8_features\"],\"with-uuid\":[\"dep:uuid\"]}}", - "countme_3.0.1": "{\"dependencies\":[{\"name\":\"dashmap\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.5\"},{\"name\":\"rustc-hash\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"enable\":[\"dashmap\",\"once_cell\",\"rustc-hash\"],\"print_at_exit\":[\"enable\"]}}", - "cpubits_0.1.1": "{\"dependencies\":[],\"features\":{}}", - "cpufeatures_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"aarch64-linux-android\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", - "cpufeatures_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"android\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", - "crc-catalog_2.4.0": "{\"dependencies\":[],\"features\":{}}", - "crc32fast_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", - "crc_3.4.0": "{\"dependencies\":[{\"name\":\"crc-catalog\",\"req\":\"^2.4.0\"}],\"features\":{}}", - "crossbeam-channel_0.5.15": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-utils/std\"]}}", - "crossbeam-deque_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-epoch\",\"req\":\"^0.9.17\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-epoch/std\",\"crossbeam-utils/std\"]}}", - "crossbeam-epoch_0.9.18": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}", - "crossbeam-queue_0.3.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}", - "crossbeam-utils_0.8.21": "{\"dependencies\":[{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", - "crossterm_0.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.12\"},{\"name\":\"bitflags\",\"req\":\"^2.3\"},{\"name\":\"crossterm_winapi\",\"optional\":true,\"req\":\"^0.9.1\",\"target\":\"cfg(windows)\"},{\"name\":\"filedescriptor\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-timer\",\"req\":\"^3.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.3.17\",\"target\":\"cfg(unix)\"},{\"features\":[\"support-v0_8\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"req\":\"^0.2.3\",\"target\":\"cfg(unix)\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25\"},{\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]}}", - "crossterm_0.28.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.12\"},{\"name\":\"bitflags\",\"req\":\"^2.3\"},{\"name\":\"crossterm_winapi\",\"optional\":true,\"req\":\"^0.9.1\",\"target\":\"cfg(windows)\"},{\"name\":\"filedescriptor\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-timer\",\"req\":\"^3.0\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"req\":\"^0.38.34\",\"target\":\"cfg(unix)\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.3.17\",\"target\":\"cfg(unix)\"},{\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"req\":\"^0.2.4\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"temp-env\",\"req\":\"^0.3.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25\"},{\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]}}", - "crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "crypto-bigint_0.7.5": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rng\"],\"kind\":\"dev\",\"name\":\"chacha20\",\"req\":\"^0.10\"},{\"name\":\"cpubits\",\"req\":\"^0.1\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\",\"target\":\"cfg(any(unix, windows))\"},{\"name\":\"ctutils\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"der\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"sys_rng\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hybrid-array\",\"optional\":true,\"req\":\"^0.4.12\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"features\":[\"num-bigint\",\"num-integer\",\"num-traits\"],\"kind\":\"dev\",\"name\":\"num-modular\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\",\"target\":\"cfg(any(unix, windows))\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"rlp\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serdect?/alloc\"],\"default\":[\"rand_core\"],\"der\":[\"dep:der\",\"hybrid-array\"],\"extra-sizes\":[],\"getrandom\":[\"dep:getrandom\",\"rand_core\"],\"rand_core\":[\"dep:rand_core\"],\"serde\":[\"dep:serdect\"],\"subtle\":[\"dep:subtle\",\"ctutils/subtle\",\"hybrid-array?/subtle\"]}}", - "crypto-common_0.1.7": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"=0.14.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", - "crypto-common_0.2.2": "{\"dependencies\":[{\"features\":[\"sys_rng\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"getrandom\":[\"rand_core\",\"dep:getrandom\"],\"rand_core\":[\"dep:rand_core\"],\"zeroize\":[\"hybrid-array/zeroize\"]}}", - "crypto-primes_0.7.2": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"crypto-bigint\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"crypto-bigint\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"float-cmp\",\"req\":\"^0.10\"},{\"name\":\"glass_pumpkin\",\"optional\":true,\"req\":\"^2.0.0-rc.0\"},{\"kind\":\"dev\",\"name\":\"libm\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"features\":[\"num-bigint\"],\"kind\":\"dev\",\"name\":\"num-modular\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"num-prime\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.16\"},{\"features\":[\"vendored\"],\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.39\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"chacha\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"rayon\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"integer\"],\"name\":\"rug\",\"optional\":true,\"req\":\"^1.26\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"multicore\":[\"rayon\"],\"tests-all\":[\"tests-openssl\",\"tests-gmp\",\"tests-exhaustive\",\"tests-glass-pumpkin\"],\"tests-exhaustive\":[],\"tests-glass-pumpkin\":[\"glass_pumpkin\"],\"tests-gmp\":[\"rug/std\"],\"tests-openssl\":[\"openssl\"]}}", - "ctr_0.10.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aes\",\"req\":\"^0.9\"},{\"features\":[\"stream-wrapper\"],\"name\":\"cipher\",\"req\":\"^0.5.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"kuznyechik\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"magma\",\"req\":\"^0.10\"}],\"features\":{\"alloc\":[\"cipher/alloc\"],\"block-padding\":[\"cipher/block-padding\"],\"zeroize\":[\"cipher/zeroize\"]}}", - "ctutils_0.4.2": "{\"dependencies\":[{\"name\":\"cmov\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"alloc\":[],\"subtle\":[\"dep:subtle\"]}}", - "curve25519-dalek-derive_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.66\"},{\"name\":\"quote\",\"req\":\"^1.0.31\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.27\"}],\"features\":{}}", - "curve25519-dalek_5.0.0-rc.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"curve25519-dalek-derive\",\"req\":\"^0.1\",\"target\":\"cfg(all(not(curve25519_dalek_backend = \\\"fiat\\\"), not(curve25519_dalek_backend = \\\"serial\\\"), target_arch = \\\"x86_64\\\"))\"},{\"default_features\":false,\"features\":[\"block-api\"],\"name\":\"digest\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.3.0\",\"target\":\"cfg(curve25519_dalek_backend = \\\"fiat\\\")\"},{\"features\":[\"sys_rng\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"const-generics\"],\"name\":\"subtle\",\"req\":\"^2.6.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"digest\":[\"dep:digest\"],\"legacy_compatibility\":[],\"lizard\":[\"digest\"],\"precomputed-tables\":[]}}", - "darling_0.20.11": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.20.11\"},{\"name\":\"darling_macro\",\"req\":\"=0.20.11\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"suggestions\":[\"darling_core/suggestions\"]}}", - "darling_core_0.20.11": "{\"dependencies\":[{\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"name\":\"ident_case\",\"req\":\"^1.0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{\"diagnostics\":[],\"suggestions\":[\"strsim\"]}}", - "darling_macro_0.20.11": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.20.11\"},{\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{}}", - "data-encoding_2.10.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "deadpool-runtime_0.1.4": "{\"dependencies\":[{\"features\":[\"unstable\"],\"name\":\"async-std_1\",\"optional\":true,\"package\":\"async-std\",\"req\":\"^1.0\"},{\"features\":[\"time\",\"rt\"],\"name\":\"tokio_1\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.0\"}],\"features\":{}}", - "deadpool_0.12.3": "{\"dependencies\":[{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.0\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"config\",\"req\":\"^0.15\"},{\"features\":[\"html_reports\",\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"deadpool-runtime\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"name\":\"num_cpus\",\"req\":\"^1.11.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.5\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.5.0\"}],\"features\":{\"default\":[\"managed\",\"unmanaged\"],\"managed\":[],\"rt_async-std_1\":[\"deadpool-runtime/async-std_1\"],\"rt_tokio_1\":[\"deadpool-runtime/tokio_1\"],\"unmanaged\":[]}}", - "deflate64_0.1.12": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"checkpoint\":[],\"default\":[]}}", - "delegate_0.13.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1.50\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"macrotest\",\"req\":\"^1.0.12\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"}],\"features\":{}}", - "der-parser_9.0.0": "{\"dependencies\":[{\"name\":\"asn1-rs\",\"req\":\"^0.6\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3.0\"}],\"features\":{\"bigint\":[\"num-bigint\"],\"default\":[\"std\"],\"serialize\":[\"std\",\"cookie-factory\"],\"std\":[],\"unstable\":[]}}", - "der_0.7.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9.2\"},{\"name\":\"der_derive\",\"optional\":true,\"req\":\"^0.7.2\"},{\"name\":\"flagset\",\"optional\":true,\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"features\":[\"alloc\"],\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"arbitrary\":[\"dep:arbitrary\",\"const-oid?/arbitrary\",\"std\"],\"bytes\":[\"dep:bytes\",\"alloc\"],\"derive\":[\"dep:der_derive\"],\"oid\":[\"dep:const-oid\"],\"pem\":[\"dep:pem-rfc7468\",\"alloc\",\"zeroize\"],\"real\":[],\"std\":[\"alloc\"]}}", - "der_0.8.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4\"},{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"der_derive\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"flagset\",\"optional\":true,\"req\":\"^0.4.7\"},{\"default_features\":false,\"name\":\"heapless\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.10\",\"target\":\"cfg(any(unix, windows))\"},{\"default_features\":false,\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"arbitrary\":[\"dep:arbitrary\",\"const-oid?/arbitrary\",\"std\"],\"ber\":[],\"bytes\":[\"dep:bytes\",\"alloc\"],\"derive\":[\"dep:der_derive\"],\"oid\":[\"dep:const-oid\"],\"pem\":[\"dep:pem-rfc7468\",\"alloc\",\"zeroize\"],\"real\":[],\"std\":[\"alloc\"]}}", - "deranged_0.5.8": "{\"dependencies\":[{\"name\":\"deranged-macros\",\"optional\":true,\"req\":\"=0.3.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"powerfmt\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"rand010\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"rand010\",\"package\":\"rand\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.86\"}],\"features\":{\"alloc\":[],\"default\":[],\"macros\":[\"dep:deranged-macros\"],\"num\":[\"dep:num-traits\"],\"powerfmt\":[\"dep:powerfmt\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\"],\"rand\":[\"rand08\",\"rand09\",\"rand010\"],\"rand010\":[\"dep:rand010\"],\"rand08\":[\"dep:rand08\"],\"rand09\":[\"dep:rand09\"],\"serde\":[\"dep:serde_core\"]}}", - "derivative_2.2.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18, < 1.0.23\"}],\"features\":{\"use_core\":[]}}", - "derive_builder_0.20.2": "{\"dependencies\":[{\"name\":\"derive_builder_macro\",\"req\":\"=0.20.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.38\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"alloc\":[\"derive_builder_macro/alloc\"],\"clippy\":[\"derive_builder_macro/clippy\"],\"default\":[\"std\"],\"std\":[\"derive_builder_macro/lib_has_std\"]}}", - "derive_builder_core_0.20.2": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.10\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.37\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{\"alloc\":[],\"clippy\":[],\"lib_has_std\":[]}}", - "derive_builder_macro_0.20.2": "{\"dependencies\":[{\"name\":\"derive_builder_core\",\"req\":\"=0.20.2\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{\"alloc\":[\"derive_builder_core/alloc\"],\"clippy\":[\"derive_builder_core/clippy\"],\"lib_has_std\":[\"derive_builder_core/lib_has_std\"]}}", - "des_0.9.0": "{\"dependencies\":[{\"name\":\"cipher\",\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"zeroize\":[\"cipher/zeroize\"]}}", - "dialoguer_0.11.0": "{\"dependencies\":[{\"name\":\"console\",\"req\":\"^0.15.0\"},{\"name\":\"fuzzy-matcher\",\"optional\":true,\"req\":\"^0.3.7\"},{\"name\":\"shell-words\",\"req\":\"^1.1.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"thiserror\",\"req\":\"^1.0.40\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.1.1\"}],\"features\":{\"completion\":[],\"default\":[\"editor\",\"password\"],\"editor\":[\"tempfile\"],\"fuzzy-select\":[\"fuzzy-matcher\"],\"history\":[],\"password\":[\"zeroize\"]}}", - "digest_0.10.7": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.4\"}],\"features\":{\"alloc\":[],\"core-api\":[\"block-buffer\"],\"default\":[\"core-api\"],\"dev\":[\"blobby\"],\"mac\":[\"subtle\"],\"oid\":[\"const-oid\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\"]}}", - "digest_0.11.2": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11.0-rc.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7\"}],\"features\":{\"alloc\":[],\"block-api\":[\"dep:block-buffer\"],\"default\":[\"block-api\"],\"dev\":[\"blobby\"],\"getrandom\":[\"common/getrandom\",\"rand_core\"],\"mac\":[\"dep:ctutils\"],\"oid\":[\"dep:const-oid\"],\"rand_core\":[\"common/rand_core\"],\"zeroize\":[\"dep:zeroize\",\"block-buffer?/zeroize\"]}}", - "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", - "dunce_1.0.5": "{\"dependencies\":[],\"features\":{}}", - "dyn-clone_1.0.20": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.66\"}],\"features\":{}}", - "ecdsa_0.17.0-rc.18": "{\"dependencies\":[{\"name\":\"der\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"digest\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.14.0-rc.32\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"elliptic-curve\",\"req\":\"^0.14.0-rc.32\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"rfc6979\",\"optional\":true,\"req\":\"^0.5.0-rc.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"signature\",\"req\":\"^3.0.0-rc.10\"},{\"default_features\":false,\"name\":\"spki\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"algorithm\":[\"dep:rfc6979\",\"digest\",\"elliptic-curve/arithmetic\",\"hazmat\"],\"alloc\":[\"elliptic-curve/alloc\",\"signature/alloc\",\"spki/alloc\"],\"default\":[\"digest\"],\"der\":[\"dep:der\"],\"dev\":[\"algorithm\",\"digest/dev\",\"elliptic-curve/dev\"],\"digest\":[\"dep:digest\",\"elliptic-curve/digest\",\"signature/digest\"],\"getrandom\":[\"elliptic-curve/getrandom\"],\"hazmat\":[],\"pem\":[\"elliptic-curve/pem\",\"pkcs8\"],\"pkcs8\":[\"der\",\"digest\",\"elliptic-curve/pkcs8\"],\"serde\":[\"dep:serdect\",\"elliptic-curve/serde\",\"pkcs8\"],\"std\":[\"alloc\",\"elliptic-curve/std\"]}}", - "ed25519-dalek_3.0.0-rc.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blake2\",\"req\":\"^0.11.0-rc.6\"},{\"default_features\":false,\"features\":[\"rng\"],\"kind\":\"dev\",\"name\":\"chacha20\",\"req\":\"^0.10\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"digest\"],\"name\":\"curve25519-dalek\",\"req\":\"^5.0.0-rc.0\"},{\"default_features\":false,\"features\":[\"digest\",\"rand_core\"],\"kind\":\"dev\",\"name\":\"curve25519-dalek\",\"req\":\"^5.0.0-rc.0\"},{\"default_features\":false,\"name\":\"ed25519\",\"req\":\"^3\"},{\"features\":[\"sys_rng\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"keccak\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"signature\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"strobe-rs\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"static_secrets\"],\"kind\":\"dev\",\"name\":\"x25519-dalek\",\"req\":\"^3.0.0-rc.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"curve25519-dalek/alloc\",\"ed25519/alloc\",\"signature/alloc\",\"serde?/alloc\",\"zeroize?/alloc\"],\"batch\":[\"alloc\",\"dep:keccak\",\"rand_core\",\"strobe-rs\"],\"default\":[\"fast\",\"zeroize\"],\"digest\":[\"signature/digest\"],\"fast\":[\"curve25519-dalek/precomputed-tables\"],\"hazmat\":[],\"legacy_compatibility\":[\"curve25519-dalek/legacy_compatibility\"],\"pem\":[\"alloc\",\"ed25519/pem\",\"pkcs8\"],\"pkcs8\":[\"ed25519/pkcs8\"],\"rand_core\":[\"dep:rand_core\"],\"serde\":[\"dep:serde\",\"ed25519/serde\"],\"zeroize\":[\"dep:zeroize\",\"curve25519-dalek/zeroize\"]}}", - "ed25519_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"signature\",\"req\":\"^3\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"pkcs8?/alloc\",\"signature/alloc\"],\"default\":[\"alloc\"],\"pem\":[\"alloc\",\"pkcs8/pem\"],\"serde\":[\"dep:serdect\"]}}", - "either_1.15.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[],\"use_std\":[\"std\"]}}", - "elliptic-curve_0.14.0-rc.33": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"array\",\"package\":\"hybrid-array\",\"req\":\"^0.4\"},{\"name\":\"base16ct\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"hybrid-array\",\"rand_core\",\"subtle\",\"zeroize\"],\"name\":\"bigint\",\"package\":\"crypto-bigint\",\"req\":\"^0.7\"},{\"features\":[\"rand_core\"],\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"digest\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.14\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"hkdf\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"alloc\"],\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"features\":[\"ctutils\",\"subtle\",\"zeroize\"],\"name\":\"sec1\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.7\"}],\"features\":{\"alloc\":[\"base16ct/alloc\",\"ff?/alloc\",\"group?/alloc\",\"array/alloc\",\"pkcs8?/alloc\",\"sec1?/alloc\",\"zeroize/alloc\"],\"arithmetic\":[\"group\"],\"basepoint-table\":[\"arithmetic\"],\"critical-section\":[\"basepoint-table\",\"once_cell/critical-section\"],\"default\":[\"arithmetic\"],\"dev\":[\"arithmetic\",\"dep:hex-literal\",\"pem\",\"pkcs8\"],\"ecdh\":[\"arithmetic\",\"digest\",\"dep:hkdf\"],\"getrandom\":[\"arithmetic\",\"bigint/getrandom\",\"common/getrandom\"],\"group\":[\"dep:group\",\"ff\"],\"pem\":[\"dep:pem-rfc7468\",\"alloc\",\"arithmetic\",\"pkcs8/pem\",\"sec1/pem\"],\"pkcs8\":[\"dep:pkcs8\",\"sec1\"],\"serde\":[\"dep:serdect\",\"alloc\",\"pkcs8\",\"sec1/serde\"],\"std\":[\"alloc\",\"once_cell?/std\",\"pkcs8?/std\",\"sec1?/std\"]}}", - "encode_unicode_1.0.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"features\":[\"https-native\"],\"kind\":\"dev\",\"name\":\"minreq\",\"req\":\"^2.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "enum_dispatch_0.3.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"custom_derive\",\"req\":\"=0.1.7\"},{\"kind\":\"dev\",\"name\":\"enum_derive\",\"req\":\"=0.1.7\"},{\"name\":\"once_cell\",\"req\":\"^1.0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\">=0.5.5, <=0.6.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"=1.0.136\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"=1.0.78\"},{\"kind\":\"dev\",\"name\":\"smol\",\"req\":\"^1.3.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "enumflags2_0.7.12": "{\"dependencies\":[{\"name\":\"enumflags2_derive\",\"req\":\"=0.7.12\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.0\"}],\"features\":{\"std\":[]}}", - "enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "env_filter_1.0.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.12.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", - "env_logger_0.11.10": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"wincon\"],\"name\":\"anstream\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"env_filter\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2.22\"},{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"}],\"features\":{\"auto-color\":[\"color\",\"anstream/auto\"],\"color\":[\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"auto-color\",\"humantime\",\"regex\"],\"humantime\":[\"dep:jiff\"],\"kv\":[\"log/kv\"],\"regex\":[\"env_filter/regex\"],\"unstable-kv\":[\"kv\"]}}", - "equivalent_1.0.2": "{\"dependencies\":[],\"features\":{}}", - "errno_0.3.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"hermit\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"libc/std\"]}}", - "etcetera_0.8.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"home\",\"req\":\"^0.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.48\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "event-listener_5.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"concurrent-queue\",\"req\":\"^2.4.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.0.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"try-lock\",\"req\":\"^0.2.5\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"concurrent-queue/loom\",\"parking?/loom\",\"dep:loom\"],\"portable-atomic\":[\"portable-atomic-util\",\"portable_atomic_crate\",\"concurrent-queue/portable-atomic\"],\"std\":[\"concurrent-queue/std\",\"parking\"]}}", - "fallible-iterator_0.2.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", - "fastrand_2.3.0": "{\"dependencies\":[{\"features\":[\"js\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wyhash\",\"req\":\"^0.5\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"js\":[\"std\",\"getrandom\"],\"std\":[\"alloc\"]}}", - "fastrand_2.4.1": "{\"dependencies\":[{\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.4\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.4\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.4\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wyhash\",\"req\":\"^0.6\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"js\":[\"std\",\"getrandom\"],\"std\":[\"alloc\"]}}", - "ff_0.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"blake2b_simd\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"ff_derive\",\"optional\":true,\"req\":\"^0.14\"},{\"features\":[\"sys_rng\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"subtle\",\"req\":\"^2.2.1\"}],\"features\":{\"alloc\":[],\"bits\":[\"bitvec\",\"ff_derive?/bits\"],\"default\":[\"bits\",\"std\"],\"derive\":[\"byteorder\",\"ff_derive\"],\"std\":[\"alloc\"]}}", - "fiat-crypto_0.3.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "filetime_0.2.27": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.27\",\"target\":\"cfg(unix)\"},{\"name\":\"libredox\",\"req\":\"^0.1.0\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{}}", - "filetime_0.2.29": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.27\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{}}", - "find-msvc-tools_0.1.9": "{\"dependencies\":[],\"features\":{}}", - "fixedbitset_0.5.7": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "flate2_1.1.9": "{\"dependencies\":[{\"name\":\"cloudflare-zlib-sys\",\"optional\":true,\"req\":\"^0.3.6\"},{\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.2.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"libz-ng-sys\",\"optional\":true,\"req\":\"^1.1.16\"},{\"default_features\":false,\"name\":\"libz-sys\",\"optional\":true,\"req\":\"^1.1.20\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8.5\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"emscripten\\\")))\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"optional\":true,\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\",\"rust-allocator\"],\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.6.0\"}],\"features\":{\"any_c_zlib\":[\"any_zlib\"],\"any_impl\":[],\"any_zlib\":[\"any_impl\"],\"cloudflare_zlib\":[\"any_c_zlib\",\"cloudflare-zlib-sys\",\"dep:crc32fast\"],\"default\":[\"rust_backend\"],\"miniz-sys\":[\"rust_backend\"],\"miniz_oxide\":[\"any_impl\",\"dep:miniz_oxide\",\"dep:crc32fast\"],\"rust_backend\":[\"miniz_oxide\",\"any_impl\"],\"zlib\":[\"any_c_zlib\",\"libz-sys\",\"dep:crc32fast\"],\"zlib-default\":[\"any_c_zlib\",\"libz-sys/default\",\"dep:crc32fast\"],\"zlib-ng\":[\"any_c_zlib\",\"libz-ng-sys\",\"dep:crc32fast\"],\"zlib-ng-compat\":[\"zlib\",\"libz-sys/zlib-ng\",\"dep:crc32fast\"],\"zlib-rs\":[\"any_zlib\",\"dep:zlib-rs\"]}}", - "flume_0.11.1": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"features\":[\"getrandom\"],\"name\":\"nanorand\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"nanorand\"],\"select\":[],\"spin\":[]}}", - "fnv_1.0.7": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "foldhash_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "foldhash_0.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rapidhash\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", - "form_urlencoded_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"}],\"features\":{\"alloc\":[\"percent-encoding/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"percent-encoding/std\"]}}", - "fs_extra_1.3.0": "{\"dependencies\":[],\"features\":{}}", - "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", - "futures-channel_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", - "futures-core_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-executor_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.32\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\"]}}", - "futures-intrusive_0.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"crossbeam\",\"req\":\"^0.7\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"lock_api\",\"req\":\"^0.4.1\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.1.11\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"parking_lot\"]}}", - "futures-io_0.3.32": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", - "futures-macro_0.3.32": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", - "futures-sink_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "futures-task_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-util_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.26\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"default_features\":false,\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.7\"},{\"name\":\"spin\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"slab\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\",\"libc\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\",\"libc\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab/std\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", - "futures_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-executor\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-io\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"futures-sink/alloc\",\"futures-channel/alloc\",\"futures-util/alloc\"],\"async-await\":[\"futures-util/async-await\",\"futures-util/async-await-macro\"],\"bilock\":[\"futures-util/bilock\"],\"cfg-target-has-atomic\":[],\"compat\":[\"std\",\"futures-util/compat\"],\"default\":[\"std\",\"async-await\",\"executor\"],\"executor\":[\"std\",\"futures-executor/std\"],\"io-compat\":[\"compat\",\"futures-util/io-compat\"],\"spin\":[\"futures-util/spin\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"futures-io/std\",\"futures-sink/std\",\"futures-util/std\",\"futures-util/io\",\"futures-util/channel\"],\"thread-pool\":[\"executor\",\"futures-executor/thread-pool\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\",\"futures-channel/unstable\",\"futures-io/unstable\",\"futures-util/unstable\"],\"write-all-vectored\":[\"futures-util/write-all-vectored\"]}}", - "generic-array_0.14.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"typenum\",\"req\":\"^1.12\"},{\"kind\":\"build\",\"name\":\"version_check\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"more_lengths\":[]}}", - "generic-array_1.3.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"const-default\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"faster-hex\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"generic_array-0_14\",\"optional\":true,\"package\":\"generic-array\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"hybrid-array-0_4\",\"optional\":true,\"package\":\"hybrid-array\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const-generics\"],\"name\":\"typenum\",\"req\":\"^1.19\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"compat-0_14\":[\"dep:generic_array-0_14\"],\"internals\":[],\"serde\":[\"dep:serde_core\"]}}", - "getrandom_0.2.17": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.18\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"}],\"features\":{\"custom\":[],\"js\":[\"wasm-bindgen\",\"js-sys\"],\"linux_disable_fallback\":[],\"rdrand\":[],\"rustc-dep-of-std\":[\"compiler_builtins\",\"core\",\"libc/rustc-dep-of-std\",\"wasi/rustc-dep-of-std\"],\"std\":[],\"test-in-browser\":[]}}", - "getrandom_0.3.4": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^5.1\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"default_features\":false,\"name\":\"wasip2\",\"req\":\"^1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"std\":[],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", - "getrandom_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^6\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"wasip2\",\"req\":\"^1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"name\":\"wasip3\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p3\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"std\":[],\"sys_rng\":[\"dep:rand_core\"],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", - "getset_0.1.6": "{\"dependencies\":[{\"name\":\"proc-macro-error2\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "ghash_0.6.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"features\":[\"hazmat\"],\"name\":\"polyval\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"zeroize\":[\"polyval/zeroize\",\"dep:zeroize\"]}}", - "gimli_0.26.2": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"crossbeam\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"memmap2\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1\"},{\"features\":[\"wasm\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.29.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"typed-arena\",\"req\":\"^2\"}],\"features\":{\"default\":[\"read\",\"write\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"endian-reader\":[\"read\",\"stable_deref_trait\"],\"read\":[\"read-core\"],\"read-core\":[],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"compiler_builtins\"],\"std\":[\"fallible-iterator/std\",\"stable_deref_trait/std\"],\"write\":[\"indexmap\"]}}", - "gimli_0.32.3": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", - "glob_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3\"}],\"features\":{}}", - "globset_0.4.18": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", - "goblin_0.10.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"plain\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"scroll\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"stderrlog\",\"req\":\"^0.6.0\"}],\"features\":{\"alloc\":[\"scroll/derive\",\"log\"],\"archive\":[\"alloc\"],\"default\":[\"std\",\"elf32\",\"elf64\",\"mach32\",\"mach64\",\"pe32\",\"pe64\",\"te\",\"archive\",\"endian_fd\"],\"elf32\":[],\"elf64\":[],\"endian_fd\":[\"alloc\"],\"mach32\":[\"alloc\",\"endian_fd\",\"archive\"],\"mach64\":[\"alloc\",\"endian_fd\",\"archive\"],\"pe32\":[\"alloc\",\"endian_fd\"],\"pe64\":[\"alloc\",\"endian_fd\"],\"std\":[\"alloc\",\"scroll/std\"],\"te\":[\"alloc\",\"endian_fd\"]}}", - "group_0.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ff\",\"req\":\"^0.14\"},{\"name\":\"memuse\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"name\":\"rand_xorshift\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.2.1\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"tests\":[\"alloc\",\"rand\",\"rand_xorshift\"],\"wnaf-memuse\":[\"alloc\",\"memuse\"]}}", - "h2_0.3.27": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.24\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.25\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", - "h2_0.4.13": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", - "hashbrown_0.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"bumpalo\",\"optional\":true,\"req\":\"^3.5.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"ahash-compile-time-rng\":[\"ahash/compile-time-rng\"],\"default\":[\"ahash\",\"inline-more\"],\"inline-more\":[],\"nightly\":[],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", - "hashbrown_0.14.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8.7\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.42\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7.42\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"ahash\",\"inline-more\",\"allocator-api2\"],\"inline-more\":[],\"nightly\":[\"allocator-api2?/nightly\",\"bumpalo/allocator_api\"],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", - "hashbrown_0.15.5": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", - "hashbrown_0.16.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", - "hashbrown_0.17.0": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", - "hashbrown_0.17.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", - "hashlink_0.10.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", - "heck_0.5.0": "{\"dependencies\":[],\"features\":{}}", - "hermit-abi_0.5.2": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"rustc-dep-of-std\":[\"core\",\"alloc\"]}}", - "hex-literal_1.1.0": "{\"dependencies\":[],\"features\":{}}", - "hex_0.4.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "hkdf_0.12.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"std\":[\"hmac/std\"]}}", - "hkdf_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hmac\",\"req\":\"^0.13\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"}],\"features\":{}}", - "hmac_0.12.1": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha-1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"reset\":[],\"std\":[\"digest/std\"]}}", - "hmac_0.13.0": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.11\"}],\"features\":{\"zeroize\":[\"digest/zeroize\"]}}", - "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "http-auth_0.1.10": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^0.2.5\"},{\"name\":\"http10\",\"optional\":true,\"package\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"md-5\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.4\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"basic-scheme\":[\"base64\"],\"default\":[\"basic-scheme\",\"digest-scheme\"],\"digest-scheme\":[\"digest\",\"hex\",\"md-5\",\"rand\",\"sha2\"],\"trace\":[\"log\"]}}", - "http-body-util_0.1.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"sync\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"channel\":[\"dep:tokio\"],\"default\":[],\"full\":[\"channel\"]}}", - "http-body_0.4.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{}}", - "http-body_1.0.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"}],\"features\":{}}", - "http_0.2.12": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"<=1.8\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^3.0.5\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", - "http_1.4.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "httparse_1.10.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "httpdate_1.0.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"}],\"features\":{}}", - "hybrid-array_0.4.14": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"const-generics\"],\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"const-generics\"],\"name\":\"typenum\",\"req\":\"^1.20\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"}],\"features\":{\"alloc\":[],\"extra-sizes\":[]}}", - "hyper-named-pipe_0.1.0": "{\"dependencies\":[{\"name\":\"hex\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"client\",\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"http1\",\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.8\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1\"},{\"features\":[\"net\"],\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"features\":[\"winerror\"],\"name\":\"winapi\",\"req\":\"^0.3.9\"}],\"features\":{}}", - "hyper-rustls_0.24.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"client\"],\"name\":\"hyper\",\"req\":\"^0.14\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.21.6\"},{\"default_features\":false,\"features\":[\"tls12\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.21.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^1.0.0\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"req\":\"^0.24.0\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.25\"}],\"features\":{\"acceptor\":[\"hyper/server\",\"tokio-runtime\"],\"default\":[\"native-tokio\",\"http1\",\"tls12\",\"logging\",\"acceptor\"],\"http1\":[\"hyper/http1\"],\"http2\":[\"hyper/http2\"],\"logging\":[\"log\",\"tokio-rustls/logging\",\"rustls/logging\"],\"native-tokio\":[\"tokio-runtime\",\"rustls-native-certs\"],\"tls12\":[\"tokio-rustls/tls12\",\"rustls/tls12\"],\"tokio-runtime\":[\"hyper/runtime\"],\"webpki-tokio\":[\"tokio-runtime\",\"webpki-roots\"]}}", - "hyper-rustls_0.27.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hyper\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"server-auto\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.23\"},{\"default_features\":false,\"features\":[\"tls12\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"native-tokio\",\"http1\",\"tls12\",\"logging\",\"aws-lc-rs\"],\"fips\":[\"aws-lc-rs\",\"rustls/fips\"],\"http1\":[\"hyper-util/http1\"],\"http2\":[\"hyper-util/http2\"],\"logging\":[\"log\",\"tokio-rustls/logging\",\"rustls/logging\"],\"native-tokio\":[\"rustls-native-certs\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"tokio-rustls/tls12\",\"rustls/tls12\"],\"webpki-tokio\":[\"webpki-roots\"]}}", - "hyper-timeout_0.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"hyper-tls\",\"req\":\"^0.6\"},{\"features\":[\"client-legacy\",\"http1\"],\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"features\":[\"client-legacy\",\"http1\",\"server\",\"server-graceful\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"io-std\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{}}", - "hyper-util_0.1.20": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.16\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.16\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1.8.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.4.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.9\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.3\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pnet_datalink\",\"req\":\"^0.35.0\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"macos\\\"))\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\">=0.5.9, <0.7\"},{\"name\":\"system-configuration\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"test-util\",\"signal\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tower-layer\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"windows-registry\",\"optional\":true,\"req\":\">=0.3, <0.7\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_happy_eyeballs_tests\":[],\"client\":[\"hyper/client\",\"tokio/net\",\"dep:tracing\",\"dep:futures-channel\",\"dep:tower-service\"],\"client-legacy\":[\"client\",\"dep:socket2\",\"tokio/sync\",\"dep:libc\",\"dep:futures-util\"],\"client-pool\":[\"client\",\"dep:futures-util\",\"dep:tower-layer\"],\"client-proxy\":[\"client\",\"dep:base64\",\"dep:ipnet\",\"dep:percent-encoding\"],\"client-proxy-system\":[\"dep:system-configuration\",\"dep:windows-registry\"],\"default\":[],\"full\":[\"client\",\"client-legacy\",\"client-pool\",\"client-proxy\",\"client-proxy-system\",\"server\",\"server-auto\",\"server-graceful\",\"service\",\"http1\",\"http2\",\"tokio\",\"tracing\"],\"http1\":[\"hyper/http1\"],\"http2\":[\"hyper/http2\"],\"server\":[\"hyper/server\"],\"server-auto\":[\"server\",\"http1\",\"http2\"],\"server-graceful\":[\"server\",\"tokio/sync\"],\"service\":[\"dep:tower-service\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"tokio/time\"],\"tracing\":[\"dep:tracing\"]}}", - "hyper_0.14.32": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.3.24\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"http-body\",\"req\":\"^0.4\"},{\"name\":\"httparse\",\"req\":\"^1.8\"},{\"name\":\"httpdate\",\"req\":\"^1.0\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pnet_datalink\",\"req\":\"^0.27.2\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"macos\\\"))\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\">=0.4.7, <0.6.0\"},{\"kind\":\"dev\",\"name\":\"spmc\",\"req\":\"^0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.27\"},{\"features\":[\"fs\",\"macros\",\"io-std\",\"io-util\",\"rt\",\"rt-multi-thread\",\"sync\",\"time\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"codec\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2\"},{\"name\":\"want\",\"req\":\"^0.3\"}],\"features\":{\"__internal_happy_eyeballs_tests\":[],\"backports\":[],\"client\":[],\"default\":[],\"deprecated\":[],\"ffi\":[\"libc\"],\"full\":[\"client\",\"http1\",\"http2\",\"server\",\"stream\",\"runtime\"],\"http1\":[],\"http2\":[\"h2\"],\"nightly\":[],\"runtime\":[\"tcp\",\"tokio/rt\",\"tokio/time\"],\"server\":[],\"stream\":[],\"tcp\":[\"socket2\",\"tokio/net\",\"tokio/rt\",\"tokio/time\"]}}", - "hyper_1.8.1": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"form_urlencoded\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"sink\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.2\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"spmc\",\"req\":\"^0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"net\",\"io-std\",\"io-util\",\"rt\",\"rt-multi-thread\",\"sync\",\"time\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"want\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"capi\":[],\"client\":[\"dep:want\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"default\":[],\"ffi\":[\"dep:http-body-util\",\"dep:futures-util\"],\"full\":[\"client\",\"http1\",\"http2\",\"server\"],\"http1\":[\"dep:atomic-waker\",\"dep:futures-channel\",\"dep:futures-core\",\"dep:httparse\",\"dep:itoa\",\"dep:pin-utils\"],\"http2\":[\"dep:futures-channel\",\"dep:futures-core\",\"dep:h2\"],\"nightly\":[],\"server\":[\"dep:httpdate\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"tracing\":[\"dep:tracing\"]}}", - "hyper_1.9.0": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"form_urlencoded\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"sink\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.6\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"spmc\",\"req\":\"^0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"net\",\"io-std\",\"io-util\",\"rt\",\"rt-multi-thread\",\"sync\",\"time\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"want\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"capi\":[],\"client\":[\"dep:want\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"default\":[],\"ffi\":[\"dep:http-body-util\",\"dep:futures-util\"],\"full\":[\"client\",\"http1\",\"http2\",\"server\"],\"http1\":[\"dep:atomic-waker\",\"dep:futures-channel\",\"dep:futures-core\",\"dep:httparse\",\"dep:itoa\"],\"http2\":[\"dep:futures-channel\",\"dep:futures-core\",\"dep:h2\"],\"nightly\":[],\"server\":[\"dep:httpdate\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"tracing\":[\"dep:tracing\"]}}", - "hyperlocal_0.9.1": "{\"dependencies\":[{\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"hyper\",\"req\":\"^1.3\"},{\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\"],\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"io-std\",\"io-util\",\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"client\":[\"http-body-util\",\"hyper/client\",\"hyper/http1\",\"hyper-util/client-legacy\",\"hyper-util/http1\",\"hyper-util/tokio\",\"tower-service\"],\"default\":[\"client\",\"server\"],\"server\":[\"hyper/http1\",\"hyper/server\",\"hyper-util/tokio\"]}}", - "iana-time-zone-haiku_0.1.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{}}", - "iana-time-zone_0.1.65": "{\"dependencies\":[{\"name\":\"android_system_properties\",\"req\":\"^0.1.5\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.1\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\",\"target\":\"cfg(target_vendor = \\\"apple\\\")\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"iana-time-zone-haiku\",\"req\":\"^0.1.1\",\"target\":\"cfg(target_os = \\\"haiku\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.66\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.46\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"windows-core\",\"req\":\">=0.56, <=0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"fallback\":[]}}", - "icu_collections_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"parse\"],\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"databake\":[\"dep:databake\",\"zerovec/databake\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"potential_utf/serde\",\"alloc\"]}}", - "icu_locale_core_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"testing\"],\"kind\":\"dev\",\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"tinystr\",\"req\":\"^0.8.3\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.1\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"litemap/alloc\",\"tinystr/alloc\",\"writeable/alloc\",\"serde?/alloc\"],\"databake\":[\"dep:databake\",\"alloc\"],\"serde\":[\"dep:serde\",\"tinystr/serde\"],\"zerovec\":[\"dep:zerovec\",\"tinystr/zerovec\"]}}", - "icu_normalizer_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arraystring\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"atoi\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"harfbuzz-traits\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_normalizer_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_properties\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"utf16_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"write16\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"arrayvec\",\"smallvec\"],\"kind\":\"dev\",\"name\":\"write16\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"compiled_data\":[\"dep:icu_normalizer_data\",\"icu_properties?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"icu_properties\",\"icu_collections/databake\",\"zerovec/databake\",\"icu_properties?/datagen\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"utf8_iter\",\"utf16_iter\"],\"harfbuzz_traits\":[\"dep:harfbuzz-traits\"],\"icu_properties\":[\"dep:icu_properties\"],\"serde\":[\"dep:serde\",\"icu_collections/serde\",\"zerovec/serde\",\"icu_properties?/serde\",\"icu_provider/serde\"],\"utf16_iter\":[\"dep:utf16_iter\",\"dep:write16\"],\"utf8_iter\":[\"dep:utf8_iter\"],\"write16\":[]}}", - "icu_normalizer_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", - "icu_properties_2.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"harfbuzz-traits\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"icu_properties_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"unicode-bidi\",\"optional\":true,\"req\":\"^0.3.11\"},{\"default_features\":false,\"features\":[\"yoke\",\"zerofrom\"],\"name\":\"zerotrie\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"zerovec/alloc\",\"icu_collections/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_properties_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_collections/databake\",\"icu_locale_core/databake\",\"zerotrie/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"harfbuzz_traits\":[\"dep:harfbuzz-traits\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"zerovec/serde\",\"icu_collections/serde\",\"icu_provider/serde\",\"zerotrie/serde\"],\"unicode_bidi\":[\"dep:unicode-bidi\"]}}", - "icu_properties_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", - "icu_provider_2.2.0": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"name\":\"erased-serde\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"postcard\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerotrie\",\"optional\":true,\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"serde?/alloc\",\"yoke/alloc\",\"zerofrom/alloc\",\"zerovec/alloc\",\"zerotrie?/alloc\",\"dep:stable_deref_trait\",\"dep:writeable\"],\"baked\":[\"dep:zerotrie\",\"dep:writeable\"],\"deserialize_bincode_1\":[\"serde\",\"dep:bincode\",\"std\"],\"deserialize_json\":[\"serde\",\"dep:serde_json\"],\"deserialize_postcard_1\":[\"serde\",\"dep:postcard\"],\"export\":[\"serde\",\"dep:erased-serde\",\"dep:databake\",\"std\",\"sync\",\"dep:postcard\",\"zerovec/databake\"],\"logging\":[\"dep:log\"],\"serde\":[\"dep:serde\",\"yoke/serde\"],\"std\":[\"alloc\"],\"sync\":[],\"zerotrie\":[]}}", - "id-arena_2.3.0": "{\"dependencies\":[{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "ident_case_1.0.1": "{\"dependencies\":[],\"features\":{}}", - "idna_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"idna_adapter\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\"],\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"kind\":\"dev\",\"name\":\"tester\",\"req\":\"^0.9\"},{\"name\":\"utf8_iter\",\"req\":\"^1.0.4\"}],\"features\":{\"alloc\":[],\"compiled_data\":[\"idna_adapter/compiled_data\"],\"default\":[\"std\",\"compiled_data\"],\"std\":[\"alloc\"]}}", - "idna_adapter_1.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"icu_normalizer\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"icu_properties\",\"req\":\"^2\"}],\"features\":{\"compiled_data\":[\"icu_normalizer/compiled_data\",\"icu_properties/compiled_data\"]}}", - "idna_adapter_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"icu_normalizer\",\"req\":\"^2.2\"},{\"default_features\":false,\"name\":\"icu_properties\",\"req\":\"^2.2\"}],\"features\":{\"compiled_data\":[\"icu_normalizer/compiled_data\",\"icu_properties/compiled_data\"]}}", - "include_dir_0.7.4": "{\"dependencies\":[{\"name\":\"glob\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"include_dir_macros\",\"req\":\"^0.7.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"default\":[],\"metadata\":[\"include_dir_macros/metadata\"],\"nightly\":[\"include_dir_macros/nightly\"]}}", - "include_dir_macros_0.7.4": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"}],\"features\":{\"metadata\":[],\"nightly\":[]}}", - "indexmap_1.9.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"raw\"],\"name\":\"hashbrown\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.4.1\"},{\"name\":\"rustc-rayon\",\"optional\":true,\"package\":\"rustc-rayon\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"}],\"features\":{\"serde-1\":[\"serde\"],\"std\":[],\"test_debug\":[],\"test_low_transition_point\":[]}}", - "indexmap_2.13.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"req\":\"^0.16.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\",\"dep:serde\"],\"std\":[],\"test_debug\":[]}}", - "indexmap_2.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.1\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\",\"dep:serde\"],\"std\":[],\"test_debug\":[]}}", - "indicatif_0.17.11": "{\"dependencies\":[{\"features\":[\"color\",\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"default_features\":false,\"features\":[\"ansi-parsing\"],\"name\":\"console\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"number_prefix\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"portable-atomic\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.1\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"fs\",\"time\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"vt100\",\"optional\":true,\"req\":\"^0.15.1\"},{\"name\":\"web-time\",\"req\":\"^1.1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"default\":[\"unicode-width\",\"console/unicode-width\"],\"futures\":[\"dep:futures-core\"],\"improved_unicode\":[\"unicode-segmentation\",\"unicode-width\",\"console/unicode-width\"],\"in_memory\":[\"vt100\"]}}", - "inotify-sys_0.1.5": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", - "inotify_0.11.2": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.30\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"inotify-sys\",\"req\":\"^0.1.5\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"maplit\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.12.0\"},{\"features\":[\"net\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.40.0\"},{\"features\":[\"macros\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40.0\"}],\"features\":{\"default\":[\"stream\"],\"stream\":[\"futures-util\",\"tokio\"]}}", - "inout_0.1.4": "{\"dependencies\":[{\"name\":\"block-padding\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{\"std\":[\"block-padding/std\"]}}", - "inout_0.2.2": "{\"dependencies\":[{\"name\":\"block-padding\",\"optional\":true,\"req\":\"^0.4.2\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4\"}],\"features\":{}}", - "instant_0.1.13": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"asmjs-unknown-emscripten\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"wasm32-unknown-emscripten\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"wasm32-unknown-unknown\"},{\"name\":\"stdweb\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"asmjs-unknown-emscripten\"},{\"name\":\"stdweb\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"wasm32-unknown-emscripten\"},{\"name\":\"stdweb\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"wasm32-unknown-unknown\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\"},{\"name\":\"wasm-bindgen_rs\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"asmjs-unknown-emscripten\"},{\"name\":\"wasm-bindgen_rs\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"wasm32-unknown-emscripten\"},{\"name\":\"wasm-bindgen_rs\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"wasm32-unknown-unknown\"},{\"features\":[\"Window\",\"Performance\",\"PerformanceTiming\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"asmjs-unknown-emscripten\"},{\"features\":[\"Window\",\"Performance\",\"PerformanceTiming\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"wasm32-unknown-emscripten\"},{\"features\":[\"Window\",\"Performance\",\"PerformanceTiming\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"wasm32-unknown-unknown\"}],\"features\":{\"inaccurate\":[],\"now\":[],\"wasm-bindgen\":[\"js-sys\",\"wasm-bindgen_rs\",\"web-sys\"]}}", - "internal-russh-num-bigint_0.5.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_0_10\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"rand_0_9\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rand_core_0_10\",\"optional\":true,\"package\":\"rand_core\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"rand_core_0_9\",\"optional\":true,\"package\":\"rand_core\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\"],\"quickcheck\":[\"dep:quickcheck\"],\"rand_0_10\":[\"rand_core_0_10\",\"dep:rand_0_10\"],\"rand_0_9\":[\"rand_core_0_9\",\"dep:rand_0_9\"],\"rand_core_0_10\":[\"dep:rand_core_0_10\"],\"rand_core_0_9\":[\"dep:rand_core_0_9\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-integer/std\",\"num-traits/std\"]}}", - "ipnet_2.12.0": "{\"dependencies\":[{\"name\":\"heapless\",\"optional\":true,\"req\":\"^0\"},{\"default_features\":false,\"name\":\"schemars08\",\"optional\":true,\"package\":\"schemars\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"schemars1\",\"optional\":true,\"package\":\"schemars\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"package\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"heapless\":[\"dep:heapless\",\"serde\"],\"json\":[\"schemars08\",\"serde\"],\"schemars\":[\"schemars08\"],\"schemars08\":[\"dep:schemars08\"],\"schemars1\":[\"dep:schemars1\"],\"ser_as_str\":[\"dep:heapless\"],\"serde\":[\"dep:serde\"],\"std\":[]}}", - "iri-string_0.7.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.4.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.104\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"memchr?/std\",\"serde?/std\"]}}", - "is_ci_1.2.0": "{\"dependencies\":[],\"features\":{}}", - "is_executable_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Storage_FileSystem\"],\"name\":\"windows-sys\",\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{}}", - "is_terminal_polyfill_1.70.2": "{\"dependencies\":[],\"features\":{\"default\":[]}}", - "itertools_0.12.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", - "itertools_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", - "itertools_0.14.0": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", - "itoa_1.0.17": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{}}", - "itoa_1.0.18": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{}}", - "jiff-static_0.2.23": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.93\"},{\"name\":\"quote\",\"req\":\"^1.0.38\"},{\"name\":\"syn\",\"req\":\"^2.0.98\"}],\"features\":{\"default\":[],\"perf-inline\":[],\"tz-fat\":[],\"tzdb\":[\"dep:jiff-tzdb\"]}}", - "jiff-static_0.2.24": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.93\"},{\"name\":\"quote\",\"req\":\"^1.0.38\"},{\"name\":\"syn\",\"req\":\"^2.0.98\"}],\"features\":{\"default\":[],\"perf-inline\":[],\"tz-fat\":[],\"tzdb\":[\"dep:jiff-tzdb\"]}}", - "jiff_0.2.23": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.23\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"time-tz\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", - "jiff_0.2.24": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.24\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"time-tz\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", - "jni-sys-macros_0.4.1": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "jni-sys_0.3.1": "{\"dependencies\":[{\"name\":\"jni_sys_04\",\"package\":\"jni-sys\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"}],\"features\":{\"default\":[]}}", - "jni-sys_0.4.1": "{\"dependencies\":[{\"name\":\"jni-sys-macros\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", - "jni_0.21.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"},{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", - "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", - "js-sys_0.3.82": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.105\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}", - "js-sys_0.3.95": "{\"dependencies\":[{\"name\":\"cfg-if\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.31\"},{\"kind\":\"dev\",\"name\":\"half\",\"req\":\"^2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.118\"}],\"features\":{\"default\":[\"std\",\"unsafe-eval\"],\"futures\":[\"dep:cfg-if\",\"dep:futures-util\"],\"futures-core-03-stream\":[\"futures\",\"dep:futures-core\"],\"std\":[\"wasm-bindgen/std\"],\"unsafe-eval\":[]}}", - "json-patch_1.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"expectorate\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.159\"},{\"name\":\"serde_json\",\"req\":\"^1.0.95\"},{\"features\":[\"preserve_order\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.19\"},{\"name\":\"thiserror\",\"req\":\"^1.0.40\"},{\"name\":\"utoipa\",\"optional\":true,\"req\":\"^4.0\"},{\"features\":[\"debug\"],\"kind\":\"dev\",\"name\":\"utoipa\",\"req\":\"^4.0\"}],\"features\":{\"default\":[\"diff\"],\"diff\":[]}}", - "jsonpath-rust_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"name\":\"pest\",\"req\":\"^2.0\"},{\"name\":\"pest_derive\",\"req\":\"^2.0\"},{\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.50\"}],\"features\":{}}", - "jsonwebtoken_10.3.0": "{\"dependencies\":[{\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.15.0\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"features\":[\"pkcs8\"],\"name\":\"ed25519-dalek\",\"optional\":true,\"req\":\"^2.1.1\"},{\"features\":[\"pkcs8\",\"rand_core\"],\"kind\":\"dev\",\"name\":\"ed25519-dalek\",\"req\":\"^2.1.1\"},{\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"ecdsa\"],\"name\":\"p256\",\"optional\":true,\"req\":\"^0.13.2\"},{\"features\":[\"ecdsa\"],\"name\":\"p384\",\"optional\":true,\"req\":\"^0.13.0\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.5\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"name\":\"rsa\",\"optional\":true,\"req\":\"^0.9.6\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.7\"},{\"features\":[\"std\"],\"name\":\"signature\",\"req\":\"^2.2.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"aws_lc_rs\":[\"aws-lc-rs\"],\"default\":[\"use_pem\"],\"rust_crypto\":[\"ed25519-dalek\",\"hmac\",\"p256\",\"p384\",\"rand\",\"rsa\",\"sha2\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}", - "jsonwebtoken_9.3.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"features\":[\"std\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\",\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"default\":[\"use_pem\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}", - "k8s-openapi_0.21.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.21\"},{\"default_features\":false,\"features\":[\"alloc\",\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"earliest\":[\"v1_24\"],\"latest\":[\"v1_29\"],\"v1_24\":[],\"v1_25\":[],\"v1_26\":[],\"v1_27\":[],\"v1_28\":[],\"v1_29\":[]}}", - "keccak_0.2.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"aarch64\\\")\"},{\"name\":\"hybrid-array\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"parallel\":[\"dep:hybrid-array\"]}}", - "kem_0.3.0": "{\"dependencies\":[{\"features\":[\"rand_core\"],\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"rand_core\",\"req\":\"^0.10\"}],\"features\":{\"getrandom\":[\"common/getrandom\"]}}", - "konst_0.2.20": "{\"dependencies\":[{\"name\":\"konst_macro_rules\",\"req\":\"=0.2.19\"},{\"name\":\"konst_proc_macros\",\"optional\":true,\"req\":\"=0.2.11\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"trybuild\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"__test\":[],\"__ui\":[\"__test\",\"trybuild\",\"rust_latest_stable\"],\"alloc\":[],\"cmp\":[],\"const_generics\":[\"rust_1_51\"],\"constant_time_slice\":[\"rust_latest_stable\"],\"default\":[\"cmp\",\"parsing\"],\"deref_raw_in_fn\":[\"rust_1_56\"],\"docsrs\":[],\"mut_refs\":[\"rust_latest_stable\",\"konst_macro_rules/mut_refs\"],\"nightly_mut_refs\":[\"mut_refs\",\"konst_macro_rules/nightly_mut_refs\"],\"parsing\":[\"parsing_no_proc\",\"konst_proc_macros\"],\"parsing_no_proc\":[],\"rust_1_51\":[\"konst_macro_rules/rust_1_51\"],\"rust_1_55\":[\"rust_1_51\",\"konst_macro_rules/rust_1_55\"],\"rust_1_56\":[\"rust_1_55\",\"konst_macro_rules/rust_1_56\"],\"rust_1_57\":[\"rust_1_56\",\"konst_macro_rules/rust_1_57\"],\"rust_1_61\":[\"rust_1_57\",\"konst_macro_rules/rust_1_61\"],\"rust_1_64\":[\"rust_1_61\"],\"rust_latest_stable\":[\"rust_1_64\"]}}", - "konst_macro_rules_0.2.19": "{\"dependencies\":[],\"features\":{\"deref_raw_in_fn\":[],\"mut_refs\":[],\"nightly_mut_refs\":[],\"rust_1_51\":[],\"rust_1_55\":[],\"rust_1_56\":[],\"rust_1_57\":[],\"rust_1_61\":[]}}", - "kqueue-sys_1.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.11.0\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", - "kqueue_1.2.0": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dhat\",\"req\":\"^0.3.2\"},{\"name\":\"kqueue-sys\",\"req\":\"^1.1.1\"},{\"name\":\"libc\",\"req\":\"^0.2.17\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"}],\"features\":{}}", - "kube-client_0.90.0": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"either\",\"optional\":true,\"req\":\"^1.6.1\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.17\"},{\"default_features\":false,\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.17\"},{\"name\":\"home\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"client-legacy\"],\"name\":\"hyper-openssl\",\"optional\":true,\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"http1\",\"logging\",\"native-tokio\",\"ring\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\"},{\"default_features\":false,\"name\":\"hyper-socks2\",\"optional\":true,\"req\":\"^0.9.0\"},{\"name\":\"hyper-timeout\",\"optional\":true,\"req\":\"^0.5.1\"},{\"features\":[\"client\",\"client-legacy\",\"http1\",\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"jsonpath-rust\",\"optional\":true,\"req\":\"^0.5.0\"},{\"default_features\":false,\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"default_features\":false,\"features\":[\"latest\"],\"kind\":\"dev\",\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"features\":[\"derive\",\"client\",\"ws\"],\"kind\":\"dev\",\"name\":\"kube\",\"req\":\"<1.0.0, >=0.61.0\"},{\"name\":\"kube-core\",\"req\":\"=0.90.0\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.36\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3.0.1\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.3\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^0.8.6\"},{\"features\":[\"alloc\",\"serde\"],\"name\":\"secrecy\",\"req\":\"^0.8.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.130\"},{\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"serde_yaml\",\"optional\":true,\"req\":\"^0.9.19\"},{\"features\":[\"gcp\"],\"name\":\"tame-oauth\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.29\"},{\"features\":[\"time\",\"signal\",\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.14.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.21.0\"},{\"features\":[\"io\",\"codec\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"},{\"features\":[\"buffer\",\"filter\",\"util\"],\"name\":\"tower\",\"optional\":true,\"req\":\"^0.4.13\"},{\"features\":[\"auth\",\"map-response-body\",\"trace\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4.0\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.36\"}],\"features\":{\"__non_core\":[\"tracing\",\"serde_yaml\",\"base64\"],\"admission\":[\"kube-core/admission\"],\"client\":[\"config\",\"__non_core\",\"hyper\",\"hyper-util\",\"http-body\",\"http-body-util\",\"tower\",\"tower-http\",\"hyper-timeout\",\"chrono\",\"jsonpath-rust\",\"bytes\",\"futures\",\"tokio\",\"tokio-util\",\"either\"],\"config\":[\"__non_core\",\"pem\",\"home\"],\"default\":[\"client\"],\"gzip\":[\"client\",\"tower-http/decompression-gzip\"],\"jsonpatch\":[\"kube-core/jsonpatch\"],\"kubelet-debug\":[\"ws\",\"kube-core/kubelet-debug\"],\"oauth\":[\"client\",\"tame-oauth\"],\"oidc\":[\"client\",\"form_urlencoded\"],\"openssl-tls\":[\"openssl\",\"hyper-openssl\"],\"rustls-tls\":[\"rustls\",\"rustls-pemfile\",\"hyper-rustls\"],\"socks5\":[\"hyper-socks2\"],\"unstable-client\":[],\"ws\":[\"client\",\"tokio-tungstenite\",\"rand\",\"kube-core/ws\",\"tokio/macros\"]}}", - "kube-core_0.90.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-json-diff\",\"req\":\"^2.0.2\"},{\"default_features\":false,\"features\":[\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.34\"},{\"name\":\"form_urlencoded\",\"req\":\"^1.2.0\"},{\"name\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"json-patch\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"default_features\":false,\"features\":[\"latest\"],\"kind\":\"dev\",\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"kind\":\"dev\",\"name\":\"kube\",\"req\":\"<1.0.0, >=0.53.0\"},{\"name\":\"schemars\",\"optional\":true,\"req\":\"^0.8.6\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.130\"},{\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.19\"},{\"name\":\"thiserror\",\"req\":\"^1.0.29\"}],\"features\":{\"admission\":[\"json-patch\"],\"jsonpatch\":[\"json-patch\"],\"kubelet-debug\":[\"ws\"],\"schema\":[\"schemars\"],\"ws\":[]}}", - "kube-derive_0.90.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-json-diff\",\"req\":\"^2.0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.34\"},{\"name\":\"darling\",\"req\":\"^0.20.3\"},{\"default_features\":false,\"features\":[\"latest\"],\"kind\":\"dev\",\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"features\":[\"derive\",\"client\"],\"kind\":\"dev\",\"name\":\"kube\",\"req\":\"<1.0.0, >=0.61.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.29\"},{\"name\":\"quote\",\"req\":\"^1.0.10\"},{\"features\":[\"chrono\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^0.8.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.130\"},{\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.19\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.38\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.48\"}],\"features\":{}}", - "kube-runtime_0.90.0": "{\"dependencies\":[{\"name\":\"ahash\",\"req\":\"^0.8\"},{\"name\":\"async-trait\",\"req\":\"^0.1.64\"},{\"name\":\"backoff\",\"req\":\"^0.4.0\"},{\"name\":\"derivative\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"futures\",\"req\":\"^0.3.17\"},{\"name\":\"hashbrown\",\"req\":\"^0.14.0\"},{\"name\":\"json-patch\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"default_features\":false,\"features\":[\"latest\"],\"kind\":\"dev\",\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"features\":[\"derive\",\"client\",\"runtime\"],\"kind\":\"dev\",\"name\":\"kube\",\"req\":\"<1.0.0, >=0.60.0\"},{\"default_features\":false,\"features\":[\"jsonpatch\",\"client\"],\"name\":\"kube-client\",\"req\":\"=0.90.0\"},{\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project\",\"req\":\"^1.0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^0.8.6\"},{\"name\":\"serde\",\"req\":\"^1.0.130\"},{\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.29\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"req\":\"^1.14.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14.0\"},{\"features\":[\"time\"],\"name\":\"tokio-util\",\"req\":\"^0.7.0\"},{\"name\":\"tracing\",\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.17\"}],\"features\":{\"unstable-runtime\":[\"unstable-runtime-subscribe\",\"unstable-runtime-predicates\",\"unstable-runtime-stream-control\",\"unstable-runtime-reconcile-on\"],\"unstable-runtime-predicates\":[],\"unstable-runtime-reconcile-on\":[],\"unstable-runtime-stream-control\":[],\"unstable-runtime-subscribe\":[]}}", - "kube_0.90.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.71\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"default_features\":false,\"features\":[\"latest\"],\"kind\":\"dev\",\"name\":\"k8s-openapi\",\"req\":\"^0.21.0\"},{\"default_features\":false,\"name\":\"kube-client\",\"optional\":true,\"req\":\"=0.90.0\"},{\"name\":\"kube-core\",\"req\":\"=0.90.0\"},{\"name\":\"kube-derive\",\"optional\":true,\"req\":\"=0.90.0\"},{\"name\":\"kube-runtime\",\"optional\":true,\"req\":\"=0.90.0\"},{\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^0.8.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14.0\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4.0\"}],\"features\":{\"admission\":[\"kube-core/admission\"],\"client\":[\"kube-client/client\",\"config\"],\"config\":[\"kube-client/config\"],\"default\":[\"client\",\"rustls-tls\"],\"derive\":[\"kube-derive\",\"kube-core/schema\"],\"gzip\":[\"kube-client/gzip\"],\"jsonpatch\":[\"kube-core/jsonpatch\"],\"kubelet-debug\":[\"kube-client/kubelet-debug\",\"kube-core/kubelet-debug\"],\"oauth\":[\"kube-client/oauth\"],\"oidc\":[\"kube-client/oidc\"],\"openssl-tls\":[\"kube-client/openssl-tls\"],\"runtime\":[\"kube-runtime\"],\"rustls-tls\":[\"kube-client/rustls-tls\"],\"socks5\":[\"kube-client/socks5\"],\"unstable-client\":[\"kube-client/unstable-client\"],\"unstable-runtime\":[\"kube-runtime/unstable-runtime\"],\"ws\":[\"kube-client/ws\",\"kube-core/ws\"]}}", - "landlock_0.4.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"enumflags2\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{}}", - "lazy_static_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"once\"],\"name\":\"spin\",\"optional\":true,\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"spin_no_std\":[\"spin\"]}}", - "leb128_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.1\"}],\"features\":{\"nightly\":[]}}", - "leb128fmt_0.1.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", - "libbz2-rs-sys_0.2.3": "{\"dependencies\":[{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"}],\"features\":{\"__internal-fuzz-disable-checksum\":[],\"c-allocator\":[\"dep:libc\"],\"custom-prefix\":[\"export-symbols\"],\"default\":[\"std\",\"stdio\"],\"export-symbols\":[],\"rust-allocator\":[],\"semver-prefix\":[\"export-symbols\"],\"std\":[\"rust-allocator\"],\"stdio\":[\"dep:libc\"],\"testing-prefix\":[\"export-symbols\"]}}", - "libc_0.2.183": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", - "libc_0.2.186": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", - "libc_0.2.189": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", - "libloading_0.8.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", - "libredox_0.1.16": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", - "libsqlite3-sys_0.30.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", - "libyml_0.0.5": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"kind\":\"build\",\"name\":\"version_check\",\"req\":\"^0.9.5\"}],\"features\":{\"default\":[],\"test-utils\":[]}}", - "linux-raw-sys_0.12.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"auxvec\":[],\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"if_tun\":[],\"image\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"no_std\"],\"std\":[],\"system\":[],\"vm_sockets\":[],\"xdp\":[]}}", - "linux-raw-sys_0.4.15": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", - "litemap_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\",\"alloc\"],\"testing\":[\"alloc\"],\"yoke\":[\"dep:yoke\"]}}", - "lock_api_0.4.14": "{\"dependencies\":[{\"name\":\"owning_ref\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"scopeguard\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.126\"}],\"features\":{\"arc_lock\":[],\"atomic_usize\":[],\"default\":[\"atomic_usize\"],\"nightly\":[]}}", - "log_0.4.29": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.16\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.16\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.12\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", - "lru-slab_0.1.2": "{\"dependencies\":[],\"features\":{}}", - "lru_0.12.5": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"scoped_threadpool\",\"req\":\"0.1.*\"},{\"kind\":\"dev\",\"name\":\"stats_alloc\",\"req\":\"0.1.*\"}],\"features\":{\"default\":[\"hashbrown\"],\"nightly\":[\"hashbrown\",\"hashbrown/nightly\"]}}", - "lzma-rust2_0.16.2": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"=0.7\"},{\"features\":[\"static\"],\"kind\":\"dev\",\"name\":\"liblzma\",\"req\":\"^0.4\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\",\"encoder\",\"optimization\",\"lzip\",\"xz\"],\"encoder\":[],\"lzip\":[],\"optimization\":[],\"std\":[],\"xz\":[\"sha2\"]}}", - "matchers_0.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"syntax\",\"dfa-build\",\"dfa-search\"],\"name\":\"regex-automata\",\"req\":\"^0.4\"}],\"features\":{\"unicode\":[\"regex-automata/unicode\"]}}", - "matchit_0.8.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", - "md-5_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"md5-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"md5-asm\"],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", - "md5_0.8.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "memchr_2.8.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", - "metrics-exporter-prometheus_0.18.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"server\",\"client\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"http1\",\"rustls-native-certs\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27\"},{\"default_features\":false,\"features\":[\"tokio\",\"service\",\"client\",\"client-legacy\",\"http1\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2.6\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"metrics\",\"req\":\"^0.24\"},{\"default_features\":false,\"features\":[\"recency\",\"registry\",\"storage\"],\"name\":\"metrics-util\",\"req\":\"^0.20\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"prost\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"prost-build\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"prost-types\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"quanta\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"rt\",\"net\",\"time\",\"rt-multi-thread\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"_hyper-client\":[\"http-body-util\",\"hyper/client\",\"hyper-util/client\",\"hyper-util/http1\",\"hyper-util/client-legacy\",\"hyper-rustls\"],\"_hyper-server\":[\"http-body-util\",\"hyper/server\",\"hyper-util/server-auto\"],\"_push-gateway-common\":[\"async-runtime\",\"rustls\",\"tracing\",\"_hyper-client\"],\"async-runtime\":[\"tokio\",\"hyper-util/tokio\"],\"default\":[\"http-listener\",\"push-gateway\"],\"http-listener\":[\"async-runtime\",\"ipnet\",\"tracing\",\"_hyper-server\"],\"protobuf\":[\"mime\",\"prost\",\"prost-types\",\"prost-build\"],\"push-gateway\":[\"_push-gateway-common\",\"hyper-rustls/aws-lc-rs\"],\"push-gateway-no-tls-provider\":[\"_push-gateway-common\"],\"uds-listener\":[\"http-listener\"]}}", - "metrics-util_0.20.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"std\"],\"name\":\"crossbeam-epoch\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"crossbeam-queue\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"getopts\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"raw-entry\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.16\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.6\"},{\"name\":\"metrics\",\"req\":\"^0.24\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ndarray\",\"req\":\"^0.16\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ndarray-stats\",\"req\":\"^0.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"noisy_float\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"ordered-float\",\"optional\":true,\"req\":\"^5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ordered-float\",\"req\":\"^5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"predicates\",\"req\":\"^3.1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"predicates-core\",\"req\":\"^1.0.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"predicates-tree\",\"req\":\"^1.0.9\"},{\"default_features\":false,\"name\":\"quanta\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"radix_trie\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rand_xoshiro\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"sketches-ddsketch\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sketches-ddsketch\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"fmt\",\"ansi\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"debugging\":[\"indexmap\",\"ordered-float\",\"registry\"],\"default\":[\"debugging\",\"layers\",\"recency\",\"registry\",\"storage\"],\"layer-filter\":[\"aho-corasick\"],\"layer-router\":[\"radix_trie\"],\"layers\":[\"layer-filter\",\"layer-router\"],\"recency\":[\"registry\",\"quanta\"],\"registry\":[\"hashbrown\",\"storage\"],\"storage\":[\"crossbeam-epoch\",\"crossbeam-utils\",\"rand\",\"rand_xoshiro\",\"sketches-ddsketch\"]}}", - "metrics_0.24.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"fallback\"],\"name\":\"portable-atomic\",\"req\":\"^1\",\"target\":\"cfg(target_pointer_width = \\\"32\\\")\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", - "miette-derive_7.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", - "miette_7.6.0": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.69\"},{\"name\":\"backtrace-ext\",\"optional\":true,\"req\":\"^0.2.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"indenter\",\"req\":\"^0.3.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"miette-derive\",\"optional\":true,\"req\":\"=7.6.0\"},{\"name\":\"owo-colors\",\"optional\":true,\"req\":\"^4.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.21\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.196\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.196\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.113\"},{\"kind\":\"dev\",\"name\":\"strip-ansi-escapes\",\"req\":\"^0.2.0\"},{\"name\":\"supports-color\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-hyperlinks\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-unicode\",\"optional\":true,\"req\":\"^3.0.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.87\"},{\"name\":\"syntect\",\"optional\":true,\"req\":\"^5.1.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"unicode-linebreak\",\"unicode-width\"],\"name\":\"textwrap\",\"optional\":true,\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2.0.11\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\"},{\"name\":\"unicode-width\",\"req\":\"^0.1.11\"}],\"features\":{\"default\":[\"derive\"],\"derive\":[\"dep:miette-derive\"],\"fancy\":[\"fancy-no-backtrace\",\"dep:backtrace\",\"dep:backtrace-ext\"],\"fancy-base\":[\"dep:owo-colors\",\"dep:textwrap\"],\"fancy-no-backtrace\":[\"fancy-base\",\"dep:terminal_size\",\"dep:supports-hyperlinks\",\"dep:supports-color\",\"dep:supports-unicode\"],\"fancy-no-syscall\":[\"fancy-base\"],\"no-format-args-capture\":[],\"syntect-highlighter\":[\"fancy-no-backtrace\",\"dep:syntect\"]}}", - "mime_0.3.17": "{\"dependencies\":[],\"features\":{}}", - "mime_guess_2.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"mime\",\"req\":\"^0.3\"},{\"name\":\"unicase\",\"req\":\"^2.4.0\"},{\"kind\":\"build\",\"name\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"rev-mappings\"],\"rev-mappings\":[]}}", - "minicov_0.3.8": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.77\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"]}}", - "minimal-lexical_0.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"compact\":[],\"default\":[\"std\"],\"lint\":[],\"nightly\":[],\"std\":[]}}", - "miniz_oxide_0.8.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"adler2\",\"req\":\"^2.0\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.3\"}],\"features\":{\"block-boundary\":[],\"default\":[\"with-alloc\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"adler2/rustc-dep-of-std\"],\"simd\":[\"simd-adler32\"],\"std\":[],\"with-alloc\":[]}}", - "mio_0.8.11": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"name\":\"libc\",\"req\":\"^0.2.149\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.149\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"wasi\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.48\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"log\"],\"net\":[],\"os-ext\":[\"os-poll\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_Security\"],\"os-poll\":[]}}", - "mio_1.1.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"wasi\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Wdk_System_IO\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_IO\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"log\"],\"net\":[],\"os-ext\":[\"os-poll\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_Security\"],\"os-poll\":[]}}", - "mio_1.2.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"libc\",\"req\":\"^0.2.183\",\"target\":\"cfg(any(unix, target_os = \\\"hermit\\\", target_os = \\\"wasi\\\"))\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"wasi\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Wdk_System_IO\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_IO\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"log\"],\"net\":[],\"os-ext\":[\"os-poll\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_Security\"],\"os-poll\":[]}}", - "ml-kem_0.3.2": "{\"dependencies\":[{\"features\":[\"ctutils\",\"extra-sizes\"],\"name\":\"array\",\"package\":\"hybrid-array\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"db\"],\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.10.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"features\":[\"sys_rng\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"kem\",\"req\":\"^0.3\"},{\"features\":[\"ctutils\"],\"name\":\"module-lattice\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"num-bigint\"],\"kind\":\"dev\",\"name\":\"num-rational\",\"req\":\"^0.4.2\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.208\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.125\"},{\"default_features\":false,\"name\":\"sha3\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[\"module-lattice/alloc\",\"pkcs8?/alloc\"],\"default\":[\"alloc\"],\"getrandom\":[\"kem/getrandom\"],\"hazmat\":[],\"pem\":[\"pkcs8/pem\"],\"pkcs8\":[\"dep:const-oid\",\"dep:pkcs8\"],\"zeroize\":[\"module-lattice/zeroize\",\"dep:zeroize\"]}}", - "module-lattice_0.2.3": "{\"dependencies\":[{\"features\":[\"extra-sizes\"],\"name\":\"array\",\"package\":\"hybrid-array\",\"req\":\"^0.4.8\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"sys_rng\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"ctutils\":[\"dep:ctutils\",\"array/ctutils\"],\"zeroize\":[\"array/zeroize\",\"dep:zeroize\"]}}", - "msvc_spectre_libs_0.1.3": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\",\"target\":\"cfg(all(target_os = \\\"windows\\\", target_env = \\\"msvc\\\"))\"}],\"features\":{\"error\":[]}}", - "multimap_0.10.1": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"serde_impl\"],\"serde_impl\":[\"serde\"]}}", - "multipart_0.18.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"buf_redux\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"clippy\",\"optional\":true,\"req\":\">=0.0, <0.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.5\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"hyper\",\"optional\":true,\"req\":\">=0.9, <0.11\"},{\"name\":\"iron\",\"optional\":true,\"req\":\">=0.4, <0.7\"},{\"name\":\"lazy_static\",\"optional\":true,\"req\":\"^1.2.0\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"mime\",\"req\":\"^0.3.14\"},{\"name\":\"mime_guess\",\"req\":\"^2.0.1\"},{\"name\":\"nickel\",\"optional\":true,\"req\":\">=0.10.1\"},{\"name\":\"quick-error\",\"optional\":true,\"req\":\"^1.2\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rocket\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"safemem\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"tiny_http\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"twoway\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"bench\":[],\"client\":[],\"default\":[\"client\",\"hyper\",\"iron\",\"mock\",\"nickel\",\"server\",\"tiny_http\"],\"mock\":[],\"nightly\":[],\"server\":[\"buf_redux\",\"httparse\",\"quick-error\",\"safemem\",\"twoway\"]}}", - "nix_0.29.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.155\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", - "nix_0.31.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.3\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2.1\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.186\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[\"poll\"],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"syslog\":[],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", - "nom_7.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"minimal-lexical\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"docsrs\":[],\"std\":[\"alloc\",\"memchr/std\",\"minimal-lexical/std\"]}}", - "notify-types_2.1.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.89\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"}],\"features\":{\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"serialization-compat-6\":[]}}", - "notify_8.2.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.7.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"crossbeam-channel\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"flume\",\"optional\":true,\"req\":\"^0.11.1\"},{\"name\":\"fsevent-sys\",\"optional\":true,\"req\":\"^4.0.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"default_features\":false,\"name\":\"inotify\",\"req\":\"^0.11.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"name\":\"kqueue\",\"req\":\"^1.1.1\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"name\":\"kqueue\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.4\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\"},{\"name\":\"notify-types\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.0\"},{\"kind\":\"dev\",\"name\":\"trash\",\"req\":\"^5.2.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"walkdir\",\"req\":\"^2.4.0\"},{\"features\":[\"Win32_System_Threading\",\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_WindowsProgramming\",\"Win32_System_IO\"],\"name\":\"windows-sys\",\"req\":\"^0.60.1\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"macos_fsevent\"],\"macos_fsevent\":[\"fsevent-sys\"],\"macos_kqueue\":[\"kqueue\",\"mio\"],\"serde\":[\"notify-types/serde\"],\"serialization-compat-6\":[\"notify-types/serialization-compat-6\"]}}", - "nu-ansi-term_0.50.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.152\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.94\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_Security\"],\"name\":\"windows\",\"package\":\"windows-sys\",\"req\":\">=0.59, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"derive_serde_style\":[\"serde\"],\"gnu_legacy\":[],\"std\":[]}}", - "num-bigint-dig_0.8.6": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"spin_no_std\"],\"name\":\"lazy_static\",\"req\":\"^1.2.0\"},{\"name\":\"libm\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"name\":\"num-iter\",\"req\":\"^0.1.37\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_isaac\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"u64_digit\"],\"fuzz\":[\"arbitrary\",\"smallvec/arbitrary\"],\"i128\":[],\"nightly\":[],\"prime\":[\"rand/std_rng\"],\"std\":[\"num-integer/std\",\"num-traits/std\",\"smallvec/write\",\"rand/std\",\"serde/std\"],\"u64_digit\":[]}}", - "num-bigint_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\"],\"quickcheck\":[\"dep:quickcheck\"],\"rand\":[\"dep:rand\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-integer/std\",\"num-traits/std\"]}}", - "num-complex_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytecheck\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"bytecheck\":[\"dep:bytecheck\"],\"bytemuck\":[\"dep:bytemuck\"],\"default\":[\"std\"],\"libm\":[\"num-traits/libm\"],\"rand\":[\"dep:rand\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-traits/std\"]}}", - "num-conv_0.2.1": "{\"dependencies\":[],\"features\":{}}", - "num-integer_0.1.46": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.11\"}],\"features\":{\"default\":[\"std\"],\"i128\":[],\"std\":[\"num-traits/std\"]}}", - "num-iter_0.1.45": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.11\"}],\"features\":{\"default\":[\"std\"],\"i128\":[],\"std\":[\"num-integer/std\",\"num-traits/std\"]}}", - "num-rational_0.4.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.42\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"num-bigint\",\"std\"],\"num-bigint\":[\"dep:num-bigint\"],\"num-bigint-std\":[\"num-bigint/std\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-bigint?/std\",\"num-integer/std\",\"num-traits/std\"]}}", - "num-traits_0.2.19": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"name\":\"libm\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"default\":[\"std\"],\"i128\":[],\"libm\":[\"dep:libm\"],\"std\":[]}}", - "num_0.4.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.5\"},{\"default_features\":false,\"name\":\"num-complex\",\"req\":\"^0.4.6\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-iter\",\"req\":\"^0.1.45\"},{\"default_features\":false,\"name\":\"num-rational\",\"req\":\"^0.4.2\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.19\"}],\"features\":{\"alloc\":[\"dep:num-bigint\",\"num-rational/num-bigint\"],\"default\":[\"std\"],\"libm\":[\"num-complex/libm\",\"num-traits/libm\"],\"num-bigint\":[\"dep:num-bigint\"],\"rand\":[\"num-bigint/rand\",\"num-complex/rand\"],\"serde\":[\"num-bigint/serde\",\"num-complex/serde\",\"num-rational/serde\"],\"std\":[\"dep:num-bigint\",\"num-bigint/std\",\"num-complex/std\",\"num-integer/std\",\"num-iter/std\",\"num-rational/std\",\"num-rational/num-bigint-std\",\"num-traits/std\"]}}", - "num_cpus_1.17.0": "{\"dependencies\":[{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.26\",\"target\":\"cfg(not(windows))\"}],\"features\":{}}", - "num_threads_0.1.7": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.107\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\", target_os = \\\"freebsd\\\"))\"}],\"features\":{}}", - "number_prefix_0.4.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "oauth2_5.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13\"},{\"name\":\"base64\",\"req\":\">=0.21, <0.23\"},{\"default_features\":false,\"features\":[\"clock\",\"serde\",\"std\",\"wasmbind\"],\"name\":\"chrono\",\"req\":\"^0.4.31\"},{\"name\":\"curl\",\"optional\":true,\"req\":\"^0.4.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"js\"],\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"req\":\"^0.1.2\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"name\":\"ureq\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.1\"},{\"features\":[\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.10\"}],\"features\":{\"default\":[\"reqwest\",\"rustls-tls\"],\"native-tls\":[\"reqwest/native-tls\"],\"pkce-plain\":[],\"reqwest-blocking\":[\"reqwest/blocking\"],\"rustls-tls\":[\"reqwest/rustls-tls\"],\"timing-resistant-secret-traits\":[]}}", - "object_0.37.3": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.2\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"ruzstd\",\"optional\":true,\"req\":\"^0.8.1\"},{\"default_features\":false,\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.236.0\"}],\"features\":{\"all\":[\"read\",\"write\",\"build\",\"std\",\"compression\",\"wasm\"],\"archive\":[],\"build\":[\"build_core\",\"write_std\",\"elf\"],\"build_core\":[\"read_core\",\"write_core\"],\"cargo-all\":[],\"coff\":[],\"compression\":[\"dep:flate2\",\"dep:ruzstd\",\"std\"],\"default\":[\"read\",\"compression\"],\"doc\":[\"read_core\",\"write_std\",\"build_core\",\"std\",\"compression\",\"archive\",\"coff\",\"elf\",\"macho\",\"pe\",\"wasm\",\"xcoff\"],\"elf\":[],\"macho\":[],\"pe\":[\"coff\"],\"read\":[\"read_core\",\"archive\",\"coff\",\"elf\",\"macho\",\"pe\",\"xcoff\",\"unaligned\"],\"read_core\":[],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"memchr/rustc-dep-of-std\"],\"std\":[\"memchr/std\"],\"unaligned\":[],\"unstable\":[],\"unstable-all\":[\"all\",\"unstable\"],\"wasm\":[\"dep:wasmparser\"],\"write\":[\"write_std\",\"coff\",\"elf\",\"macho\",\"pe\",\"xcoff\"],\"write_core\":[\"dep:crc32fast\",\"dep:indexmap\",\"dep:hashbrown\"],\"write_std\":[\"write_core\",\"std\",\"indexmap?/std\",\"crc32fast?/std\"],\"xcoff\":[]}}", - "oci-client_0.16.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"assert-json-diff\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.5\"},{\"kind\":\"dev\",\"name\":\"docker_credential\",\"req\":\"^1.3\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12\"},{\"name\":\"http\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"http-auth\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"jsonwebtoken\",\"req\":\"^10.2\"},{\"features\":[\"rust_crypto\"],\"kind\":\"dev\",\"name\":\"jsonwebtoken\",\"req\":\"^10.2\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"oci-spec\",\"req\":\"^0.9\"},{\"name\":\"olpc-cjson\",\"req\":\"^0.1\"},{\"name\":\"regex\",\"req\":\"^1.11\"},{\"default_features\":false,\"features\":[\"json\",\"query\",\"stream\"],\"name\":\"reqwest\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.21\"},{\"kind\":\"dev\",\"name\":\"testcontainers\",\"req\":\"^0.27\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"macros\",\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"macros\",\"fs\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"unicase\",\"req\":\"^2.8\"}],\"features\":{\"default\":[\"rustls-tls\",\"test-registry\"],\"hickory-dns\":[\"reqwest/hickory-dns\"],\"native-tls\":[\"reqwest/native-tls\"],\"rustls-tls\":[\"reqwest/rustls\"],\"test-registry\":[]}}", - "oci-spec_0.9.0": "{\"dependencies\":[{\"name\":\"const_format\",\"req\":\"^0.2\"},{\"name\":\"derive_builder\",\"req\":\"^0.20.0\"},{\"name\":\"getset\",\"req\":\"^0.1.3\"},{\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"name\":\"regex\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.129\"},{\"name\":\"serde_json\",\"req\":\"^1.0.66\"},{\"features\":[\"preserve_order\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.66\"},{\"name\":\"strum\",\"req\":\"^0.27.0\"},{\"name\":\"strum_macros\",\"req\":\"^0.27.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.23.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"}],\"features\":{\"default\":[\"distribution\",\"image\",\"runtime\"],\"distribution\":[],\"image\":[],\"proptests\":[\"quickcheck\"],\"runtime\":[]}}", - "oid-registry_0.7.1": "{\"dependencies\":[{\"name\":\"asn1-rs\",\"req\":\"^0.6\"}],\"features\":{\"crypto\":[\"kdf\",\"pkcs1\",\"pkcs7\",\"pkcs9\",\"pkcs12\",\"nist_algs\",\"x962\"],\"default\":[\"registry\"],\"kdf\":[],\"ms_spc\":[],\"nist_algs\":[],\"pkcs1\":[],\"pkcs12\":[],\"pkcs7\":[],\"pkcs9\":[],\"registry\":[],\"x500\":[],\"x509\":[],\"x962\":[]}}", - "olpc-cjson_0.1.4": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"unicode-normalization\",\"req\":\"^0.1\"}],\"features\":{}}", - "once_cell_1.21.4": "{\"dependencies\":[{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"parking_lot_core\",\"optional\":true,\"req\":\"^0.9.10\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.6\"}],\"features\":{\"alloc\":[\"race\"],\"atomic-polyfill\":[\"critical-section\"],\"critical-section\":[\"dep:critical-section\",\"portable-atomic\"],\"default\":[\"std\"],\"parking_lot\":[\"dep:parking_lot_core\"],\"portable-atomic\":[\"dep:portable-atomic\"],\"race\":[],\"std\":[\"alloc\"],\"unstable\":[]}}", - "once_cell_polyfill_1.70.2": "{\"dependencies\":[],\"features\":{\"default\":[]}}", - "openssh_0.11.6": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.137\"},{\"name\":\"once_cell\",\"req\":\"^1.8.0\"},{\"name\":\"openssh-mux-client\",\"optional\":true,\"req\":\"^0.17.6\"},{\"kind\":\"dev\",\"name\":\"openssh-sftp-client\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"shell-escape\",\"req\":\"^0.1.5\"},{\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"process\",\"io-util\",\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.36.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"default\":[\"process-mux\"],\"native-mux\":[\"openssh-mux-client\"],\"process-mux\":[]}}", - "openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}", - "opentelemetry-otlp_0.32.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"http\",\"optional\":true,\"req\":\"^1.1\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"opentelemetry\",\"req\":\"^0.32\"},{\"default_features\":false,\"name\":\"opentelemetry-http\",\"optional\":true,\"req\":\"^0.32\"},{\"default_features\":false,\"name\":\"opentelemetry-proto\",\"req\":\"^0.32\"},{\"default_features\":false,\"name\":\"opentelemetry_sdk\",\"req\":\"^0.32\"},{\"default_features\":false,\"features\":[\"trace\",\"testing\"],\"kind\":\"dev\",\"name\":\"opentelemetry_sdk\",\"req\":\"^0.32\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"temp-env\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"sync\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"net\"],\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"tonic\",\"optional\":true,\"req\":\"^0.14.1\"},{\"default_features\":false,\"features\":[\"router\",\"server\"],\"kind\":\"dev\",\"name\":\"tonic\",\"req\":\"^0.14.1\"},{\"name\":\"tonic-types\",\"optional\":true,\"req\":\"^0.14.1\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"default\":[\"http-proto\",\"reqwest-blocking-client\",\"trace\",\"metrics\",\"logs\",\"internal-logs\"],\"experimental-grpc-retry\":[\"grpc-tonic\",\"opentelemetry_sdk/experimental_async_runtime\",\"opentelemetry_sdk/rt-tokio\"],\"experimental-http-retry\":[\"opentelemetry_sdk/experimental_async_runtime\",\"opentelemetry_sdk/rt-tokio\",\"tokio\",\"httpdate\"],\"grpc-tonic\":[\"tonic\",\"tonic-types\",\"prost\",\"http\",\"tokio\",\"opentelemetry-proto/gen-tonic\"],\"gzip-http\":[\"flate2\"],\"gzip-tonic\":[\"tonic/gzip\"],\"http-json\":[\"serde_json\",\"prost\",\"opentelemetry-http\",\"opentelemetry-proto/gen-tonic-messages\",\"opentelemetry-proto/with-serde\",\"http\",\"trace\",\"metrics\"],\"http-proto\":[\"prost\",\"opentelemetry-http\",\"opentelemetry-proto/gen-tonic-messages\",\"http\",\"trace\",\"metrics\"],\"hyper-client\":[\"opentelemetry-http/hyper\"],\"integration-testing\":[\"tonic\",\"prost\",\"tokio/full\",\"trace\",\"logs\"],\"internal-logs\":[\"opentelemetry_sdk/internal-logs\",\"opentelemetry/internal-logs\"],\"logs\":[\"opentelemetry/logs\",\"opentelemetry_sdk/logs\",\"opentelemetry-proto/logs\"],\"metrics\":[\"opentelemetry/metrics\",\"opentelemetry_sdk/metrics\",\"opentelemetry-proto/metrics\"],\"reqwest-blocking-client\":[\"reqwest/blocking\",\"opentelemetry-http/reqwest-blocking\"],\"reqwest-client\":[\"reqwest\",\"opentelemetry-http/reqwest\"],\"reqwest-rustls\":[\"reqwest\",\"opentelemetry-http/reqwest-rustls\"],\"reqwest-rustls-webpki-roots\":[\"reqwest\",\"opentelemetry-http/reqwest-rustls-webpki-roots\"],\"serialize\":[\"serde\",\"serde_json\"],\"tls\":[\"tls-ring\"],\"tls-aws-lc\":[\"tonic/tls-aws-lc\"],\"tls-provider-agnostic\":[\"tonic/_tls-any\"],\"tls-ring\":[\"tonic/tls-ring\"],\"tls-roots\":[\"tonic/tls-native-roots\"],\"tls-webpki-roots\":[\"tonic/tls-webpki-roots\"],\"trace\":[\"opentelemetry/trace\",\"opentelemetry_sdk/trace\",\"opentelemetry-proto/trace\"],\"zstd-http\":[\"zstd\"],\"zstd-tonic\":[\"tonic/zstd\"]}}", - "opentelemetry-proto_0.32.0": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"const-hex\",\"optional\":true,\"req\":\"^1.14.1\"},{\"default_features\":false,\"name\":\"opentelemetry\",\"req\":\"^0.32\"},{\"default_features\":false,\"features\":[\"testing\"],\"kind\":\"dev\",\"name\":\"opentelemetry\",\"req\":\"^0.32\"},{\"default_features\":false,\"name\":\"opentelemetry_sdk\",\"req\":\"^0.32\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.14\"},{\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"serde_derive\",\"std\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"},{\"default_features\":false,\"features\":[\"codegen\"],\"name\":\"tonic\",\"optional\":true,\"req\":\"^0.14.1\"},{\"name\":\"tonic-prost\",\"optional\":true,\"req\":\"^0.14.1\"},{\"kind\":\"dev\",\"name\":\"tonic-prost-build\",\"req\":\"^0.14.1\"}],\"features\":{\"default\":[\"full\"],\"full\":[\"gen-tonic\",\"trace\",\"logs\",\"metrics\",\"zpages\",\"with-serde\",\"internal-logs\"],\"gen-tonic\":[\"gen-tonic-messages\",\"tonic\",\"tonic-prost\",\"tonic/channel\"],\"gen-tonic-messages\":[\"prost\"],\"internal-logs\":[\"opentelemetry/internal-logs\"],\"logs\":[\"opentelemetry/logs\",\"opentelemetry_sdk/logs\"],\"metrics\":[\"opentelemetry/metrics\",\"opentelemetry_sdk/metrics\"],\"profiles\":[],\"testing\":[\"opentelemetry/testing\"],\"trace\":[\"opentelemetry/trace\",\"opentelemetry_sdk/trace\"],\"with-schemars\":[\"schemars\"],\"with-serde\":[\"serde\",\"const-hex\",\"base64\"],\"zpages\":[\"trace\"]}}", - "opentelemetry_0.32.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"js-sys\",\"req\":\"^0.3.63\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"os_rng\",\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"thiserror\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\">=0.1.40\"}],\"features\":{\"default\":[\"trace\",\"metrics\",\"logs\",\"internal-logs\",\"futures\"],\"experimental_metrics_bound_instruments\":[\"metrics\"],\"futures\":[\"futures-core\",\"futures-sink\",\"pin-project-lite\"],\"internal-logs\":[\"tracing\"],\"logs\":[],\"metrics\":[],\"testing\":[\"trace\"],\"trace\":[\"futures\",\"thiserror\"]}}", - "opentelemetry_sdk_0.32.1": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"name\":\"futures-executor\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\",\"sink\",\"async-await-macro\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"http\",\"optional\":true,\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"opentelemetry\",\"req\":\"^0.32\"},{\"default_features\":false,\"name\":\"opentelemetry-http\",\"optional\":true,\"req\":\"^0.32\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"fallback\"],\"name\":\"portable-atomic\",\"req\":\"^1\",\"target\":\"cfg(not(target_has_atomic = \\\"64\\\"))\"},{\"features\":[\"flamegraph\",\"criterion\"],\"kind\":\"dev\",\"name\":\"pprof\",\"req\":\"^0.14\",\"target\":\"cfg(not(target_os = \\\"windows\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\",\"small_rng\",\"os_rng\",\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.23.0\"},{\"default_features\":false,\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"temp-env\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"url\",\"optional\":true,\"req\":\"^2.5\"}],\"features\":{\"bench_profiling\":[],\"default\":[\"trace\",\"metrics\",\"logs\",\"internal-logs\"],\"experimental_async_runtime\":[],\"experimental_logs_batch_log_processor_with_async_runtime\":[\"logs\",\"experimental_async_runtime\"],\"experimental_metrics_bound_instruments\":[\"metrics\",\"opentelemetry/experimental_metrics_bound_instruments\"],\"experimental_metrics_custom_reader\":[\"metrics\"],\"experimental_metrics_disable_name_validation\":[\"metrics\"],\"experimental_metrics_periodicreader_with_async_runtime\":[\"metrics\",\"experimental_async_runtime\"],\"experimental_trace_batch_span_processor_with_async_runtime\":[\"tokio/sync\",\"trace\",\"experimental_async_runtime\"],\"internal-logs\":[\"opentelemetry/internal-logs\"],\"jaeger_remote_sampler\":[\"trace\",\"opentelemetry-http\",\"http\",\"serde\",\"serde_json\",\"url\",\"experimental_async_runtime\"],\"logs\":[\"opentelemetry/logs\"],\"metrics\":[\"opentelemetry/metrics\"],\"rt-tokio\":[\"tokio/rt\",\"tokio/time\",\"tokio-stream\",\"experimental_async_runtime\"],\"rt-tokio-current-thread\":[\"tokio/rt\",\"tokio/time\",\"tokio-stream\",\"experimental_async_runtime\"],\"spec_unstable_metrics_views\":[\"metrics\"],\"testing\":[\"opentelemetry/testing\",\"trace\",\"metrics\",\"logs\",\"tokio/sync\"],\"trace\":[\"opentelemetry/trace\",\"rand\",\"percent-encoding\"]}}", - "ordered-float_2.10.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.1\"},{\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.3\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"schemars\",\"optional\":true,\"req\":\"^0.6.5\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"randtest\":[\"rand/std\",\"rand/std_rng\"],\"std\":[\"num-traits/std\"]}}", - "outref_0.5.2": "{\"dependencies\":[],\"features\":{}}", - "owo-colors_4.3.0": "{\"dependencies\":[{\"name\":\"supports-color\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-color-2\",\"optional\":true,\"package\":\"supports-color\",\"req\":\"^2.0\"}],\"features\":{\"alloc\":[],\"supports-colors\":[\"dep:supports-color-2\",\"supports-color\"]}}", - "p256_0.14.0-rc.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"der\"],\"name\":\"ecdsa-core\",\"optional\":true,\"package\":\"ecdsa\",\"req\":\"^0.17.0-rc.17\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"ecdsa-core\",\"package\":\"ecdsa\",\"req\":\"^0.17.0-rc.17\"},{\"default_features\":false,\"features\":[\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.14.0-rc.33\"},{\"name\":\"hash2curve\",\"optional\":true,\"req\":\"^0.14.0-rc.12\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"primefield\",\"optional\":true,\"req\":\"^0.14.0-rc.10\"},{\"kind\":\"dev\",\"name\":\"primefield\",\"req\":\"^0.14.0-rc.10\"},{\"name\":\"primeorder\",\"optional\":true,\"req\":\"^0.14.0-rc.10\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"primeorder\",\"req\":\"^0.14.0-rc.10\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"}],\"features\":{\"alloc\":[\"ecdsa-core?/alloc\",\"elliptic-curve/alloc\",\"primeorder?/alloc\"],\"arithmetic\":[\"dep:primefield\",\"dep:primeorder\",\"elliptic-curve/arithmetic\"],\"default\":[\"arithmetic\",\"ecdsa\",\"pem\",\"precomputed-tables\",\"std\"],\"digest\":[\"ecdsa-core/digest\",\"ecdsa-core/hazmat\"],\"ecdh\":[\"arithmetic\",\"elliptic-curve/ecdh\"],\"ecdsa\":[\"arithmetic\",\"ecdsa-core/algorithm\",\"sha256\"],\"expose-field\":[\"arithmetic\"],\"getrandom\":[\"elliptic-curve/getrandom\"],\"group-digest\":[\"hash2curve\",\"sha2\"],\"hash2curve\":[\"arithmetic\",\"dep:hash2curve\",\"primeorder/hash2curve\"],\"oprf\":[\"group-digest\"],\"pem\":[\"elliptic-curve/pem\",\"ecdsa-core/pem\",\"pkcs8\"],\"pkcs8\":[\"ecdsa-core?/pkcs8\",\"elliptic-curve/pkcs8\"],\"precomputed-tables\":[\"arithmetic\",\"primeorder/basepoint-table\"],\"serde\":[\"ecdsa-core?/serde\",\"elliptic-curve/serde\",\"primeorder?/serde\",\"serdect\"],\"sha256\":[\"digest\",\"sha2\"],\"std\":[\"alloc\",\"ecdsa-core?/std\",\"elliptic-curve/std\",\"getrandom\"],\"test-vectors\":[\"dep:hex-literal\"]}}", - "p384_0.14.0-rc.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"der\"],\"name\":\"ecdsa-core\",\"optional\":true,\"package\":\"ecdsa\",\"req\":\"^0.17.0-rc.17\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"ecdsa-core\",\"package\":\"ecdsa\",\"req\":\"^0.17.0-rc.17\"},{\"default_features\":false,\"features\":[\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.14.0-rc.33\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.3\",\"target\":\"cfg(not(p384_backend = \\\"bignum\\\"))\"},{\"name\":\"hash2curve\",\"optional\":true,\"req\":\"^0.14.0-rc.12\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"primefield\",\"optional\":true,\"req\":\"^0.14.0-rc.10\"},{\"name\":\"primeorder\",\"optional\":true,\"req\":\"^0.14.0-rc.10\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"primeorder\",\"req\":\"^0.14.0-rc.10\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"}],\"features\":{\"alloc\":[\"ecdsa-core?/alloc\",\"elliptic-curve/alloc\",\"primeorder?/alloc\"],\"arithmetic\":[\"dep:primefield\",\"dep:primeorder\",\"elliptic-curve/arithmetic\",\"elliptic-curve/digest\"],\"default\":[\"arithmetic\",\"ecdsa\",\"pem\",\"precomputed-tables\",\"std\"],\"digest\":[\"ecdsa-core/digest\",\"ecdsa-core/hazmat\"],\"ecdh\":[\"arithmetic\",\"elliptic-curve/ecdh\"],\"ecdsa\":[\"arithmetic\",\"ecdsa-core/algorithm\",\"sha384\"],\"expose-field\":[\"arithmetic\"],\"getrandom\":[\"ecdsa-core?/getrandom\",\"elliptic-curve/getrandom\"],\"group-digest\":[\"hash2curve\",\"sha2\"],\"hash2curve\":[\"arithmetic\",\"dep:hash2curve\",\"primeorder/hash2curve\"],\"oprf\":[\"group-digest\"],\"pem\":[\"elliptic-curve/pem\",\"ecdsa-core/pem\",\"pkcs8\"],\"pkcs8\":[\"ecdsa-core/pkcs8\",\"elliptic-curve/pkcs8\"],\"precomputed-tables\":[\"arithmetic\",\"primeorder/basepoint-table\"],\"serde\":[\"ecdsa-core?/serde\",\"elliptic-curve/serde\",\"primeorder?/serde\",\"serdect\"],\"sha384\":[\"digest\",\"sha2\"],\"std\":[\"alloc\",\"ecdsa-core?/std\",\"elliptic-curve/std\",\"getrandom\"],\"test-vectors\":[\"hex-literal\"]}}", - "p521_0.14.0-rc.10": "{\"dependencies\":[{\"name\":\"base16ct\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"der\"],\"name\":\"ecdsa-core\",\"optional\":true,\"package\":\"ecdsa\",\"req\":\"^0.17.0-rc.17\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"ecdsa-core\",\"package\":\"ecdsa\",\"req\":\"^0.17.0-rc.17\"},{\"default_features\":false,\"features\":[\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.14.0-rc.33\"},{\"name\":\"hash2curve\",\"optional\":true,\"req\":\"^0.14.0-rc.12\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"primefield\",\"optional\":true,\"req\":\"^0.14.0-rc.10\"},{\"name\":\"primeorder\",\"optional\":true,\"req\":\"^0.14.0-rc.10\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"primeorder\",\"req\":\"^0.14.0-rc.10\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"}],\"features\":{\"alloc\":[\"ecdsa-core?/alloc\",\"elliptic-curve/alloc\",\"primeorder?/alloc\"],\"arithmetic\":[\"dep:primefield\",\"dep:primeorder\"],\"default\":[\"arithmetic\",\"ecdsa\",\"pem\",\"precomputed-tables\",\"std\"],\"digest\":[\"ecdsa-core/digest\",\"ecdsa-core/hazmat\"],\"ecdh\":[\"arithmetic\",\"elliptic-curve/ecdh\"],\"ecdsa\":[\"arithmetic\",\"ecdsa-core/algorithm\",\"sha512\"],\"expose-field\":[\"arithmetic\"],\"getrandom\":[\"ecdsa-core?/getrandom\",\"elliptic-curve/getrandom\"],\"group-digest\":[\"hash2curve\",\"dep:sha2\"],\"hash2curve\":[\"arithmetic\",\"dep:hash2curve\",\"primeorder/hash2curve\"],\"oprf\":[\"group-digest\"],\"pem\":[\"elliptic-curve/pem\",\"pkcs8\"],\"pkcs8\":[\"ecdsa-core?/pkcs8\",\"elliptic-curve/pkcs8\"],\"precomputed-tables\":[\"arithmetic\",\"primeorder/basepoint-table\"],\"serde\":[\"ecdsa-core?/serde\",\"elliptic-curve/serde\",\"primeorder?/serde\",\"serdect\"],\"sha512\":[\"digest\",\"dep:sha2\"],\"std\":[\"alloc\",\"ecdsa-core?/std\",\"elliptic-curve/std\",\"getrandom\"],\"test-vectors\":[\"dep:hex-literal\"]}}", - "pageant_0.2.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.4\",\"target\":\"cfg(windows)\"},{\"name\":\"bytes\",\"req\":\"^1.7\",\"target\":\"cfg(windows)\"},{\"name\":\"delegate\",\"req\":\"^0.13\",\"target\":\"cfg(windows)\"},{\"name\":\"futures\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4.11\",\"target\":\"cfg(windows)\"},{\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(windows)\"},{\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\",\"target\":\"cfg(windows)\"},{\"name\":\"thiserror\",\"req\":\"^1.0.30\"},{\"name\":\"tokio\",\"req\":\"^1.17.0\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Security\"],\"name\":\"windows\",\"req\":\"^0.62\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-strings\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"wmmessage\",\"namedpipes\"],\"namedpipes\":[\"tokio/net\",\"tokio/time\",\"dep:sha2\",\"dep:windows-strings\",\"windows/Win32_Security_Authentication_Identity\",\"windows/Win32_Security_Cryptography\"],\"wmmessage\":[\"tokio/rt\",\"tokio/io-util\",\"windows/Win32_UI_WindowsAndMessaging\",\"windows/Win32_System_Memory\",\"windows/Win32_System_Threading\",\"windows/Win32_System_DataExchange\"]}}", - "parking_2.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{}}", - "parking_lot_0.12.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"name\":\"lock_api\",\"req\":\"^0.4.14\"},{\"name\":\"parking_lot_core\",\"req\":\"^0.9.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"}],\"features\":{\"arc_lock\":[\"lock_api/arc_lock\"],\"deadlock_detection\":[\"parking_lot_core/deadlock_detection\"],\"default\":[],\"hardware-lock-elision\":[],\"nightly\":[\"parking_lot_core/nightly\",\"lock_api/nightly\"],\"owning_ref\":[\"lock_api/owning_ref\"],\"send_guard\":[],\"serde\":[\"lock_api/serde\"]}}", - "parking_lot_core_0.9.12": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.60\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.95\",\"target\":\"cfg(unix)\"},{\"name\":\"petgraph\",\"optional\":true,\"req\":\"^0.6.0\"},{\"name\":\"redox_syscall\",\"req\":\"^0.5\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"name\":\"smallvec\",\"req\":\"^1.6.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"deadlock_detection\":[\"petgraph\",\"backtrace\"],\"nightly\":[]}}", - "password-hash_0.6.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"phc\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"alloc\":[\"phc?/alloc\"],\"getrandom\":[\"dep:getrandom\",\"phc?/getrandom\"],\"rand_core\":[\"dep:rand_core\",\"phc?/rand_core\"]}}", - "paste_1.0.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"paste-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", - "pbkdf2_0.12.2": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.7\"},{\"default_features\":false,\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"hmac\"],\"parallel\":[\"rayon\",\"std\"],\"simple\":[\"hmac\",\"password-hash\",\"sha2\"],\"std\":[\"password-hash/std\"]}}", - "pbkdf2_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"belt-hash\",\"req\":\"^0.2\"},{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.13\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"base64\"],\"name\":\"mcf\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.11\"}],\"features\":{\"alloc\":[\"mcf?/alloc\",\"password-hash?/alloc\"],\"default\":[\"hmac\"],\"getrandom\":[\"password-hash/getrandom\"],\"kdf\":[\"sha2\",\"dep:kdf\"],\"mcf\":[\"sha2\",\"password-hash\",\"dep:mcf\"],\"phc\":[\"password-hash/phc\",\"sha2\"],\"rand_core\":[\"password-hash/rand_core\"],\"sha2\":[\"hmac\",\"dep:sha2\"]}}", - "pem-rfc7468_0.7.0": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1.4\"}],\"features\":{\"alloc\":[\"base64ct/alloc\"],\"std\":[\"alloc\",\"base64ct/std\"]}}", - "pem-rfc7468_1.0.0": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1.4\"}],\"features\":{\"alloc\":[\"base64ct/alloc\"],\"std\":[\"alloc\",\"base64ct/std\"]}}", - "pem_3.0.6": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"base64/std\",\"serde_core?/std\"]}}", - "percent-encoding_2.3.2": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "pest_2.8.6": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.4.0\"},{\"features\":[\"fancy\"],\"name\":\"miette\",\"optional\":true,\"req\":\"^7.2.0\"},{\"features\":[\"fancy\"],\"kind\":\"dev\",\"name\":\"miette\",\"req\":\"^7.2.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.85\"},{\"default_features\":false,\"name\":\"ucd-trie\",\"req\":\"^0.1.5\"}],\"features\":{\"const_prec_climber\":[],\"default\":[\"std\",\"memchr\"],\"miette-error\":[\"std\",\"pretty-print\",\"dep:miette\"],\"pretty-print\":[\"dep:serde\",\"dep:serde_json\"],\"std\":[\"ucd-trie/std\"]}}", - "pest_derive_2.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"pest\",\"req\":\"^2.8.6\"},{\"default_features\":false,\"name\":\"pest_generator\",\"req\":\"^2.8.6\"}],\"features\":{\"default\":[\"std\"],\"grammar-extras\":[\"pest_generator/grammar-extras\"],\"not-bootstrap-in-src\":[\"pest_generator/not-bootstrap-in-src\"],\"std\":[\"pest/std\",\"pest_generator/std\"]}}", - "pest_generator_2.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"pest\",\"req\":\"^2.8.6\"},{\"name\":\"pest_meta\",\"req\":\"^2.8.6\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"default\":[\"std\"],\"export-internal\":[],\"grammar-extras\":[\"pest_meta/grammar-extras\"],\"not-bootstrap-in-src\":[\"pest_meta/not-bootstrap-in-src\"],\"std\":[\"pest/std\"]}}", - "pest_meta_2.8.6": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cargo\",\"optional\":true,\"req\":\"^0.81.0\"},{\"name\":\"pest\",\"req\":\"^2.8.6\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"default\":[],\"grammar-extras\":[],\"not-bootstrap-in-src\":[\"dep:cargo\"]}}", - "petgraph_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"name\":\"dot-parser\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"dot-parser-macros\",\"optional\":true,\"req\":\"^0.5.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.5.7\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\",\"dot_parser\"],\"default\":[\"std\",\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"dot_parser\":[\"std\",\"dep:dot-parser\",\"dep:dot-parser-macros\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"quickcheck\":[\"std\",\"dep:quickcheck\",\"graphmap\",\"stable_graph\"],\"rayon\":[\"std\",\"dep:rayon\",\"indexmap/rayon\",\"hashbrown/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[\"serde?/alloc\"],\"std\":[\"indexmap/std\"],\"unstable\":[\"generate\"]}}", - "petname_2.0.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"features\":[\"cargo\",\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.4\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\">=0.11\"},{\"kind\":\"build\",\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3\"}],\"features\":{\"default\":[\"clap\",\"default-rng\",\"default-words\"],\"default-rng\":[\"rand/std\",\"rand/std_rng\"],\"default-words\":[]}}", - "phc_0.6.1": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1.7\"},{\"name\":\"ctutils\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"alloc\":[\"base64ct/alloc\"],\"default\":[\"rand_core\"],\"getrandom\":[\"dep:getrandom\"],\"rand_core\":[\"dep:rand_core\"]}}", - "pin-project-internal_1.1.11": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", - "pin-project-lite_0.2.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", - "pin-project_1.1.11": "{\"dependencies\":[{\"name\":\"pin-project-internal\",\"req\":\"=1.1.11\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", - "pin-utils_0.1.0": "{\"dependencies\":[],\"features\":{}}", - "pkcs1_0.7.5": "{\"dependencies\":[{\"features\":[\"db\"],\"kind\":\"dev\",\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"spki\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"der/alloc\",\"zeroize\",\"pkcs8?/alloc\"],\"pem\":[\"alloc\",\"der/pem\",\"pkcs8?/pem\"],\"std\":[\"der/std\",\"alloc\"],\"zeroize\":[\"der/zeroize\"]}}", - "pkcs1_0.8.0-rc.4": "{\"dependencies\":[{\"features\":[\"db\"],\"kind\":\"dev\",\"name\":\"const-oid\",\"req\":\"^0.10\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.8.0-rc.9\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"spki\",\"req\":\"^0.8.0-rc.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"der/alloc\",\"zeroize\"],\"pem\":[\"alloc\",\"der/pem\"],\"std\":[\"der/std\",\"alloc\"],\"zeroize\":[\"der/zeroize\"]}}", - "pkcs5_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aes\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"aes\"],\"name\":\"aes-gcm\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"cbc\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"des\",\"optional\":true,\"req\":\"^0.9\"},{\"features\":[\"sys_rng\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"hmac\"],\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"scrypt\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"spki\",\"req\":\"^0.8\"}],\"features\":{\"3des\":[\"dep:des\",\"pbes2\"],\"alloc\":[],\"des-insecure\":[\"dep:des\",\"pbes2\"],\"getrandom\":[\"dep:getrandom\",\"rand_core\"],\"pbes2\":[\"dep:aes\",\"dep:cbc\",\"dep:pbkdf2\",\"dep:scrypt\",\"dep:sha2\",\"dep:aes-gcm\"],\"rand_core\":[\"dep:rand_core\"],\"sha1-insecure\":[\"dep:sha1\",\"pbes2\"]}}", - "pkcs8_0.10.2": "{\"dependencies\":[{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"spki\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"rand_core\"],\"getrandom\":[\"rand_core/getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", - "pkcs8_0.11.0": "{\"dependencies\":[{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.8.0-rc.12\"},{\"features\":[\"sys_rng\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"features\":[\"rand_core\"],\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"spki\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"dep:rand_core\"],\"getrandom\":[\"encryption\",\"pkcs5/getrandom\",\"dep:getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", - "pkg-config_0.3.33": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", - "plain_0.2.3": "{\"dependencies\":[],\"features\":{}}", - "polling_3.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"concurrent-queue\",\"req\":\"^2.2.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"event\",\"fs\",\"pipe\",\"process\",\"std\",\"time\"],\"name\":\"rustix\",\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"fuchsia\\\", target_os = \\\"vxworks\\\"))\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3.17\",\"target\":\"cfg(all(unix, not(target_os=\\\"vita\\\")))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_LibraryLoader\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "poly1305_0.9.1": "{\"dependencies\":[{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"universal-hash\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{}}", - "polyval_0.7.3": "{\"dependencies\":[{\"name\":\"cpubits\",\"req\":\"^0.1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.9\",\"target\":\"cfg(any(unix, windows))\"},{\"name\":\"universal-hash\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"hazmat\":[]}}", - "portable-atomic-util_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"req\":\"^1.5.1\"}],\"features\":{\"alloc\":[],\"default\":[],\"std\":[\"alloc\"]}}", - "portable-atomic-util_0.2.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"req\":\"^1.5.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"}],\"features\":{\"alloc\":[],\"default\":[],\"std\":[\"alloc\"]}}", - "portable-atomic_1.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"^0.1\",\"target\":\"cfg(valgrind)\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"=0.8.16\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"=0.2.163\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"sptr\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"fallback\"],\"disable-fiq\":[],\"fallback\":[],\"float\":[],\"force-amo\":[],\"require-cas\":[],\"s-mode\":[],\"std\":[],\"unsafe-assume-privileged\":[],\"unsafe-assume-single-core\":[]}}", - "potential_utf_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.1\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"writeable/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"writeable\":[\"dep:writeable\"],\"zerovec\":[\"dep:zerovec\"]}}", - "powerfmt_0.2.0": "{\"dependencies\":[{\"name\":\"powerfmt-macros\",\"optional\":true,\"req\":\"=0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"macros\"],\"macros\":[\"dep:powerfmt-macros\"],\"std\":[\"alloc\"]}}", - "ppmd-rust_1.4.0": "{\"dependencies\":[],\"features\":{\"default\":[],\"unstable-tagged-offsets\":[]}}", - "ppv-lite86_0.2.21": "{\"dependencies\":[{\"features\":[\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.23\"}],\"features\":{\"default\":[\"std\"],\"no_simd\":[],\"simd\":[],\"std\":[]}}", - "prettyplease_0.2.37": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.105\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"extra-traits\",\"parsing\",\"printing\",\"visit-mut\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.105\"}],\"features\":{\"verbatim\":[\"syn/parsing\"]}}", - "primefield_0.14.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rand_core\",\"hybrid-array\",\"subtle\"],\"name\":\"bigint\",\"package\":\"crypto-bigint\",\"req\":\"^0.7.5\"},{\"features\":[\"rand_core\"],\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"ff\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"const-generics\"],\"name\":\"subtle\",\"req\":\"^2.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.7\"}],\"features\":{}}", - "primeorder_0.14.0-rc.10": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"arithmetic\",\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.14.0-rc.33\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"alloc\":[\"elliptic-curve/alloc\"],\"basepoint-table\":[\"elliptic-curve/basepoint-table\"],\"dev\":[],\"hash2curve\":[],\"serde\":[\"elliptic-curve/serde\",\"serdect\"],\"std\":[\"alloc\",\"elliptic-curve/std\"]}}", - "proc-macro-error-attr2_2.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"}],\"features\":{}}", - "proc-macro-error2_2.0.1": "{\"dependencies\":[{\"name\":\"proc-macro-error-attr2\",\"req\":\"=2.0.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.99\"}],\"features\":{\"default\":[\"syn-error\"],\"nightly\":[],\"syn-error\":[\"dep:syn\"]}}", - "proc-macro2_1.0.106": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"proc-macro\"],\"nightly\":[],\"proc-macro\":[],\"span-locations\":[]}}", - "prost-build_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.14\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"petgraph\",\"req\":\"^0.8\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^22\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}", - "prost-derive_0.14.3": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "prost-reflect_0.16.5": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.23.0\"},{\"name\":\"logos\",\"optional\":true,\"req\":\"^0.16.0\"},{\"name\":\"miette\",\"optional\":true,\"req\":\"^7.0.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"name\":\"prost\",\"req\":\"^0.14.0\"},{\"kind\":\"dev\",\"name\":\"prost-build\",\"req\":\"^0.14.0\"},{\"name\":\"prost-reflect-derive\",\"optional\":true,\"req\":\"^0.16.1\"},{\"name\":\"prost-types\",\"req\":\"^0.14.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.132\"},{\"name\":\"serde-value\",\"optional\":true,\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.106\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.4.2\"},{\"kind\":\"dev\",\"name\":\"yaml_serde\",\"req\":\"^0.10.3\"}],\"features\":{\"derive\":[\"dep:prost-reflect-derive\"],\"miette\":[\"dep:miette\"],\"serde\":[\"dep:serde\",\"dep:base64\",\"dep:serde-value\"],\"text-format\":[\"dep:logos\"]}}", - "prost-types_0.14.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"prost\",\"req\":\"^0.14.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\"],\"std\":[\"prost/std\"]}}", - "prost_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"std\":[]}}", - "protobuf-src_1.1.0+21.5": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autotools\",\"req\":\"^0.2.5\"}],\"features\":{}}", - "protoc-gen-prost_0.5.0": "{\"dependencies\":[{\"name\":\"once_cell\",\"req\":\"^1.21.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"prost\",\"req\":\"^0.14.1\"},{\"default_features\":false,\"name\":\"prost-build\",\"req\":\"^0.14.1\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.14.1\"},{\"default_features\":false,\"name\":\"regex\",\"req\":\"^1.11.1\"}],\"features\":{}}", - "protoc-gen-tonic_0.5.0": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"prettyplease\",\"req\":\"^0.2.37\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.103\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"prost\",\"req\":\"^0.14.1\"},{\"default_features\":false,\"name\":\"prost-build\",\"req\":\"^0.14.1\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.14.1\"},{\"name\":\"protoc-gen-prost\",\"req\":\"^0.5.0\"},{\"name\":\"quote\",\"req\":\"^1.0.42\"},{\"default_features\":false,\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"features\":[\"parsing\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.109\"},{\"name\":\"tonic-build\",\"req\":\"^0.14.1\"}],\"features\":{}}", - "pulldown-cmark-to-cmark_22.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.5\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"yansi\",\"req\":\"^1.0.1\"}],\"features\":{}}", - "pulldown-cmark_0.13.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"getopts\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"memchr\",\"req\":\"^2.5\"},{\"name\":\"pulldown-cmark-escape\",\"optional\":true,\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.6\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.61\"},{\"name\":\"unicase\",\"req\":\"^2.6\"}],\"features\":{\"default\":[\"getopts\",\"html\"],\"gen-tests\":[],\"html\":[\"pulldown-cmark-escape\"],\"simd\":[\"pulldown-cmark-escape?/simd\"]}}", - "pyo3-build-config_0.28.3": "{\"dependencies\":[{\"name\":\"python3-dll-a\",\"optional\":true,\"req\":\"^0.2.12\"},{\"kind\":\"build\",\"name\":\"python3-dll-a\",\"optional\":true,\"req\":\"^0.2.12\"},{\"name\":\"target-lexicon\",\"req\":\"^0.13.3\"},{\"kind\":\"build\",\"name\":\"target-lexicon\",\"req\":\"^0.13.3\"}],\"features\":{\"abi3\":[],\"abi3-py310\":[\"abi3-py311\"],\"abi3-py311\":[\"abi3-py312\"],\"abi3-py312\":[\"abi3-py313\"],\"abi3-py313\":[\"abi3-py314\"],\"abi3-py314\":[\"abi3\"],\"abi3-py37\":[\"abi3-py38\"],\"abi3-py38\":[\"abi3-py39\"],\"abi3-py39\":[\"abi3-py310\"],\"default\":[],\"extension-module\":[],\"generate-import-lib\":[\"dep:python3-dll-a\"],\"resolve-config\":[]}}", - "pyo3-ffi_0.28.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.62\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\"},{\"features\":[\"resolve-config\"],\"kind\":\"build\",\"name\":\"pyo3-build-config\",\"req\":\"=0.28.3\"}],\"features\":{\"abi3\":[\"pyo3-build-config/abi3\"],\"abi3-py310\":[\"abi3-py311\",\"pyo3-build-config/abi3-py310\"],\"abi3-py311\":[\"abi3-py312\",\"pyo3-build-config/abi3-py311\"],\"abi3-py312\":[\"abi3-py313\",\"pyo3-build-config/abi3-py312\"],\"abi3-py313\":[\"abi3-py314\",\"pyo3-build-config/abi3-py313\"],\"abi3-py314\":[\"abi3\",\"pyo3-build-config/abi3-py314\"],\"abi3-py37\":[\"abi3-py38\",\"pyo3-build-config/abi3-py37\"],\"abi3-py38\":[\"abi3-py39\",\"pyo3-build-config/abi3-py38\"],\"abi3-py39\":[\"abi3-py310\",\"pyo3-build-config/abi3-py39\"],\"default\":[],\"extension-module\":[\"pyo3-build-config/extension-module\"],\"generate-import-lib\":[\"pyo3-build-config/generate-import-lib\"]}}", - "pyo3-introspection_0.28.3": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1\"},{\"name\":\"goblin\",\"req\":\">=0.9, <0.11\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.12.0\"}],\"features\":{}}", - "pyo3-macros-backend_0.28.3": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"features\":[\"resolve-config\"],\"name\":\"pyo3-build-config\",\"req\":\"=0.28.3\"},{\"kind\":\"build\",\"name\":\"pyo3-build-config\",\"req\":\"=0.28.3\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.37\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"full\",\"extra-traits\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.59\"}],\"features\":{\"experimental-async\":[],\"experimental-inspect\":[]}}", - "pyo3-macros_0.28.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"pyo3-macros-backend\",\"req\":\"=0.28.3\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"experimental-async\":[\"pyo3-macros-backend/experimental-async\"],\"experimental-inspect\":[\"pyo3-macros-backend/experimental-inspect\"],\"multiple-pymethods\":[]}}", - "pyo3_0.28.3": "{\"dependencies\":[{\"name\":\"anyhow\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_approx_eq\",\"req\":\"^1.1.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.7\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.25\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.25\"},{\"default_features\":false,\"name\":\"chrono-tz\",\"optional\":true,\"req\":\">=0.10, <0.11\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\">=0.10, <0.11\"},{\"name\":\"either\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"eyre\",\"optional\":true,\"req\":\">=0.6.8, <0.7\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\">=0.15.0, <0.17\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\">=2.5.0, <3\"},{\"name\":\"inventory\",\"optional\":true,\"req\":\"^0.3.5\"},{\"name\":\"jiff-02\",\"optional\":true,\"package\":\"jiff\",\"req\":\"^0.2\"},{\"name\":\"libc\",\"req\":\"^0.2.62\"},{\"name\":\"lock_api\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.4\"},{\"name\":\"num-complex\",\"optional\":true,\"req\":\">=0.4.6, <0.5\"},{\"name\":\"num-rational\",\"optional\":true,\"req\":\"^0.4.1\"},{\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.16\"},{\"name\":\"once_cell\",\"req\":\"^1.21\"},{\"default_features\":false,\"name\":\"ordered-float\",\"optional\":true,\"req\":\"^5.0.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"features\":[\"arc_lock\"],\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.3\"},{\"name\":\"portable-atomic\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_has_atomic = \\\"64\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"features\":[\"resolve-config\"],\"kind\":\"build\",\"name\":\"pyo3-build-config\",\"req\":\"=0.28.3\"},{\"name\":\"pyo3-ffi\",\"req\":\"=0.28.3\"},{\"name\":\"pyo3-macros\",\"optional\":true,\"req\":\"=0.28.3\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.15\"},{\"kind\":\"dev\",\"name\":\"send_wrapper\",\"req\":\"^0.6\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.61\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.12.0\"},{\"default_features\":false,\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.38\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\">=1.0.115\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.0\"},{\"features\":[\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.10.0\"}],\"features\":{\"abi3\":[\"pyo3-build-config/abi3\",\"pyo3-ffi/abi3\"],\"abi3-py310\":[\"abi3-py311\",\"pyo3-build-config/abi3-py310\",\"pyo3-ffi/abi3-py310\"],\"abi3-py311\":[\"abi3-py312\",\"pyo3-build-config/abi3-py311\",\"pyo3-ffi/abi3-py311\"],\"abi3-py312\":[\"abi3-py313\",\"pyo3-build-config/abi3-py312\",\"pyo3-ffi/abi3-py312\"],\"abi3-py313\":[\"abi3-py314\",\"pyo3-build-config/abi3-py313\",\"pyo3-ffi/abi3-py313\"],\"abi3-py314\":[\"abi3\",\"pyo3-build-config/abi3-py314\",\"pyo3-ffi/abi3-py314\"],\"abi3-py37\":[\"abi3-py38\",\"pyo3-build-config/abi3-py37\",\"pyo3-ffi/abi3-py37\"],\"abi3-py38\":[\"abi3-py39\",\"pyo3-build-config/abi3-py38\",\"pyo3-ffi/abi3-py38\"],\"abi3-py39\":[\"abi3-py310\",\"pyo3-build-config/abi3-py39\",\"pyo3-ffi/abi3-py39\"],\"arc_lock\":[\"lock_api\",\"lock_api/arc_lock\",\"parking_lot?/arc_lock\"],\"auto-initialize\":[],\"bigdecimal\":[\"dep:bigdecimal\",\"num-bigint\"],\"chrono-local\":[\"chrono/clock\",\"dep:iana-time-zone\"],\"default\":[\"macros\"],\"experimental-async\":[\"macros\",\"pyo3-macros/experimental-async\"],\"experimental-inspect\":[\"pyo3-macros/experimental-inspect\"],\"extension-module\":[\"pyo3-ffi/extension-module\"],\"full\":[\"macros\",\"anyhow\",\"arc_lock\",\"bigdecimal\",\"bytes\",\"chrono\",\"chrono-local\",\"chrono-tz\",\"either\",\"experimental-async\",\"experimental-inspect\",\"eyre\",\"hashbrown\",\"indexmap\",\"jiff-02\",\"lock_api\",\"num-bigint\",\"num-complex\",\"num-rational\",\"ordered-float\",\"parking_lot\",\"py-clone\",\"rust_decimal\",\"serde\",\"smallvec\",\"time\",\"uuid\"],\"generate-import-lib\":[\"pyo3-ffi/generate-import-lib\"],\"macros\":[\"pyo3-macros\"],\"multiple-pymethods\":[\"inventory\",\"pyo3-macros/multiple-pymethods\"],\"nightly\":[],\"num-bigint\":[\"dep:num-bigint\",\"dep:num-traits\"],\"parking_lot\":[\"dep:parking_lot\",\"lock_api\"],\"py-clone\":[]}}", - "quanta_0.12.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"average\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"=0.3.3\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.5\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(not(any(target_os = \\\"windows\\\", target_arch = \\\"wasm32\\\")))\"},{\"name\":\"once_cell\",\"req\":\"^1.4\"},{\"default_features\":false,\"name\":\"prost-types\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"raw-cpuid\",\"req\":\"^11.0\",\"target\":\"cfg(target_arch = \\\"x86\\\")\"},{\"name\":\"raw-cpuid\",\"req\":\"^11.0\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"name\":\"wasi\",\"req\":\"^0.11\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Window\",\"Performance\"],\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"profileapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[\"flaky_tests\"],\"flaky_tests\":[],\"prost\":[\"prost-types\"]}}", - "quick-error_1.2.3": "{\"dependencies\":[],\"features\":{}}", - "quinn-proto_0.11.14": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"__rustls-post-quantum-test\":[],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}", - "quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}", - "quinn_0.11.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.22\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.11\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"crc\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"directories-next\",\"req\":\"^2\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.19\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"proto\",\"package\":\"quinn-proto\",\"req\":\"^0.11.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"smol\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"std-future\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"tracing\"],\"name\":\"udp\",\"package\":\"quinn-udp\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"proto/aws-lc-rs\"],\"aws-lc-rs-fips\":[\"proto/aws-lc-rs-fips\"],\"bloom\":[\"proto/bloom\"],\"default\":[\"log\",\"platform-verifier\",\"runtime-tokio\",\"rustls-ring\",\"bloom\"],\"lock_tracking\":[],\"log\":[\"tracing/log\",\"proto/log\",\"udp/log\"],\"platform-verifier\":[\"proto/platform-verifier\"],\"qlog\":[\"proto/qlog\"],\"ring\":[\"proto/ring\"],\"runtime-async-std\":[\"async-io\",\"async-std\"],\"runtime-smol\":[\"async-io\",\"smol\"],\"runtime-tokio\":[\"tokio/time\",\"tokio/rt\",\"tokio/net\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"aws-lc-rs\",\"proto/rustls-aws-lc-rs\",\"proto/aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"dep:rustls\",\"aws-lc-rs-fips\",\"proto/rustls-aws-lc-rs-fips\",\"proto/aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"ring\",\"proto/rustls-ring\",\"proto/ring\"]}}", - "quote_1.0.45": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\"]}}", - "r-efi_5.3.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"efiapi\":[],\"examples\":[\"native\"],\"native\":[],\"rustc-dep-of-std\":[\"core\"]}}", - "r-efi_6.0.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"native\":[],\"rustc-dep-of-std\":[\"core\"]}}", - "rand_0.10.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rng\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"chacha\":[\"dep:chacha20\"],\"default\":[\"std\",\"std_rng\",\"sys_rng\",\"thread_rng\"],\"log\":[],\"serde\":[\"dep:serde\"],\"simd_support\":[],\"std\":[\"alloc\",\"getrandom?/std\"],\"std_rng\":[\"dep:chacha20\"],\"sys_rng\":[\"dep:getrandom\",\"getrandom/sys_rng\"],\"thread_rng\":[\"std\",\"std_rng\",\"sys_rng\"],\"unbiased\":[]}}", - "rand_0.8.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.22\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"}],\"features\":{\"alloc\":[\"rand_core/alloc\"],\"default\":[\"std\",\"std_rng\"],\"getrandom\":[\"rand_core/getrandom\"],\"log\":[],\"min_const_gen\":[],\"nightly\":[],\"serde1\":[\"serde\",\"rand_core/serde1\"],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha/std\",\"alloc\",\"getrandom\",\"libc\"],\"std_rng\":[\"rand_chacha\"]}}", - "rand_0.9.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"std_rng\",\"os_rng\",\"small_rng\",\"thread_rng\"],\"log\":[],\"nightly\":[],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\",\"rand_core/serde\"],\"simd_support\":[],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha?/std\",\"alloc\"],\"std_rng\":[\"dep:rand_chacha\"],\"thread_rng\":[\"std\",\"std_rng\",\"os_rng\"],\"unbiased\":[]}}", - "rand_chacha_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.8\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde1\":[\"serde\"],\"simd\":[],\"std\":[\"ppv-lite86/std\"]}}", - "rand_chacha_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.14\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"os_rng\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\"],\"std\":[\"ppv-lite86/std\",\"rand_core/std\"]}}", - "rand_core_0.10.1": "{\"dependencies\":[],\"features\":{}}", - "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", - "rand_core_0.9.5": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"os_rng\":[\"dep:getrandom\"],\"serde\":[\"dep:serde\"],\"std\":[\"getrandom?/std\"]}}", - "rand_xoshiro_0.7.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", - "ratatui_0.26.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.71\"},{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.12\"},{\"kind\":\"dev\",\"name\":\"better-panic\",\"req\":\"^0.3.0\"},{\"name\":\"bitflags\",\"req\":\"^2.3\"},{\"name\":\"cassowary\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6.2\"},{\"name\":\"compact_str\",\"req\":\"^0.7.1\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"crossterm\",\"optional\":true,\"req\":\"^0.27\"},{\"kind\":\"dev\",\"name\":\"derive_builder\",\"req\":\"^0.20.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"fakeit\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"font8x8\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2\"},{\"name\":\"itertools\",\"req\":\"^0.12\"},{\"name\":\"lru\",\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"palette\",\"req\":\"^0.7.3\"},{\"name\":\"paste\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.109\"},{\"name\":\"stability\",\"req\":\"^0.2.0\"},{\"features\":[\"derive\"],\"name\":\"strum\",\"req\":\"^0.26\"},{\"name\":\"termion\",\"optional\":true,\"req\":\"^3.0\"},{\"name\":\"termwiz\",\"optional\":true,\"req\":\"^0.22.0\"},{\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.11\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.10\"},{\"name\":\"unicode-truncate\",\"req\":\"^1\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\"],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]}}", - "raw-cpuid_11.6.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.2\"},{\"kind\":\"dev\",\"name\":\"core_affinity\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"phf\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"termimad\",\"optional\":true,\"req\":\"^0.25\"}],\"features\":{\"cli\":[\"display\",\"clap\"],\"display\":[\"std\",\"termimad\",\"serde_json\",\"serialize\"],\"serialize\":[\"serde\",\"serde_derive\"],\"std\":[]}}", - "rayon-core_1.13.0": "{\"dependencies\":[{\"name\":\"crossbeam-deque\",\"req\":\"^0.8.1\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"scoped-tls\",\"req\":\"^1.0\"},{\"name\":\"wasm_sync\",\"optional\":true,\"req\":\"^0.1.0\"}],\"features\":{\"web_spin_lock\":[\"dep:wasm_sync\"]}}", - "rayon_1.12.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"either\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"name\":\"rayon-core\",\"req\":\"^1.13.0\"},{\"name\":\"wasm_sync\",\"optional\":true,\"req\":\"^0.1.0\"}],\"features\":{\"web_spin_lock\":[\"dep:wasm_sync\",\"rayon-core/web_spin_lock\"]}}", - "rcgen_0.13.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.6.0\"},{\"features\":[\"vendored\"],\"kind\":\"dev\",\"name\":\"botan\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3.0.2\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.4.1\"},{\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rsa\",\"req\":\"^0.9\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"default_features\":false,\"name\":\"time\",\"req\":\"^0.3.6\"},{\"features\":[\"verify\"],\"name\":\"x509-parser\",\"optional\":true,\"req\":\"^0.16\"},{\"features\":[\"verify\"],\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.16\"},{\"features\":[\"time\",\"std\"],\"name\":\"yasna\",\"req\":\"^0.5.2\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.2\"}],\"features\":{\"aws_lc_rs\":[\"crypto\",\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\"],\"crypto\":[],\"default\":[\"crypto\",\"pem\",\"ring\"],\"fips\":[\"crypto\",\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"ring\":[\"crypto\",\"dep:ring\"]}}", - "redox_syscall_0.5.18": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{\"default\":[\"userspace\"],\"rustc-dep-of-std\":[\"core\",\"bitflags/rustc-dep-of-std\"],\"std\":[],\"userspace\":[]}}", - "redox_syscall_0.7.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{\"default\":[\"userspace\"],\"rustc-dep-of-std\":[\"core\",\"bitflags/rustc-dep-of-std\"],\"std\":[],\"userspace\":[]}}", - "regex-automata_0.4.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.14\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"syntax\",\"perf\",\"unicode\",\"meta\",\"nfa\",\"dfa\",\"hybrid\"],\"dfa\":[\"dfa-build\",\"dfa-search\",\"dfa-onepass\"],\"dfa-build\":[\"nfa-thompson\",\"dfa-search\"],\"dfa-onepass\":[\"nfa-thompson\"],\"dfa-search\":[],\"hybrid\":[\"alloc\",\"nfa-thompson\"],\"internal-instrument\":[\"internal-instrument-pikevm\"],\"internal-instrument-pikevm\":[\"logging\",\"std\"],\"logging\":[\"dep:log\",\"aho-corasick?/logging\",\"memchr?/logging\"],\"meta\":[\"syntax\",\"nfa-pikevm\"],\"nfa\":[\"nfa-thompson\",\"nfa-pikevm\",\"nfa-backtrack\"],\"nfa-backtrack\":[\"nfa-thompson\"],\"nfa-pikevm\":[\"nfa-thompson\"],\"nfa-thompson\":[\"alloc\"],\"perf\":[\"perf-inline\",\"perf-literal\"],\"perf-inline\":[],\"perf-literal\":[\"perf-literal-substring\",\"perf-literal-multisubstring\"],\"perf-literal-multisubstring\":[\"dep:aho-corasick\"],\"perf-literal-substring\":[\"aho-corasick?/perf-literal\",\"dep:memchr\"],\"std\":[\"regex-syntax?/std\",\"memchr?/std\",\"aho-corasick?/std\",\"alloc\"],\"syntax\":[\"dep:regex-syntax\",\"alloc\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"unicode-word-boundary\",\"regex-syntax?/unicode\"],\"unicode-age\":[\"regex-syntax?/unicode-age\"],\"unicode-bool\":[\"regex-syntax?/unicode-bool\"],\"unicode-case\":[\"regex-syntax?/unicode-case\"],\"unicode-gencat\":[\"regex-syntax?/unicode-gencat\"],\"unicode-perl\":[\"regex-syntax?/unicode-perl\"],\"unicode-script\":[\"regex-syntax?/unicode-script\"],\"unicode-segment\":[\"regex-syntax?/unicode-segment\"],\"unicode-word-boundary\":[]}}", - "regex-lite_0.1.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"string\"],\"std\":[],\"string\":[]}}", - "regex-syntax_0.8.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", - "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", - "regorus_0.9.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"anyhow\",\"req\":\"^1.0.45\"},{\"kind\":\"build\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"alloc\",\"serde\"],\"name\":\"bincode\",\"optional\":true,\"req\":\"^2.0.1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.40\"},{\"name\":\"chrono-tz\",\"optional\":true,\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.5.53\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"dashmap\",\"optional\":true,\"req\":\"^6.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2.8.0\"},{\"default_features\":false,\"features\":[\"simd-accel\"],\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.16\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.12.1\"},{\"default_features\":false,\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.11.0\"},{\"default_features\":false,\"name\":\"jsonschema\",\"optional\":true,\"req\":\"^0.30.0\"},{\"default_features\":false,\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"mimalloc\",\"optional\":true,\"package\":\"regorus-mimalloc\",\"req\":\"^2.2.6\"},{\"features\":[\"error\"],\"name\":\"msvc_spectre_libs\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.16\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"prettydiff\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"regex\",\"optional\":true,\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"derive\",\"rc\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0.150\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_json\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"name\":\"serde_yaml\",\"optional\":true,\"req\":\"^0.9.16\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.16\"},{\"default_features\":false,\"features\":[\"mutex\",\"spin_mutex\"],\"name\":\"spin\",\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"test-generator\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.5.4\"},{\"default_features\":false,\"features\":[\"v4\",\"fast-rng\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.15.1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"}],\"features\":{\"allocator-memory-limits\":[\"std\",\"mimalloc\",\"mimalloc/allocator-memory-limits\"],\"arc\":[],\"ast\":[],\"azure-rbac\":[],\"azure_policy\":[\"dep:jsonschema\",\"arc\",\"dashmap\"],\"base64\":[\"dep:data-encoding\"],\"base64url\":[\"dep:data-encoding\"],\"coverage\":[],\"default\":[\"full-opa\",\"arc\",\"rvm\"],\"full-opa\":[\"base64\",\"base64url\",\"coverage\",\"glob\",\"graph\",\"hex\",\"http\",\"jsonschema\",\"allocator-memory-limits\",\"mimalloc\",\"net\",\"opa-runtime\",\"regex\",\"semver\",\"std\",\"time\",\"uuid\",\"urlquery\",\"yaml\"],\"glob\":[\"dep:globset\"],\"graph\":[],\"hex\":[\"dep:data-encoding\"],\"http\":[],\"jsonschema\":[\"dep:jsonschema\"],\"mimalloc\":[\"dep:mimalloc\"],\"net\":[\"dep:ipnet\"],\"no_std\":[\"lazy_static/spin_no_std\"],\"opa-no-std\":[\"arc\",\"base64\",\"base64url\",\"coverage\",\"graph\",\"hex\",\"no_std\",\"opa-runtime\",\"regex\",\"semver\",\"lazy_static/spin_no_std\"],\"opa-runtime\":[],\"opa-testutil\":[],\"rand\":[\"dep:rand\"],\"regex\":[\"dep:regex\"],\"rego-extensions\":[],\"rvm\":[\"dep:bincode\",\"dep:indexmap\"],\"semver\":[\"dep:semver\"],\"std\":[\"rand/std\",\"rand/std_rng\",\"serde_json/std\",\"msvc_spectre_libs\"],\"time\":[\"dep:chrono\",\"dep:chrono-tz\"],\"urlquery\":[\"dep:url\"],\"uuid\":[\"dep:uuid\"],\"yaml\":[\"serde_yaml\"]}}", - "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", - "reqwest_0.13.2": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__native-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"__native-tls-alpn\":[\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-aws-lc-rs\":[\"hyper-rustls?/aws-lc-rs\",\"tokio-rustls?/aws-lc-rs\",\"rustls?/aws-lc-rs\",\"quinn?/rustls-aws-lc-rs\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"rustls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"form\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"dep:h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde\",\"dep:serde_json\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"__native-tls\",\"__native-tls-alpn\"],\"native-tls-no-alpn\":[\"__native-tls\"],\"native-tls-vendored\":[\"__native-tls\",\"native-tls-crate?/vendored\",\"__native-tls-alpn\"],\"native-tls-vendored-no-alpn\":[\"__native-tls\",\"native-tls-crate?/vendored\"],\"query\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"rustls\":[\"__rustls-aws-lc-rs\",\"dep:rustls-platform-verifier\",\"__rustls\"],\"rustls-no-provider\":[\"dep:rustls-platform-verifier\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"zstd\":[\"tower-http/decompression-zstd\"]}}", - "rfc6979_0.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", - "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", - "rouille_3.6.2": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.13\"},{\"name\":\"brotli\",\"optional\":true,\"req\":\"^3.3.2\"},{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"req\":\"^0.4.19\"},{\"features\":[\"gzip\"],\"name\":\"deflate\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"filetime\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"server\"],\"name\":\"multipart\",\"req\":\"^0.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postgres\",\"req\":\"^0.19\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_derive\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"sha1_smol\",\"req\":\"^1.0.0\"},{\"name\":\"threadpool\",\"req\":\"^1\"},{\"features\":[\"local-offset\"],\"name\":\"time\",\"req\":\"^0.3.15\"},{\"default_features\":false,\"name\":\"tiny_http\",\"req\":\"^0.12.0\"},{\"name\":\"url\",\"req\":\"^2\"}],\"features\":{\"default\":[\"gzip\",\"brotli\"],\"gzip\":[\"deflate\"],\"rustls\":[\"tiny_http/ssl-rustls\"],\"ssl\":[\"tiny_http/ssl\"]}}", - "rowan_0.16.1": "{\"dependencies\":[{\"name\":\"countme\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"m_lexer\",\"req\":\"^0.0.4\"},{\"name\":\"rustc-hash\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.89\"},{\"name\":\"text-size\",\"req\":\"^1.1.0\"}],\"features\":{\"serde1\":[\"serde\",\"text-size/serde\"]}}", - "rsa_0.10.0-rc.18": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"zeroize\",\"alloc\"],\"name\":\"crypto-bigint\",\"req\":\"^0.7\"},{\"features\":[\"getrandom\"],\"name\":\"crypto-common\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"crypto-primes\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\",\"pem\"],\"name\":\"pkcs1\",\"optional\":true,\"req\":\"^0.8.0-rc.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"pem\"],\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"chacha\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.138\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\"^3.0.0-rc.10\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\",\"encoding\"],\"encoding\":[\"dep:pkcs1\",\"dep:pkcs8\",\"dep:spki\"],\"getrandom\":[\"crypto-bigint/getrandom\",\"crypto-common\"],\"hazmat\":[],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"encoding\",\"dep:serde\",\"dep:serdect\",\"crypto-bigint/serde\"],\"std\":[\"pkcs1?/std\",\"pkcs8?/std\"]}}", - "rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}", - "russh-cryptovec_0.61.0": "{\"dependencies\":[{\"name\":\"log\",\"req\":\"^0.4.11\"},{\"features\":[\"mman\"],\"name\":\"nix\",\"req\":\"^0.31\",\"target\":\"cfg(unix)\"},{\"features\":[\"bytes\"],\"name\":\"ssh-encoding\",\"optional\":true,\"req\":\"=0.3.0-rc.9\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.46\"},{\"features\":[\"Win32_System_Memory\",\"Win32_System_SystemInformation\",\"Win32_System_Threading\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"ssh-encoding\":[\"dep:ssh-encoding\"]}}", - "russh-util_0.52.0": "{\"dependencies\":[{\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"sync\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.17.0\"},{\"features\":[\"io-util\",\"rt-multi-thread\",\"rt\"],\"name\":\"tokio\",\"req\":\"^1.17.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.43\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{}}", - "russh_0.61.2": "{\"dependencies\":[{\"name\":\"aes\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.4\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.16.2\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"name\":\"block-padding\",\"req\":\"^0.4\"},{\"name\":\"byteorder\",\"req\":\"^1.4\"},{\"name\":\"bytes\",\"req\":\"^1.7\"},{\"name\":\"cbc\",\"req\":\"^0.2\"},{\"name\":\"cipher\",\"req\":\"^0.5.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"features\":[\"html_reports\"],\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.7\"},{\"features\":[\"alloc\"],\"name\":\"crypto-bigint\",\"req\":\"^0.7.3\"},{\"name\":\"ctr\",\"req\":\"^0.10\"},{\"name\":\"curve25519-dalek\",\"req\":\"=5.0.0-rc.0\"},{\"name\":\"data-encoding\",\"req\":\"^2.3\"},{\"name\":\"delegate\",\"req\":\"^0.13\"},{\"name\":\"der\",\"req\":\"^0.8\"},{\"name\":\"des\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"digest\",\"req\":\"^0.11.0-rc.5\"},{\"name\":\"ecdsa\",\"req\":\"=0.17.0-rc.18\"},{\"features\":[\"alloc\",\"rand_core\",\"pkcs8\"],\"name\":\"ed25519-dalek\",\"req\":\"=3.0.0-rc.0\"},{\"features\":[\"ecdh\"],\"name\":\"elliptic-curve\",\"req\":\"=0.14.0-rc.33\"},{\"name\":\"enum_dispatch\",\"req\":\"^0.3.13\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.15\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"features\":[\"compat-0_14\"],\"name\":\"generic-array\",\"req\":\"^1.3.3\"},{\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.4\"},{\"name\":\"ghash\",\"req\":\"^0.6.0\"},{\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hmac\",\"req\":\"^0.13\"},{\"name\":\"inout\",\"req\":\"^0.2\"},{\"name\":\"keccak\",\"req\":\"^0.2.0\"},{\"name\":\"log\",\"req\":\"^0.4.11\"},{\"name\":\"md5\",\"req\":\"^0.8\"},{\"name\":\"ml-kem\",\"req\":\"^0.3\"},{\"name\":\"module-lattice\",\"req\":\"^0.2\"},{\"features\":[\"rand_0_10\"],\"name\":\"num-bigint\",\"package\":\"internal-russh-num-bigint\",\"req\":\"=0.5.0\"},{\"name\":\"num_bigint_0_4\",\"package\":\"num-bigint\",\"req\":\"^0.4.6\"},{\"features\":[\"ecdh\"],\"name\":\"p256\",\"req\":\"=0.14.0-rc.10\"},{\"features\":[\"ecdh\"],\"name\":\"p384\",\"req\":\"=0.14.0-rc.10\"},{\"features\":[\"ecdh\"],\"name\":\"p521\",\"req\":\"=0.14.0-rc.10\"},{\"name\":\"pageant\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"name\":\"pbkdf2\",\"req\":\"^0.13\"},{\"name\":\"pkcs1\",\"optional\":true,\"req\":\"=0.8.0-rc.4\"},{\"name\":\"pkcs5\",\"req\":\"^0.8\"},{\"features\":[\"encryption\",\"std\"],\"name\":\"pkcs8\",\"req\":\"^0.11\"},{\"name\":\"polyval\",\"req\":\"^0.7.1\"},{\"features\":[\"thread_rng\"],\"name\":\"rand\",\"req\":\"^0.10\"},{\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"name\":\"rand_core\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.14\"},{\"name\":\"rsa\",\"optional\":true,\"req\":\"=0.10.0-rc.18\"},{\"features\":[\"ssh-encoding\"],\"name\":\"russh-cryptovec\",\"req\":\"^0.61.0\"},{\"kind\":\"dev\",\"name\":\"russh-sftp\",\"req\":\"^2.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"russh-util\",\"req\":\"^0.52.0\"},{\"name\":\"salsa20\",\"req\":\"^0.11.0\"},{\"name\":\"scrypt\",\"req\":\"^0.12.0\"},{\"features\":[\"der\"],\"name\":\"sec1\",\"req\":\"^0.8\"},{\"features\":[\"oid\"],\"name\":\"sha1\",\"req\":\"^0.11\"},{\"features\":[\"oid\"],\"name\":\"sha2\",\"req\":\"^0.11\"},{\"name\":\"sha3\",\"req\":\"^0.11.0\"},{\"kind\":\"dev\",\"name\":\"shell-escape\",\"req\":\"^0.1\"},{\"name\":\"signature\",\"req\":\"^3.0.0-rc.10\"},{\"name\":\"spki\",\"req\":\"^0.8\"},{\"features\":[\"bytes\"],\"name\":\"ssh-encoding\",\"req\":\"=0.3.0-rc.9\"},{\"features\":[\"ed25519\",\"p256\",\"p384\",\"p521\",\"encryption\",\"ppk\",\"sha1\"],\"name\":\"ssh-key\",\"req\":\"=0.7.0-rc.10\"},{\"name\":\"subtle\",\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.14.0\"},{\"kind\":\"dev\",\"name\":\"termion\",\"req\":\"^4\",\"target\":\"cfg(not(target_os = \\\"windows\\\"))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"io-util\",\"sync\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.17.0\"},{\"features\":[\"io-util\",\"rt-multi-thread\",\"time\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.17.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"io-std\",\"io-util\",\"rt-multi-thread\",\"time\",\"net\",\"sync\",\"macros\",\"process\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.17.0\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.17.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"tokio-fd\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_os = \\\"windows\\\"))\"},{\"features\":[\"net\",\"sync\"],\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"typenum\",\"req\":\"^1.17\"},{\"name\":\"universal-hash\",\"req\":\"^0.6.1\"},{\"features\":[\"bit-vec\",\"num-bigint\"],\"name\":\"yasna\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"zeroize\",\"req\":\"^1.7\"}],\"features\":{\"_bench\":[\"dep:criterion\"],\"async-trait\":[\"dep:async-trait\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\"],\"default\":[\"flate2\",\"aws-lc-rs\",\"rsa\"],\"des\":[\"dep:des\"],\"dsa\":[\"ssh-key/dsa\"],\"legacy-ed25519-pkcs8-parser\":[\"yasna\"],\"ring\":[\"dep:ring\"],\"rsa\":[\"dep:rsa\",\"dep:pkcs1\",\"ssh-key/rsa\"],\"serde\":[\"ssh-key/serde\"]}}", - "rustc-demangle_0.1.27": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"compiler_builtins\":[],\"rustc-dep-of-std\":[\"core\"],\"std\":[]}}", - "rustc-hash_1.1.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "rustc-hash_2.1.2": "{\"dependencies\":[{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"rand\":[\"dep:rand\",\"std\"],\"std\":[]}}", - "rustc_version_0.4.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"semver\",\"req\":\"^1.0\"}],\"features\":{}}", - "rusticata-macros_4.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"}],\"features\":{}}", - "rustix_0.38.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.161\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.5.2\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"procfs\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"cc\":[],\"default\":[\"std\",\"use-libc-auxv\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"linux-raw-sys/io_uring\"],\"libc-extra-traits\":[\"libc?/extra_traits\"],\"linux_4_11\":[],\"linux_latest\":[\"linux_4_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[\"fs\"],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"procfs\":[\"once_cell\",\"itoa\",\"fs\"],\"pty\":[\"itoa\",\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"compiler_builtins\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\",\"compiler_builtins?/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\",\"libc-extra-traits\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\",\"libc-extra-traits\"],\"use-libc-auxv\":[]}}", - "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", - "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", - "rustls-pemfile_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.9\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"pki-types/std\"]}}", - "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", - "rustls-pki-types_1.14.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", - "rustls-platform-verifier-android_0.1.1": "{\"dependencies\":[],\"features\":{}}", - "rustls-platform-verifier_0.6.2": "{\"dependencies\":[{\"name\":\"android_logger\",\"optional\":true,\"req\":\"^0.15\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"default_features\":false,\"name\":\"jni\",\"optional\":true,\"req\":\"^0.21\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.9\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"default_features\":false,\"features\":[\"ring\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"req\":\"^0.8\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"rustls-platform-verifier-android\",\"req\":\"^0.1.0\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"security-framework\",\"req\":\"^3.5.0\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"security-framework-sys\",\"req\":\"^2.15\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"webpki-root-certs\",\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"webpki-root-certs\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_Security_Cryptography\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.62.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"cert-logging\":[\"base64\"],\"dbg\":[],\"docsrs\":[\"jni\",\"once_cell\"],\"ffi-testing\":[\"android_logger\",\"rustls/ring\"]}}", - "rustls-webpki_0.101.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.21\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.11.3\"},{\"default_features\":false,\"name\":\"ring\",\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[\"ring/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "rustls-webpki_0.103.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", - "rustls_0.21.12": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.21\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^1.0.3\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"name\":\"sct\",\"req\":\"^0.7.0\"},{\"features\":[\"alloc\",\"std\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.101.7\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.25.0\"}],\"features\":{\"dangerous_configuration\":[],\"default\":[\"logging\",\"tls12\"],\"logging\":[\"log\"],\"quic\":[],\"read_buf\":[\"rustversion\"],\"secret_extraction\":[],\"tls12\":[]}}", - "rustls_0.23.38": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.6\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", - "rustls_0.23.40": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.6\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", - "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", - "ryu_1.0.23": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.5\"}],\"features\":{\"small\":[]}}", - "safemem_0.3.3": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "salsa20_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"stream-wrapper\"],\"name\":\"cipher\",\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"zeroize\":[\"cipher/zeroize\"]}}", - "same-file_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"winapi-util\",\"req\":\"^0.1.1\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "schannel_0.1.29": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_Security_Cryptography\",\"Win32_Security_Authentication_Identity\",\"Win32_Security_Credentials\",\"Win32_System_LibraryLoader\",\"Win32_System_Memory\",\"Win32_System_SystemInformation\"],\"name\":\"windows-sys\",\"req\":\"^0.61\"},{\"features\":[\"Win32_System_SystemInformation\",\"Win32_System_Time\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\"}],\"features\":{}}", - "schemars_0.8.22": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec05\",\"optional\":true,\"package\":\"arrayvec\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"arrayvec07\",\"optional\":true,\"package\":\"arrayvec\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"bigdecimal03\",\"optional\":true,\"package\":\"bigdecimal\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"bigdecimal04\",\"optional\":true,\"package\":\"bigdecimal\",\"req\":\"^0.4\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"dyn-clone\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.3\"},{\"name\":\"enumset\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"serde-1\"],\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.2\"},{\"features\":[\"serde\"],\"name\":\"indexmap2\",\"optional\":true,\"package\":\"indexmap\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"schemars_derive\",\"optional\":true,\"req\":\"=0.8.22\"},{\"features\":[\"serde\"],\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0.25\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.1.17\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"url\",\"optional\":true,\"req\":\"^2.0\"},{\"default_features\":false,\"name\":\"uuid08\",\"optional\":true,\"package\":\"uuid\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"uuid1\",\"optional\":true,\"package\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"arrayvec\":[\"arrayvec05\"],\"bigdecimal\":[\"bigdecimal03\"],\"default\":[\"derive\"],\"derive\":[\"schemars_derive\"],\"derive_json_schema\":[\"impl_json_schema\"],\"impl_json_schema\":[\"derive\"],\"indexmap1\":[\"indexmap\"],\"preserve_order\":[\"indexmap\"],\"raw_value\":[\"serde_json/raw_value\"],\"ui_test\":[],\"uuid\":[\"uuid08\"]}}", - "schemars_derive_0.8.22": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.2.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"serde_derive_internals\",\"req\":\"^0.29\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "scopeguard_1.2.0": "{\"dependencies\":[],\"features\":{\"default\":[\"use_std\"],\"use_std\":[]}}", - "scroll_0.13.0": "{\"dependencies\":[{\"name\":\"scroll_derive\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"default\":[\"std\"],\"derive\":[\"dep:scroll_derive\"],\"std\":[]}}", - "scroll_derive_0.13.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"scroll\",\"req\":\"^0.13\"},{\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "scrypt_0.12.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"mcf\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"pbkdf2\",\"req\":\"^0.13\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"salsa20\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11\"}],\"features\":{\"alloc\":[\"password-hash?/alloc\"],\"getrandom\":[\"password-hash\",\"password-hash/getrandom\"],\"kdf\":[\"alloc\",\"dep:kdf\"],\"mcf\":[\"alloc\",\"phc\",\"dep:ctutils\",\"dep:mcf\"],\"parallel\":[\"dep:rayon\"],\"phc\":[\"password-hash/phc\"],\"rand_core\":[\"password-hash/rand_core\"]}}", - "sct_0.7.1": "{\"dependencies\":[{\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9.0\"}],\"features\":{}}", - "sec1_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"base16ct\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"oid\"],\"name\":\"der\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"hybrid-array\",\"optional\":true,\"req\":\"^0.4.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"der?/alloc\",\"zeroize?/alloc\"],\"default\":[\"der\",\"point\"],\"der\":[\"dep:der\",\"zeroize\"],\"pem\":[\"alloc\",\"der/pem\"],\"point\":[\"dep:base16ct\",\"dep:hybrid-array\"],\"serde\":[\"dep:serdect\"],\"std\":[\"alloc\",\"der?/std\"],\"zeroize\":[\"dep:zeroize\",\"der?/zeroize\"]}}", - "seccompiler_0.5.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.153\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.27\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.9\"}],\"features\":{\"json\":[\"serde\",\"serde_json\"]}}", - "secrecy_0.8.0": "{\"dependencies\":[{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.4\"}],\"features\":{\"alloc\":[\"zeroize/alloc\"],\"default\":[\"alloc\"]}}", - "security-framework-sys_2.17.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.7\"},{\"name\":\"libc\",\"req\":\"^0.2.150\"}],\"features\":{\"OSX_10_10\":[],\"OSX_10_11\":[],\"OSX_10_12\":[],\"OSX_10_13\":[],\"OSX_10_14\":[],\"OSX_10_15\":[],\"OSX_10_9\":[],\"default\":[\"OSX_10_13\"],\"macos-12\":[]}}", - "security-framework_3.7.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.11\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"libc\",\"req\":\"^0.2.139\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"name\":\"security-framework-sys\",\"req\":\"^2.17\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.12.0\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.23\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{\"OSX_10_12\":[],\"OSX_10_13\":[],\"OSX_10_14\":[],\"OSX_10_15\":[\"security-framework-sys/OSX_10_15\"],\"alpn\":[],\"default\":[\"OSX_10_14\",\"alpn\",\"session-tickets\"],\"job-bless\":[],\"macos-12\":[\"security-framework-sys/macos-12\"],\"nightly\":[],\"session-tickets\":[],\"sync-keychain\":[\"OSX_10_13\"]}}", - "semver_1.0.27": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"package\":\"serde_core\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde\"],\"std\":[]}}", - "semver_1.0.28": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"package\":\"serde_core\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde\"],\"std\":[]}}", - "serde-value_0.7.0": "{\"dependencies\":[{\"name\":\"ordered-float\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.0\"}],\"features\":{}}", - "serde_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"result\"],\"name\":\"serde_core\",\"req\":\"=1.0.228\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"default\":[\"std\"],\"derive\":[\"serde_derive\"],\"rc\":[\"serde_core/rc\"],\"std\":[\"serde_core/std\"],\"unstable\":[\"serde_core/unstable\"]}}", - "serde_core_1.0.228": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_derive\",\"req\":\"=1.0.228\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"result\"],\"rc\":[],\"result\":[],\"std\":[],\"unstable\":[]}}", - "serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}", - "serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", - "serde_json_1.0.145": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"name\":\"ryu\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}", - "serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}", - "serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}", - "serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", - "serde_spanned_0.6.9": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"}],\"features\":{}}", - "serde_urlencoded_0.7.1": "{\"dependencies\":[{\"name\":\"form_urlencoded\",\"req\":\"^1\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"name\":\"serde\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"}],\"features\":{}}", - "serde_yaml_0.9.34+deprecated": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.79\"},{\"name\":\"indexmap\",\"req\":\"^2.2.1\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"name\":\"ryu\",\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.195\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.195\"},{\"name\":\"unsafe-libyaml\",\"req\":\"^0.2.11\"}],\"features\":{}}", - "serde_yml_0.0.12": "{\"dependencies\":[{\"name\":\"indexmap\",\"req\":\"^2.2.4\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.5\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"name\":\"libyml\",\"req\":\"^0.0.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"name\":\"ryu\",\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.204\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.204\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.204\"},{\"kind\":\"build\",\"name\":\"version_check\",\"req\":\"^0.9.4\"}],\"features\":{\"default\":[]}}", - "serdect_0.4.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"base16ct\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ciborium\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"serde-json-core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"base16ct/alloc\",\"serde/alloc\"],\"default\":[\"alloc\"],\"derive\":[\"serde/derive\"]}}", - "sha1_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha1-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"sha1-asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", - "sha1_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", - "sha1_smol_1.0.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", - "sha2_0.10.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha2-asm\",\"optional\":true,\"req\":\"^0.6.1\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"}],\"features\":{\"asm\":[\"sha2-asm\"],\"asm-aarch64\":[\"asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"force-soft-compact\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", - "sha2_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", - "sha3_0.11.0": "{\"dependencies\":[{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"keccak\",\"req\":\"^0.2\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", - "sharded-slab_0.1.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"^1\"},{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"features\":[\"checkpoint\"],\"name\":\"loom\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"features\":[\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"memory-stats\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"slab\",\"req\":\"^0.4.2\"}],\"features\":{}}", - "shell-escape_0.1.5": "{\"dependencies\":[],\"features\":{}}", - "shell-words_1.1.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "shlex_1.3.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "signal-hook-mio_0.2.5": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"~0.2\"},{\"name\":\"mio-0_6\",\"optional\":true,\"package\":\"mio\",\"req\":\"~0.6\"},{\"features\":[\"os-util\",\"uds\"],\"name\":\"mio-0_7\",\"optional\":true,\"package\":\"mio\",\"req\":\"~0.7\"},{\"features\":[\"os-util\",\"os-poll\",\"uds\"],\"kind\":\"dev\",\"name\":\"mio-0_7\",\"package\":\"mio\",\"req\":\"~0.7\"},{\"features\":[\"net\",\"os-ext\"],\"name\":\"mio-0_8\",\"optional\":true,\"package\":\"mio\",\"req\":\"~0.8\"},{\"features\":[\"net\",\"os-ext\"],\"name\":\"mio-1_0\",\"optional\":true,\"package\":\"mio\",\"req\":\"^1.0\"},{\"name\":\"mio-uds\",\"optional\":true,\"req\":\"~0.6\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"~3\"},{\"name\":\"signal-hook\",\"req\":\"~0.3\"}],\"features\":{\"support-v0_6\":[\"mio-0_6\",\"mio-uds\"],\"support-v0_7\":[\"mio-0_7\"],\"support-v0_8\":[\"mio-0_8\"],\"support-v1_0\":[\"mio-1_0\"]}}", - "signal-hook-registry_1.4.8": "{\"dependencies\":[{\"name\":\"errno\",\"req\":\">=0.2, <0.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"~0.3\"}],\"features\":{}}", - "signal-hook_0.3.18": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.7\"},{\"name\":\"signal-hook-registry\",\"req\":\"^1.4\"}],\"features\":{\"channel\":[],\"default\":[\"channel\",\"iterator\"],\"extended-siginfo\":[\"channel\",\"iterator\",\"extended-siginfo-raw\"],\"extended-siginfo-raw\":[\"cc\"],\"iterator\":[\"channel\"]}}", - "signature_2.2.0": "{\"dependencies\":[{\"name\":\"derive\",\"optional\":true,\"package\":\"signature_derive\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10.6\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\",\"rand_core?/std\"]}}", - "signature_3.0.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"alloc\":[],\"rand_core\":[\"dep:rand_core\"]}}", - "simd-adler32_0.3.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adler\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"adler32\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"default\":[\"std\",\"const-generics\"],\"nightly\":[],\"std\":[]}}", - "simple_asn1_0.6.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"}],\"features\":{}}", - "sketches-ddsketch_0.3.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.4.3\"},{\"features\":[\"derive\",\"serde_derive\"],\"name\":\"serde\",\"optional\":true,\"package\":\"serde\",\"req\":\"^1.0\"}],\"features\":{\"use_serde\":[\"serde\",\"serde/derive\"]}}", - "slab_0.4.12": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "smallvec_1.15.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bincode\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"bincode1\",\"package\":\"bincode\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unty\",\"optional\":true,\"req\":\"^0.0.4\"}],\"features\":{\"const_generics\":[],\"const_new\":[\"const_generics\"],\"debugger_visualizer\":[],\"drain_filter\":[],\"drain_keep_rest\":[\"drain_filter\"],\"impl_bincode\":[\"bincode\",\"unty\"],\"may_dangle\":[],\"specialization\":[],\"union\":[],\"write\":[]}}", - "socket2_0.5.10": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", - "socket2_0.6.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", - "spiffe_0.15.1": "{\"dependencies\":[{\"name\":\"arc-swap\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"alloc\"],\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"vendored\"],\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\"},{\"features\":[\"pkcs8\"],\"kind\":\"dev\",\"name\":\"p256\",\"req\":\"^0.13\"},{\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.14\"},{\"name\":\"prost-types\",\"optional\":true,\"req\":\"^0.14\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"time\",\"optional\":true,\"req\":\">=0.3.47, <0.4\"},{\"default_features\":false,\"features\":[\"rt\",\"net\",\"time\",\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"transport\",\"codegen\"],\"name\":\"tonic\",\"optional\":true,\"req\":\"^0.14\"},{\"name\":\"tonic-prost\",\"optional\":true,\"req\":\"^0.14\"},{\"features\":[\"util\"],\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"x509-parser\",\"optional\":true,\"req\":\"^0.18\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[],\"jwt\":[\"dep:serde\",\"dep:serde_json\",\"dep:time\",\"dep:base64ct\",\"dep:zeroize\"],\"jwt-source\":[\"workload-api\",\"jwt\"],\"jwt-verify-aws-lc-rs\":[\"jwt\",\"dep:jsonwebtoken\",\"jsonwebtoken/aws_lc_rs\"],\"jwt-verify-rust-crypto\":[\"jwt\",\"dep:jsonwebtoken\",\"jsonwebtoken/rust_crypto\"],\"logging\":[\"dep:log\"],\"tracing\":[\"dep:tracing\",\"logging\"],\"transport\":[\"dep:url\"],\"transport-grpc\":[\"transport\",\"dep:tokio\",\"dep:tonic\",\"dep:tower\",\"dep:hyper-util\"],\"workload-api\":[\"workload-api-full\"],\"workload-api-core\":[\"transport-grpc\",\"dep:futures\",\"dep:tokio\",\"dep:tokio-util\",\"dep:arc-swap\",\"dep:fastrand\",\"dep:tonic-prost\",\"dep:prost\",\"dep:prost-types\"],\"workload-api-full\":[\"workload-api-x509\",\"workload-api-jwt\"],\"workload-api-jwt\":[\"workload-api-core\",\"jwt\"],\"workload-api-x509\":[\"workload-api-core\",\"x509\"],\"x509\":[\"dep:x509-parser\",\"dep:pkcs8\",\"dep:zeroize\"],\"x509-source\":[\"workload-api\"]}}", - "spin_0.9.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"name\":\"lock_api_crate\",\"optional\":true,\"package\":\"lock_api\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"barrier\":[\"mutex\"],\"default\":[\"lock_api\",\"mutex\",\"spin_mutex\",\"rwlock\",\"once\",\"lazy\",\"barrier\"],\"fair_mutex\":[\"mutex\"],\"lazy\":[\"once\"],\"lock_api\":[\"lock_api_crate\"],\"mutex\":[],\"once\":[],\"portable_atomic\":[\"portable-atomic\"],\"rwlock\":[],\"spin_mutex\":[\"mutex\"],\"std\":[],\"ticket_mutex\":[\"mutex\"],\"use_ticket_mutex\":[\"mutex\",\"ticket_mutex\"]}}", - "spki_0.7.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"base64ct?/alloc\",\"der/alloc\"],\"arbitrary\":[\"std\",\"dep:arbitrary\",\"der/arbitrary\"],\"base64\":[\"dep:base64ct\"],\"fingerprint\":[\"sha2\"],\"pem\":[\"alloc\",\"der/pem\"],\"std\":[\"der/std\",\"alloc\"]}}", - "spki_0.8.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4\"},{\"default_features\":false,\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"base64ct?/alloc\",\"der/alloc\"],\"arbitrary\":[\"std\",\"dep:arbitrary\",\"der/arbitrary\"],\"base64\":[\"dep:base64ct\"],\"fingerprint\":[\"digest\",\"sha2\"],\"pem\":[\"alloc\",\"der/pem\"],\"std\":[\"der/std\",\"alloc\"]}}", - "sqlx-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-io\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"name\":\"hashlink\",\"req\":\"^0.10.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.15\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.132\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.73\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"sqlite\",\"mysql\",\"migrate\",\"macros\",\"time\",\"uuid\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"async-io\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"]}}", - "sqlx-macros-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.79\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"offline\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"full\",\"derive\",\"parsing\",\"printing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0.52\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"sqlx-core/_rt-async-std\"],\"_rt-tokio\":[\"tokio\",\"sqlx-core/_rt-tokio\"],\"_sqlite\":[],\"_tls-native-tls\":[\"sqlx-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[],\"derive\":[],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[],\"migrate\":[\"sqlx-core/migrate\"],\"mysql\":[\"sqlx-mysql\"],\"postgres\":[\"sqlx-postgres\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", - "sqlx-macros_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.36\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"any\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros-core\",\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{\"_rt-async-std\":[\"sqlx-macros-core/_rt-async-std\"],\"_rt-tokio\":[\"sqlx-macros-core/_rt-tokio\"],\"_tls-native-tls\":[\"sqlx-macros-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-macros-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-macros-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-macros-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-macros-core/bigdecimal\"],\"bit-vec\":[\"sqlx-macros-core/bit-vec\"],\"chrono\":[\"sqlx-macros-core/chrono\"],\"default\":[],\"derive\":[\"sqlx-macros-core/derive\"],\"ipnet\":[\"sqlx-macros-core/ipnet\"],\"ipnetwork\":[\"sqlx-macros-core/ipnetwork\"],\"json\":[\"sqlx-macros-core/json\"],\"mac_address\":[\"sqlx-macros-core/mac_address\"],\"macros\":[\"sqlx-macros-core/macros\"],\"migrate\":[\"sqlx-macros-core/migrate\"],\"mysql\":[\"sqlx-macros-core/mysql\"],\"postgres\":[\"sqlx-macros-core/postgres\"],\"rust_decimal\":[\"sqlx-macros-core/rust_decimal\"],\"sqlite\":[\"sqlx-macros-core/sqlite\"],\"sqlite-unbundled\":[\"sqlx-macros-core/sqlite-unbundled\"],\"time\":[\"sqlx-macros-core/time\"],\"uuid\":[\"sqlx-macros-core/uuid\"]}}", - "sqlx-mysql_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"digest\",\"req\":\"^0.10.0\"},{\"name\":\"dotenvy\",\"req\":\"^0.15.5\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rsa\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.144\"},{\"default_features\":false,\"name\":\"sha1\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"mysql\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"sqlx-core/bigdecimal\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde/derive\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-postgres_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.8.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"home\",\"req\":\"^0.5.5\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.144\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"req\":\"^1.0.85\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"serde\"],\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"derive\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"features\":[\"json\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-sqlite_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\",\"unlock_notify\"],\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"macros\",\"runtime-tokio\",\"tls-none\",\"sqlite\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx_0.8.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.52\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.12\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.19\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand_xoshiro\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-async-std-native-tls\":[\"runtime-async-std\",\"tls-native-tls\"],\"runtime-async-std-rustls\":[\"runtime-async-std\",\"tls-rustls-ring\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"runtime-tokio-native-tls\":[\"runtime-tokio\",\"tls-native-tls\"],\"runtime-tokio-rustls\":[\"runtime-tokio\",\"tls-rustls-ring\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", - "ssh-cipher_0.3.0-rc.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"optional\":true,\"req\":\"^0.6.0-rc.10\"},{\"default_features\":false,\"name\":\"aes\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"aes\"],\"name\":\"aes-gcm\",\"optional\":true,\"req\":\"^0.11.0-rc.3\"},{\"name\":\"cbc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"cipher\",\"legacy\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"cipher\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"ctr\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"des\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"encoding\",\"package\":\"ssh-encoding\",\"req\":\"^0.3.0-rc.9\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"poly1305\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"aes-cbc\":[\"dep:aes\",\"dep:cbc\"],\"aes-ctr\":[\"dep:aes\",\"dep:ctr\"],\"aes-gcm\":[\"dep:aead\",\"dep:aes\",\"dep:aes-gcm\"],\"chacha20poly1305\":[\"dep:aead\",\"dep:chacha20\",\"dep:poly1305\",\"dep:ctutils\"],\"tdes\":[\"dep:des\",\"dep:cbc\"],\"zeroize\":[\"dep:zeroize\",\"aes?/zeroize\",\"aes-gcm?/zeroize\",\"chacha20?/zeroize\",\"des?/zeroize\",\"poly1305?/zeroize\"]}}", - "ssh-encoding_0.3.0-rc.9": "{\"dependencies\":[{\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"bigint\",\"optional\":true,\"package\":\"crypto-bigint\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"ssh-derive\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"base64ct?/alloc\",\"pem-rfc7468?/alloc\",\"zeroize?/alloc\"],\"base64\":[\"dep:base64ct\"],\"bigint\":[\"alloc\",\"zeroize\",\"dep:bigint\"],\"bytes\":[\"alloc\",\"dep:bytes\"],\"derive\":[\"ssh-derive\"],\"pem\":[\"base64\",\"dep:pem-rfc7468\"]}}", - "ssh-key_0.7.0-rc.10": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"argon2\",\"optional\":true,\"req\":\"^0.6.0-rc.8\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"bcrypt-pbkdf\",\"optional\":true,\"req\":\"^0.11\"},{\"features\":[\"rng\"],\"kind\":\"dev\",\"name\":\"chacha20\",\"req\":\"^0.10\"},{\"features\":[\"zeroize\"],\"name\":\"cipher\",\"package\":\"ssh-cipher\",\"req\":\"^0.3.0-rc.9\"},{\"default_features\":false,\"name\":\"ctutils\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"hazmat\"],\"name\":\"dsa\",\"optional\":true,\"req\":\"^0.7.0-rc.15\"},{\"default_features\":false,\"name\":\"ed25519-dalek\",\"optional\":true,\"req\":\"^3.0.0-pre.7\"},{\"features\":[\"base64\",\"digest\",\"pem\",\"ctutils\",\"zeroize\"],\"name\":\"encoding\",\"package\":\"ssh-encoding\",\"req\":\"^0.3.0-rc.9\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"ecdsa\"],\"name\":\"p256\",\"optional\":true,\"req\":\"^0.14.0-rc.9\"},{\"default_features\":false,\"features\":[\"ecdsa\"],\"name\":\"p384\",\"optional\":true,\"req\":\"^0.14.0-rc.9\"},{\"default_features\":false,\"features\":[\"ecdsa\"],\"name\":\"p521\",\"optional\":true,\"req\":\"^0.14.0-rc.9\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"sha2\"],\"name\":\"rsa\",\"optional\":true,\"req\":\"^0.10.0-rc.18\"},{\"default_features\":false,\"features\":[\"point\"],\"name\":\"sec1\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.16\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"signature\",\"req\":\"^3\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"encoding/alloc\",\"signature/alloc\",\"zeroize/alloc\"],\"crypto\":[\"ed25519\",\"p256\",\"p384\",\"p521\",\"rsa\"],\"default\":[\"ecdsa\",\"rand_core\",\"std\"],\"dsa\":[\"dep:dsa\",\"dep:sha1\",\"alloc\",\"encoding/bigint\",\"signature/rand_core\"],\"ecdsa\":[\"dep:sec1\"],\"ed25519\":[\"dep:ed25519-dalek\",\"rand_core\"],\"encryption\":[\"dep:bcrypt-pbkdf\",\"alloc\",\"cipher/aes-cbc\",\"cipher/aes-ctr\",\"cipher/aes-gcm\",\"cipher/chacha20poly1305\",\"rand_core\"],\"p256\":[\"dep:p256\",\"ecdsa\"],\"p384\":[\"dep:p384\",\"ecdsa\"],\"p521\":[\"dep:p521\",\"ecdsa\"],\"ppk\":[\"dep:hex\",\"alloc\",\"cipher/aes-cbc\",\"dep:hmac\",\"dep:argon2\",\"dep:sha1\"],\"rsa\":[\"dep:rsa\",\"alloc\",\"encoding/bigint\",\"rand_core\"],\"sha1\":[\"dep:sha1\"],\"std\":[\"alloc\"],\"tdes\":[\"cipher/tdes\",\"encryption\"]}}", - "stability_0.2.1": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"derive\",\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "stable_deref_trait_1.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "static_assertions_1.1.0": "{\"dependencies\":[],\"features\":{\"nightly\":[]}}", - "stringprep_0.1.5": "{\"dependencies\":[{\"name\":\"unicode-bidi\",\"req\":\"^0.3\"},{\"name\":\"unicode-normalization\",\"req\":\"^0.1\"},{\"name\":\"unicode-properties\",\"req\":\"^0.1.1\"}],\"features\":{}}", - "strsim_0.11.1": "{\"dependencies\":[],\"features\":{}}", - "strum_0.26.3": "{\"dependencies\":[{\"features\":[\"macros\"],\"name\":\"phf\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"strum_macros\",\"optional\":true,\"req\":\"^0.26.3\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"}],\"features\":{\"default\":[\"std\"],\"derive\":[\"strum_macros\"],\"std\":[]}}", - "strum_0.27.2": "{\"dependencies\":[{\"features\":[\"macros\"],\"name\":\"phf\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"strum_macros\",\"optional\":true,\"req\":\"^0.27\"}],\"features\":{\"default\":[\"std\"],\"derive\":[\"strum_macros\"],\"std\":[]}}", - "strum_macros_0.26.4": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"features\":[\"parsing\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "strum_macros_0.27.2": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"parsing\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "subtle_2.6.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"core_hint_black_box\":[],\"default\":[\"std\",\"i128\"],\"i128\":[],\"nightly\":[],\"std\":[]}}", - "supports-color_3.0.2": "{\"dependencies\":[{\"name\":\"is_ci\",\"req\":\"^1.2.0\"}],\"features\":{}}", - "supports-hyperlinks_3.2.0": "{\"dependencies\":[],\"features\":{}}", - "supports-unicode_3.0.0": "{\"dependencies\":[],\"features\":{}}", - "symlink_0.1.0": "{\"dependencies\":[],\"features\":{}}", - "syn_1.0.109": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.46\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.1\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", - "syn_2.0.117": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", - "sync_wrapper_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"}],\"features\":{\"futures\":[\"futures-core\"]}}", - "synstructure_0.13.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"visit\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"synstructure_test_traits\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"syn/proc-macro\",\"quote/proc-macro\"]}}", - "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", - "target-lexicon_0.13.5": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"arch_z80\":[],\"arch_zkasm\":[],\"default\":[],\"serde_support\":[\"serde\",\"std\"],\"std\":[]}}", - "temp-env_0.3.6": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.21\"},{\"name\":\"parking_lot\",\"req\":\"^0.12.1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21.1\"}],\"features\":{\"async_closure\":[\"dep:futures\"],\"default\":[]}}", - "tempfile_3.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\">=0.3.0, <0.5\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.4\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", - "terminal-colorsaurus_1.0.3": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.7\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.151\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"memchr\",\"req\":\"^2.7.1\",\"target\":\"cfg(any(unix, windows))\"},{\"default_features\":false,\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"rgb\",\"optional\":true,\"req\":\"^0.8.37\"},{\"name\":\"terminal-trx\",\"req\":\"^0.2.6\",\"target\":\"cfg(any(unix, windows))\"},{\"features\":[\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"name\":\"xterm-color\",\"req\":\"^1.0\"}],\"features\":{}}", - "terminal-trx_0.2.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.152\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"features\":[\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "terminal_size_0.4.4": "{\"dependencies\":[{\"features\":[\"termios\"],\"name\":\"rustix\",\"req\":\"^1.0.1\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\"],\"name\":\"windows-sys\",\"req\":\">=0.59, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "text-size_1.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"}],\"features\":{}}", - "textwrap_0.16.2": "{\"dependencies\":[{\"features\":[\"embed_en-us\"],\"name\":\"hyphenation\",\"optional\":true,\"req\":\"^0.8.4\"},{\"name\":\"smawk\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"termion\",\"req\":\"^4.0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicode-linebreak\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9.5\"}],\"features\":{\"default\":[\"unicode-linebreak\",\"unicode-width\",\"smawk\"]}}", - "thiserror-impl_1.0.69": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", - "thiserror-impl_2.0.18": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", - "thiserror_1.0.69": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.73\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"name\":\"thiserror-impl\",\"req\":\"=1.0.69\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", - "thiserror_2.0.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.73\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"name\":\"thiserror-impl\",\"req\":\"=2.0.18\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "thread_local_1.1.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"}],\"features\":{\"nightly\":[]}}", - "threadpool_1.8.1": "{\"dependencies\":[{\"name\":\"num_cpus\",\"req\":\"^1.13\"}],\"features\":{}}", - "time-core_0.1.8": "{\"dependencies\":[],\"features\":{\"large-dates\":[]}}", - "time-macros_0.2.27": "{\"dependencies\":[{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"}],\"features\":{\"formatting\":[],\"large-dates\":[],\"parsing\":[],\"serde\":[]}}", - "time_0.3.47": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.1\",\"target\":\"cfg(bench)\"},{\"features\":[\"powerfmt\"],\"name\":\"deranged\",\"req\":\"^0.5.2\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"num_threads\",\"optional\":true,\"req\":\"^0.1.2\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"default_features\":false,\"name\":\"powerfmt\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.126\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"},{\"name\":\"time-macros\",\"optional\":true,\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"time-macros\",\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.102\",\"target\":\"cfg(__ui_tests)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"formatting\":[\"dep:itoa\",\"std\",\"time-macros?/formatting\"],\"large-dates\":[\"time-core/large-dates\",\"time-macros?/large-dates\"],\"local-offset\":[\"std\",\"dep:libc\",\"dep:num_threads\"],\"macros\":[\"dep:time-macros\"],\"parsing\":[\"time-macros?/parsing\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\",\"deranged/quickcheck\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\",\"deranged/rand08\"],\"rand09\":[\"dep:rand09\",\"deranged/rand09\"],\"serde\":[\"dep:serde_core\",\"time-macros?/serde\",\"deranged/serde\"],\"serde-human-readable\":[\"serde\",\"formatting\",\"parsing\"],\"serde-well-known\":[\"serde\",\"formatting\",\"parsing\"],\"std\":[\"alloc\"],\"wasm-bindgen\":[\"dep:js-sys\"]}}", - "tiny_http_0.12.0": "{\"dependencies\":[{\"name\":\"ascii\",\"req\":\"^1.0\"},{\"name\":\"chunked_transfer\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fdlimit\",\"req\":\"^0.1\"},{\"name\":\"httpdate\",\"req\":\"^1.0.2\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rustc-serialize\",\"req\":\"^0.3\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.20\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.6.0\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[],\"ssl\":[\"ssl-openssl\"],\"ssl-openssl\":[\"openssl\",\"zeroize\"],\"ssl-rustls\":[\"rustls\",\"rustls-pemfile\",\"zeroize\"]}}", - "tinystr_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", - "tinyvec_1.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"generic-array\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"smallvec\",\"req\":\"^1\"},{\"name\":\"tinyvec_macros\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"alloc\":[\"tinyvec_macros\"],\"debugger_visualizer\":[],\"default\":[],\"experimental_write_impl\":[],\"grab_spare_slice\":[],\"latest_stable_rust\":[\"rustc_1_61\"],\"nightly_slice_partition_dedup\":[],\"real_blackbox\":[\"criterion/real_blackbox\"],\"rustc_1_40\":[],\"rustc_1_55\":[],\"rustc_1_57\":[],\"rustc_1_61\":[\"rustc_1_57\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"alloc\"]}}", - "tinyvec_macros_0.1.1": "{\"dependencies\":[],\"features\":{}}", - "tokio-macros_2.6.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"}],\"features\":{}}", - "tokio-macros_2.7.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"}],\"features\":{}}", - "tokio-rustls_0.24.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.21.0\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^1\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"alloc\",\"std\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.100.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.23.1\"}],\"features\":{\"dangerous_configuration\":[\"rustls/dangerous_configuration\"],\"default\":[\"logging\",\"tls12\"],\"early-data\":[],\"logging\":[\"rustls/logging\"],\"secret_extraction\":[\"rustls/secret_extraction\"],\"tls12\":[\"rustls/tls12\"]}}", - "tokio-rustls_0.26.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"brotli\":[\"rustls/brotli\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"],\"zlib\":[\"rustls/zlib\"]}}", - "tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}", - "tokio-tungstenite_0.26.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.26.2\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]}}", - "tokio-tungstenite_0.29.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.29.0\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]}}", - "tokio-util_0.7.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", - "tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", - "tokio_1.50.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.11\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", - "tokio_1.52.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"kind\":\"dev\",\"name\":\"backtrace\",\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.11\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.2.0\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.31.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.3\",\"target\":\"cfg(any(not(target_family = \\\"wasm\\\"), all(target_os = \\\"wasi\\\", not(target_env = \\\"p1\\\"))))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.7.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", - "toml_0.8.23": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"req\":\"^1.0.145\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.199\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.116\"},{\"features\":[\"serde\"],\"name\":\"serde_spanned\",\"req\":\"^0.6.9\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.0\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.2\"},{\"features\":[\"serde\"],\"name\":\"toml_datetime\",\"req\":\"^0.6.11\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"toml_edit\",\"optional\":true,\"req\":\"^0.22.27\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"}],\"features\":{\"default\":[\"parse\",\"display\"],\"display\":[\"dep:toml_edit\",\"toml_edit?/display\"],\"parse\":[\"dep:toml_edit\",\"toml_edit?/parse\"],\"preserve_order\":[\"indexmap\"],\"unbounded\":[\"toml_edit?/unbounded\"]}}", - "toml_datetime_0.6.11": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"}],\"features\":{}}", - "toml_edit_0.22.27": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2.3.0\"},{\"features\":[\"max_inline\"],\"name\":\"kstring\",\"optional\":true,\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.5.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.199\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.116\"},{\"features\":[\"serde\"],\"name\":\"serde_spanned\",\"optional\":true,\"req\":\"^0.6.9\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.0\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.2\"},{\"name\":\"toml_datetime\",\"req\":\"^0.6.11\"},{\"name\":\"toml_write\",\"optional\":true,\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.10\"}],\"features\":{\"default\":[\"parse\",\"display\"],\"display\":[\"dep:toml_write\"],\"parse\":[\"dep:winnow\"],\"perf\":[\"dep:kstring\"],\"serde\":[\"dep:serde\",\"toml_datetime/serde\",\"dep:serde_spanned\"],\"unbounded\":[],\"unstable-debug\":[\"winnow?/debug\"]}}", - "toml_write_0.1.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"toml_old\",\"package\":\"toml\",\"req\":\"^0.5.10\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "tonic-build_0.14.5": "{\"dependencies\":[{\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"default\":[\"transport\"],\"transport\":[]}}", - "tonic-build_0.14.6": "{\"dependencies\":[{\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"default\":[\"transport\"],\"transport\":[]}}", - "tonic-prost-build_0.14.6": "{\"dependencies\":[{\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"prost-build\",\"req\":\"^0.14\"},{\"name\":\"prost-types\",\"req\":\"^0.14\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"tempfile\",\"req\":\"^3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tonic\",\"req\":\"^0.14.6\"},{\"default_features\":false,\"name\":\"tonic-build\",\"req\":\"^0.14.6\"}],\"features\":{\"cleanup-markdown\":[\"prost-build/cleanup-markdown\"],\"default\":[\"transport\",\"cleanup-markdown\"],\"transport\":[\"tonic-build/transport\"]}}", - "tonic-prost_0.14.5": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"prost\",\"req\":\"^0.14\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"tonic\",\"req\":\"^0.14.0\"}],\"features\":{}}", - "tonic-prost_0.14.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"prost\",\"req\":\"^0.14\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"tonic\",\"req\":\"^0.14.6\"}],\"features\":{}}", - "tonic-types_0.14.6": "{\"dependencies\":[{\"name\":\"prost\",\"req\":\"^0.14\"},{\"name\":\"prost-types\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"tonic\",\"req\":\"^0.14.6\"}],\"features\":{}}", - "tonic_0.14.5": "{\"dependencies\":[{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"hyper-timeout\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.11\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.0\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"logging\",\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.1\"},{\"default_features\":false,\"name\":\"tokio-stream\",\"req\":\"^0.1.16\"},{\"default_features\":false,\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"load-shed\",\"timeout\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13.0\"}],\"features\":{\"_tls-any\":[\"dep:tokio\",\"tokio?/rt\",\"tokio?/macros\",\"tls-connect-info\"],\"channel\":[\"dep:hyper\",\"hyper?/client\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"dep:tower\",\"tower?/balance\",\"tower?/buffer\",\"tower?/discover\",\"tower?/limit\",\"tower?/load-shed\",\"tower?/util\",\"dep:tokio\",\"tokio?/time\",\"dep:hyper-timeout\"],\"codegen\":[\"dep:async-trait\"],\"default\":[\"router\",\"transport\",\"codegen\"],\"deflate\":[\"dep:flate2\"],\"gzip\":[\"dep:flate2\"],\"router\":[\"dep:axum\",\"dep:tower\",\"tower?/util\"],\"server\":[\"dep:h2\",\"dep:hyper\",\"hyper?/server\",\"dep:hyper-util\",\"hyper-util?/service\",\"hyper-util?/server-auto\",\"dep:socket2\",\"dep:tokio\",\"tokio?/macros\",\"tokio?/net\",\"tokio?/time\",\"tokio-stream/net\",\"dep:tower\",\"tower?/util\",\"tower?/limit\",\"tower?/load-shed\"],\"tls-aws-lc\":[\"_tls-any\",\"tokio-rustls/aws-lc-rs\"],\"tls-connect-info\":[\"dep:tokio-rustls\"],\"tls-native-roots\":[\"_tls-any\",\"channel\",\"dep:rustls-native-certs\"],\"tls-ring\":[\"_tls-any\",\"tokio-rustls/ring\"],\"tls-webpki-roots\":[\"_tls-any\",\"channel\",\"dep:webpki-roots\"],\"transport\":[\"server\",\"channel\"],\"zstd\":[\"dep:zstd\"]}}", - "tonic_0.14.6": "{\"dependencies\":[{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"hyper-timeout\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.11\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.0\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"logging\",\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.1\"},{\"default_features\":false,\"name\":\"tokio-stream\",\"req\":\"^0.1.16\"},{\"default_features\":false,\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"load-shed\",\"timeout\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13.0\"}],\"features\":{\"_tls-any\":[\"dep:tokio\",\"tokio?/rt\",\"tokio?/macros\",\"tls-connect-info\"],\"channel\":[\"dep:hyper\",\"hyper?/client\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"dep:tower\",\"tower?/balance\",\"tower?/buffer\",\"tower?/discover\",\"tower?/limit\",\"tower?/load-shed\",\"tower?/util\",\"dep:tokio\",\"tokio?/time\",\"dep:hyper-timeout\"],\"codegen\":[\"dep:async-trait\"],\"default\":[\"router\",\"transport\",\"codegen\"],\"deflate\":[\"dep:flate2\"],\"gzip\":[\"dep:flate2\"],\"router\":[\"dep:axum\",\"dep:tower\",\"tower?/util\"],\"server\":[\"dep:h2\",\"dep:hyper\",\"hyper?/server\",\"dep:hyper-util\",\"hyper-util?/service\",\"hyper-util?/server-auto\",\"dep:socket2\",\"dep:tokio\",\"tokio?/macros\",\"tokio?/net\",\"tokio?/time\",\"tokio-stream/net\",\"dep:tower\",\"tower?/util\",\"tower?/limit\",\"tower?/load-shed\"],\"tls-aws-lc\":[\"_tls-any\",\"tokio-rustls/aws-lc-rs\"],\"tls-connect-info\":[\"dep:tokio-rustls\"],\"tls-native-roots\":[\"_tls-any\",\"channel\",\"dep:rustls-native-certs\"],\"tls-ring\":[\"_tls-any\",\"tokio-rustls/ring\"],\"tls-webpki-roots\":[\"_tls-any\",\"channel\",\"dep:webpki-roots\"],\"transport\":[\"server\",\"channel\"],\"zstd\":[\"dep:zstd\"]}}", - "tower-http_0.5.2": "{\"dependencies\":[{\"features\":[\"tokio\"],\"name\":\"async-compression\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.21\"},{\"name\":\"bitflags\",\"req\":\"^2.0.2\"},{\"kind\":\"dev\",\"name\":\"brotli\",\"req\":\"^3\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.14\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.14\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"http-range-header\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"client-legacy\",\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"iri-string\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.17\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"sync_wrapper\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"tower\",\"optional\":true,\"req\":\"^0.4.1\"},{\"features\":[\"buffer\",\"util\",\"retry\",\"make\",\"timeout\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4.10\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"zstd\",\"req\":\"^0.12\"}],\"features\":{\"add-extension\":[],\"auth\":[\"base64\",\"validate-request\"],\"catch-panic\":[\"tracing\",\"futures-util/std\"],\"compression-br\":[\"async-compression/brotli\",\"futures-core\",\"tokio-util\",\"tokio\"],\"compression-deflate\":[\"async-compression/zlib\",\"futures-core\",\"tokio-util\",\"tokio\"],\"compression-full\":[\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"compression-zstd\"],\"compression-gzip\":[\"async-compression/gzip\",\"futures-core\",\"tokio-util\",\"tokio\"],\"compression-zstd\":[\"async-compression/zstd\",\"futures-core\",\"tokio-util\",\"tokio\"],\"cors\":[],\"decompression-br\":[\"async-compression/brotli\",\"futures-core\",\"tokio-util\",\"tokio\"],\"decompression-deflate\":[\"async-compression/zlib\",\"futures-core\",\"tokio-util\",\"tokio\"],\"decompression-full\":[\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"decompression-zstd\"],\"decompression-gzip\":[\"async-compression/gzip\",\"futures-core\",\"tokio-util\",\"tokio\"],\"decompression-zstd\":[\"async-compression/zstd\",\"futures-core\",\"tokio-util\",\"tokio\"],\"default\":[],\"follow-redirect\":[\"futures-util\",\"iri-string\",\"tower/util\"],\"fs\":[\"futures-util\",\"tokio/fs\",\"tokio-util/io\",\"tokio/io-util\",\"dep:http-range-header\",\"mime_guess\",\"mime\",\"percent-encoding\",\"httpdate\",\"set-status\",\"futures-util/alloc\",\"tracing\"],\"full\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-full\",\"cors\",\"decompression-full\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"limit\":[],\"map-request-body\":[],\"map-response-body\":[],\"metrics\":[\"tokio/time\"],\"normalize-path\":[],\"propagate-header\":[],\"redirect\":[],\"request-id\":[\"uuid\"],\"sensitive-headers\":[],\"set-header\":[],\"set-status\":[],\"timeout\":[\"tokio/time\"],\"trace\":[\"tracing\"],\"util\":[\"tower\"],\"validate-request\":[\"mime\"]}}", - "tower-http_0.6.8": "{\"dependencies\":[{\"features\":[\"tokio\"],\"name\":\"async-compression\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bitflags\",\"req\":\"^2.0.2\"},{\"kind\":\"dev\",\"name\":\"brotli\",\"req\":\"^8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.14\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.14\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"http-range-header\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"client-legacy\",\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"iri-string\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.17\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"sync_wrapper\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"buffer\",\"util\",\"retry\",\"make\",\"timeout\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"zstd\",\"req\":\"^0.13\"}],\"features\":{\"add-extension\":[],\"auth\":[\"base64\",\"validate-request\"],\"catch-panic\":[\"tracing\",\"futures-util/std\",\"dep:http-body\",\"dep:http-body-util\"],\"compression-br\":[\"async-compression/brotli\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"compression-deflate\":[\"async-compression/zlib\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"compression-full\":[\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"compression-zstd\"],\"compression-gzip\":[\"async-compression/gzip\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"compression-zstd\":[\"async-compression/zstd\",\"futures-core\",\"dep:http-body\",\"tokio-util\",\"tokio\"],\"cors\":[],\"decompression-br\":[\"async-compression/brotli\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"decompression-deflate\":[\"async-compression/zlib\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"decompression-full\":[\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"decompression-zstd\"],\"decompression-gzip\":[\"async-compression/gzip\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"decompression-zstd\":[\"async-compression/zstd\",\"futures-core\",\"dep:http-body\",\"dep:http-body-util\",\"tokio-util\",\"tokio\"],\"default\":[],\"follow-redirect\":[\"futures-util\",\"dep:http-body\",\"iri-string\",\"tower/util\"],\"fs\":[\"futures-core\",\"futures-util\",\"dep:http-body\",\"dep:http-body-util\",\"tokio/fs\",\"tokio-util/io\",\"tokio/io-util\",\"dep:http-range-header\",\"mime_guess\",\"mime\",\"percent-encoding\",\"httpdate\",\"set-status\",\"futures-util/alloc\",\"tracing\"],\"full\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-full\",\"cors\",\"decompression-full\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"limit\":[\"dep:http-body\",\"dep:http-body-util\"],\"map-request-body\":[],\"map-response-body\":[],\"metrics\":[\"dep:http-body\",\"tokio/time\"],\"normalize-path\":[],\"propagate-header\":[],\"redirect\":[],\"request-id\":[\"uuid\"],\"sensitive-headers\":[],\"set-header\":[],\"set-status\":[],\"timeout\":[\"dep:http-body\",\"tokio/time\"],\"trace\":[\"dep:http-body\",\"tracing\"],\"util\":[\"tower\"],\"validate-request\":[\"mime\"]}}", - "tower-layer_0.3.3": "{\"dependencies\":[],\"features\":{}}", - "tower-mcp-types_0.12.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2\"}],\"features\":{\"default\":[],\"testing\":[]}}", - "tower-service_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.22\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"features\":[\"macros\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"tower-layer\",\"req\":\"^0.3\"}],\"features\":{}}", - "tower_0.4.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"hdrhistogram\",\"optional\":true,\"req\":\"^7.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"hdrhistogram\",\"req\":\"^7.0\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"pin-project\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"features\":[\"small_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.6\"},{\"features\":[\"macros\",\"sync\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.6.2\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.1\"},{\"name\":\"tower-service\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"fmt\",\"ansi\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"__common\":[\"futures-core\",\"pin-project-lite\"],\"balance\":[\"discover\",\"load\",\"ready-cache\",\"make\",\"rand\",\"slab\"],\"buffer\":[\"__common\",\"tokio/sync\",\"tokio/rt\",\"tokio-util\",\"tracing\"],\"default\":[\"log\"],\"discover\":[\"__common\"],\"filter\":[\"__common\",\"futures-util\"],\"full\":[\"balance\",\"buffer\",\"discover\",\"filter\",\"hedge\",\"limit\",\"load\",\"load-shed\",\"make\",\"ready-cache\",\"reconnect\",\"retry\",\"spawn-ready\",\"steer\",\"timeout\",\"util\"],\"hedge\":[\"util\",\"filter\",\"futures-util\",\"hdrhistogram\",\"tokio/time\",\"tracing\"],\"limit\":[\"__common\",\"tokio/time\",\"tokio/sync\",\"tokio-util\",\"tracing\"],\"load\":[\"__common\",\"tokio/time\",\"tracing\"],\"load-shed\":[\"__common\"],\"log\":[\"tracing/log\"],\"make\":[\"futures-util\",\"pin-project-lite\",\"tokio/io-std\"],\"ready-cache\":[\"futures-core\",\"futures-util\",\"indexmap\",\"tokio/sync\",\"tracing\",\"pin-project-lite\"],\"reconnect\":[\"make\",\"tokio/io-std\",\"tracing\"],\"retry\":[\"__common\",\"tokio/time\"],\"spawn-ready\":[\"__common\",\"futures-util\",\"tokio/sync\",\"tokio/rt\",\"util\",\"tracing\"],\"steer\":[],\"timeout\":[\"pin-project-lite\",\"tokio/time\"],\"util\":[\"__common\",\"futures-util\",\"pin-project\"]}}", - "tower_0.5.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.22\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.22\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.22\"},{\"default_features\":false,\"features\":[\"async-await-macro\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.22\"},{\"default_features\":false,\"name\":\"hdrhistogram\",\"optional\":true,\"req\":\"^7.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"hdrhistogram\",\"req\":\"^7.0\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.2\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\"},{\"name\":\"sync_wrapper\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.6.2\"},{\"features\":[\"macros\",\"sync\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3.3\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"fmt\",\"ansi\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"balance\":[\"discover\",\"load\",\"ready-cache\",\"make\",\"slab\",\"util\"],\"buffer\":[\"tokio/sync\",\"tokio/rt\",\"tokio-util\",\"tracing\",\"pin-project-lite\"],\"discover\":[\"futures-core\",\"pin-project-lite\"],\"filter\":[\"futures-util\",\"pin-project-lite\"],\"full\":[\"balance\",\"buffer\",\"discover\",\"filter\",\"hedge\",\"limit\",\"load\",\"load-shed\",\"make\",\"ready-cache\",\"reconnect\",\"retry\",\"spawn-ready\",\"steer\",\"timeout\",\"util\"],\"hedge\":[\"util\",\"filter\",\"futures-util\",\"hdrhistogram\",\"tokio/time\",\"tracing\"],\"limit\":[\"tokio/time\",\"tokio/sync\",\"tokio-util\",\"tracing\",\"pin-project-lite\"],\"load\":[\"tokio/time\",\"tracing\",\"pin-project-lite\"],\"load-shed\":[\"pin-project-lite\"],\"log\":[\"tracing/log\"],\"make\":[\"pin-project-lite\",\"tokio\"],\"ready-cache\":[\"futures-core\",\"futures-util\",\"indexmap\",\"tokio/sync\",\"tracing\",\"pin-project-lite\"],\"reconnect\":[\"make\",\"tracing\"],\"retry\":[\"tokio/time\",\"util\"],\"spawn-ready\":[\"futures-util\",\"tokio/sync\",\"tokio/rt\",\"util\",\"tracing\"],\"steer\":[],\"timeout\":[\"pin-project-lite\",\"tokio/time\"],\"tokio-stream\":[],\"util\":[\"futures-core\",\"futures-util\",\"pin-project-lite\",\"sync_wrapper\"]}}", - "tracing-appender_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"name\":\"crossbeam-channel\",\"req\":\"^0.5.6\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"symlink\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.2\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"fmt\",\"std\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.18\"}],\"features\":{}}", - "tracing-attributes_0.1.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1.67\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"visit-mut\",\"clone-impls\",\"extra-traits\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.64\"}],\"features\":{\"async-await\":[]}}", - "tracing-core_0.1.36": "{\"dependencies\":[{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.13.0\"},{\"default_features\":false,\"name\":\"valuable\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"}],\"features\":{\"default\":[\"std\",\"valuable?/std\"],\"std\":[\"once_cell\"]}}", - "tracing-log_0.2.0": "{\"dependencies\":[{\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.7.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"lru\",\"optional\":true,\"req\":\"^0.7.7\"},{\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"}],\"features\":{\"default\":[\"log-tracer\",\"std\"],\"interest-cache\":[\"lru\",\"ahash\"],\"log-tracer\":[],\"std\":[\"log/std\"]}}", - "tracing-opentelemetry_0.33.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"js-sys\",\"req\":\"^0.3.64\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"lazy_static\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"trace\"],\"name\":\"opentelemetry\",\"req\":\"^0.32.0\"},{\"features\":[\"trace\",\"metrics\"],\"kind\":\"dev\",\"name\":\"opentelemetry\",\"req\":\"^0.32.0\"},{\"features\":[\"metrics\",\"grpc-tonic\"],\"kind\":\"dev\",\"name\":\"opentelemetry-otlp\",\"req\":\"^0.32.0\"},{\"features\":[\"semconv_experimental\"],\"kind\":\"dev\",\"name\":\"opentelemetry-semantic-conventions\",\"req\":\"^0.32.0\"},{\"features\":[\"trace\",\"metrics\"],\"kind\":\"dev\",\"name\":\"opentelemetry-stdout\",\"req\":\"^0.32.0\"},{\"default_features\":false,\"features\":[\"trace\",\"experimental_metrics_custom_reader\",\"testing\"],\"kind\":\"dev\",\"name\":\"opentelemetry_sdk\",\"req\":\"^0.32.0\"},{\"features\":[\"flamegraph\",\"criterion\"],\"kind\":\"dev\",\"name\":\"pprof\",\"req\":\"^0.15.0\",\"target\":\"cfg(not(target_os = \\\"windows\\\"))\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"std\",\"attributes\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"},{\"kind\":\"dev\",\"name\":\"tracing-error\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"tracing-log\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"registry\",\"std\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.22\"},{\"default_features\":false,\"features\":[\"registry\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"web-time\",\"req\":\"^1.0.0\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"}],\"features\":{\"default\":[\"tracing-log\",\"metrics\"],\"metrics\":[\"opentelemetry/metrics\",\"smallvec\"]}}", - "tracing-serde_0.2.0": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"},{\"default_features\":false,\"name\":\"valuable-serde\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"},{\"default_features\":false,\"name\":\"valuable_crate\",\"optional\":true,\"package\":\"valuable\",\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"}],\"features\":{\"valuable\":[\"valuable_crate\",\"valuable-serde\",\"tracing-core/valuable\"]}}", - "tracing-subscriber_0.3.23": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"clock\",\"std\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.26\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"matchers\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"nu-ansi-term\",\"optional\":true,\"req\":\"^0.50.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.13.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-automata\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.140\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.82\"},{\"name\":\"sharded-slab\",\"optional\":true,\"req\":\"^0.1.4\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"thread_local\",\"optional\":true,\"req\":\"^1.1.4\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.2\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.2\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.43\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.43\"},{\"default_features\":false,\"name\":\"tracing-core\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"std-future\",\"std\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"log-tracer\",\"std\"],\"name\":\"tracing-log\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"tracing-log\",\"req\":\"^0.2.0\"},{\"name\":\"tracing-serde\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"valuable-serde\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"},{\"default_features\":false,\"name\":\"valuable_crate\",\"optional\":true,\"package\":\"valuable\",\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"}],\"features\":{\"alloc\":[],\"ansi\":[\"fmt\",\"nu-ansi-term\"],\"default\":[\"smallvec\",\"fmt\",\"ansi\",\"tracing-log\",\"std\"],\"env-filter\":[\"matchers\",\"once_cell\",\"tracing\",\"std\",\"thread_local\",\"dep:regex-automata\"],\"fmt\":[\"registry\",\"std\"],\"json\":[\"tracing-serde\",\"serde\",\"serde_json\"],\"local-time\":[\"time/local-offset\"],\"nu-ansi-term\":[\"dep:nu-ansi-term\"],\"regex\":[],\"registry\":[\"sharded-slab\",\"thread_local\",\"std\"],\"std\":[\"alloc\",\"tracing-core/std\"],\"valuable\":[\"tracing-core/valuable\",\"valuable_crate\",\"valuable-serde\",\"tracing-serde/valuable\"]}}", - "tracing_0.1.44": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.21\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\"},{\"name\":\"tracing-attributes\",\"optional\":true,\"req\":\"^0.1.31\"},{\"default_features\":false,\"name\":\"tracing-core\",\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.38\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"async-await\":[],\"attributes\":[\"tracing-attributes\"],\"default\":[\"std\",\"attributes\"],\"log-always\":[\"log\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"std\":[\"tracing-core/std\"],\"valuable\":[\"tracing-core/valuable\"]}}", - "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", - "tungstenite_0.26.2": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^2.0.7\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]}}", - "tungstenite_0.29.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.6\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.7\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]}}", - "twoway_0.1.8": "{\"dependencies\":[{\"name\":\"galil-seiferas\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.7.0\"},{\"features\":[\"unstable\"],\"name\":\"jetscii\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"macro-attr\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"newtype_derive\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.2.26\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3.10\"},{\"name\":\"unchecked-index\",\"optional\":true,\"req\":\"^0.2.2\"}],\"features\":{\"all\":[\"jetscii\",\"pcmp\",\"pattern\",\"test-set\"],\"benchmarks\":[\"galil-seiferas\",\"pattern\",\"unchecked-index\"],\"default\":[\"use_std\"],\"pattern\":[],\"pcmp\":[\"unchecked-index\"],\"test-set\":[],\"use_std\":[\"memchr/use_std\"]}}", - "typed-path_0.12.3": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "typenum_1.20.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"i128\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", - "ucd-trie_0.1.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "unicase_2.9.0": "{\"dependencies\":[],\"features\":{\"nightly\":[]}}", - "unicode-bidi_0.3.18": "{\"dependencies\":[{\"name\":\"flame\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"flamer\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\">=0.8, <2.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\">=0.8, <2.0\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\">=1.13\"}],\"features\":{\"bench_it\":[],\"default\":[\"std\",\"hardcoded-data\"],\"flame_it\":[\"flame\",\"flamer\"],\"hardcoded-data\":[],\"std\":[],\"unstable\":[],\"with_serde\":[\"serde\"]}}", - "unicode-ident_1.0.24": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"fst\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"roaring\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ucd-trie\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"unicode-xid\",\"req\":\"^0.2.6\"}],\"features\":{}}", - "unicode-linebreak_0.1.5": "{\"dependencies\":[],\"features\":{}}", - "unicode-normalization_0.1.25": "{\"dependencies\":[{\"features\":[\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "unicode-properties_0.1.4": "{\"dependencies\":[],\"features\":{\"default\":[\"general-category\",\"emoji\"],\"emoji\":[],\"general-category\":[]}}", - "unicode-segmentation_1.13.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"}],\"features\":{\"no_std\":[]}}", - "unicode-truncate_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "unicode-width_0.1.14": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\",\"compiler_builtins\"]}}", - "unicode-width_0.2.2": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\"]}}", - "unicode-xid_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{\"bench\":[],\"default\":[],\"no_std\":[]}}", - "universal-hash_0.6.1": "{\"dependencies\":[{\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"ctutils\",\"req\":\"^0.4\"}],\"features\":{}}", - "unsafe-libyaml_0.2.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0\"}],\"features\":{}}", - "untrusted_0.7.1": "{\"dependencies\":[],\"features\":{}}", - "untrusted_0.9.0": "{\"dependencies\":[],\"features\":{}}", - "ureq_2.12.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^4.0.0\"},{\"default_features\":false,\"name\":\"cookie\",\"optional\":true,\"req\":\"^0.18\"},{\"default_features\":false,\"features\":[\"preserve_order\",\"serde_json\"],\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.21.1\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"humantime\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"<=0.9\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.22\"},{\"name\":\"hootbin\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.1\"},{\"name\":\"http-02\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"ring\",\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.19\"},{\"default_features\":false,\"features\":[\"std\",\"ring\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23.5\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.97\"},{\"name\":\"socks\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"brotli\":[\"dep:brotli-decompressor\"],\"charset\":[\"dep:encoding_rs\"],\"cookies\":[\"dep:cookie\",\"dep:cookie_store\"],\"default\":[\"tls\",\"gzip\"],\"gzip\":[\"dep:flate2\"],\"http-crate\":[\"dep:http\"],\"http-interop\":[\"dep:http-02\"],\"json\":[\"dep:serde\",\"dep:serde_json\"],\"native-certs\":[\"dep:rustls-native-certs\"],\"native-tls\":[\"dep:native-tls\"],\"proxy-from-env\":[],\"socks-proxy\":[\"dep:socks\"],\"testdeps\":[\"dep:hootbin\"],\"tls\":[\"dep:webpki-roots\",\"dep:rustls\",\"dep:rustls-pki-types\"]}}", - "url_2.5.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.2.2\"},{\"default_features\":false,\"features\":[\"alloc\",\"compiled_data\"],\"name\":\"idna\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"percent-encoding\",\"req\":\"^2.3.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"debugger_visualizer\":[],\"default\":[\"std\"],\"expose_internals\":[],\"serde\":[\"dep:serde\",\"dep:serde_derive\"],\"std\":[\"idna/std\",\"percent-encoding/std\",\"form_urlencoded/std\",\"serde?/std\"]}}", - "urlencoding_2.1.3": "{\"dependencies\":[],\"features\":{}}", - "utf-8_0.7.6": "{\"dependencies\":[],\"features\":{}}", - "utf8_iter_1.0.4": "{\"dependencies\":[],\"features\":{}}", - "utf8parse_0.2.2": "{\"dependencies\":[],\"features\":{\"default\":[],\"nightly\":[]}}", - "uuid_1.23.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"atomic\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh-derive\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.22\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"md-5\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.56\"},{\"default_features\":false,\"name\":\"sha1_smol\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"slog\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.52\"},{\"name\":\"uuid-rng-internal-lib\",\"optional\":true,\"package\":\"uuid-rng-internal\",\"req\":\"^1.23.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"atomic\":[\"dep:atomic\"],\"borsh\":[\"dep:borsh\",\"dep:borsh-derive\"],\"default\":[\"std\"],\"fast-rng\":[\"rng\",\"dep:rand\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"macro-diagnostics\":[],\"md5\":[\"dep:md-5\"],\"rng\":[\"dep:getrandom\"],\"rng-getrandom\":[\"rng\",\"dep:getrandom\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/getrandom\"],\"rng-rand\":[\"rng\",\"dep:rand\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/rand\"],\"serde\":[\"dep:serde_core\"],\"sha1\":[\"dep:sha1_smol\"],\"std\":[\"wasm-bindgen?/std\",\"js-sys?/std\"],\"v1\":[\"atomic\"],\"v3\":[\"md5\"],\"v4\":[\"rng\"],\"v5\":[\"sha1\"],\"v6\":[\"atomic\"],\"v7\":[\"rng\"],\"v8\":[]}}", - "valuable_0.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"valuable-derive\",\"optional\":true,\"req\":\"=0.1.1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"derive\":[\"valuable-derive\"],\"std\":[\"alloc\"]}}", - "vcpkg_0.2.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3.7\"}],\"features\":{}}", - "version_check_0.9.5": "{\"dependencies\":[],\"features\":{}}", - "vsimd_0.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"const-str\",\"req\":\"^0.5.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.8\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.33\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"alloc\":[],\"detect\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "walkdir_2.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"same-file\",\"req\":\"^1.0.1\"},{\"name\":\"winapi-util\",\"req\":\"^0.1.1\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "walrus-macro_0.24.0": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"name\":\"quote\",\"req\":\"^1.0.37\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.77\"}],\"features\":{}}", - "walrus_0.24.5": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.0\"},{\"name\":\"gimli\",\"req\":\"^0.26.0\"},{\"name\":\"id-arena\",\"req\":\"^2.2.1\"},{\"name\":\"leb128\",\"req\":\"^0.2.4\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"walrus-macro\",\"req\":\"=0.24.0\"},{\"name\":\"wasm-encoder\",\"req\":\"^0.240.0\"},{\"name\":\"wasmparser\",\"req\":\"^0.240.0\"}],\"features\":{\"parallel\":[\"rayon\",\"id-arena/rayon\"]}}", - "want_0.3.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"tokio-executor\",\"req\":\"^0.2.0-alpha.2\"},{\"kind\":\"dev\",\"name\":\"tokio-sync\",\"req\":\"^0.2.0-alpha.2\"},{\"name\":\"try-lock\",\"req\":\"^0.2.4\"}],\"features\":{}}", - "wasi_0.11.1+wasi-snapshot-preview1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\"],\"std\":[]}}", - "wasip2_1.0.2+wasi-0.2.9": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"wit-bindgen\",\"req\":\"^0.51.0\"}],\"features\":{\"bitflags\":[\"wit-bindgen/bitflags\"],\"default\":[\"std\",\"bitflags\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"wit-bindgen/rustc-dep-of-std\"],\"std\":[]}}", - "wasip2_1.0.3+wasi-0.2.9": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"wit-bindgen\",\"req\":\"^0.57.1\"}],\"features\":{\"bitflags\":[\"wit-bindgen/bitflags\"],\"default\":[\"std\",\"bitflags\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"wit-bindgen/rustc-dep-of-std\"],\"std\":[]}}", - "wasip3_0.4.0+wasi-0.3.0-rc-2026-01-06": "{\"dependencies\":[{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.10.1\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"thiserror\",\"optional\":true,\"req\":\"^2.0.17\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"wit-bindgen\",\"req\":\"^0.51.0\"},{\"default_features\":false,\"features\":[\"async-spawn\"],\"kind\":\"dev\",\"name\":\"wit-bindgen\",\"req\":\"^0.51.0\"}],\"features\":{\"http-compat\":[\"dep:bytes\",\"dep:http-body\",\"dep:http\",\"dep:thiserror\",\"wit-bindgen/async-spawn\"]}}", - "wasite_0.1.0": "{\"dependencies\":[],\"features\":{}}", - "wasm-bindgen-cli-support_0.2.105": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"leb128\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"name\":\"rustc-demangle\",\"req\":\"^0.1.13\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"parallel\"],\"name\":\"walrus\",\"req\":\"^0.24.2\"},{\"name\":\"wasm-bindgen-shared\",\"req\":\"=0.2.105\"},{\"name\":\"wasmparser\",\"req\":\"^0.214\"},{\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.214\"},{\"kind\":\"dev\",\"name\":\"wast\",\"req\":\"^214\"},{\"kind\":\"dev\",\"name\":\"wat\",\"req\":\"^1.0\"}],\"features\":{}}", - "wasm-bindgen-cli_0.2.105": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"assert_cmd\",\"req\":\"^2\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"predicates\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.2\"},{\"default_features\":false,\"name\":\"rouille\",\"req\":\"^3.0.0\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"shlex\",\"req\":\"^1\"},{\"name\":\"tempfile\",\"req\":\"^3.0\"},{\"default_features\":false,\"features\":[\"brotli\",\"gzip\"],\"name\":\"ureq\",\"req\":\"^2.7\"},{\"name\":\"walrus\",\"req\":\"^0.24.2\"},{\"name\":\"wasm-bindgen-cli-support\",\"req\":\"=0.2.105\"},{\"kind\":\"dev\",\"name\":\"wasmparser\",\"req\":\"^0.214\"},{\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.214\"}],\"features\":{\"default\":[\"rustls-tls\"],\"native-tls\":[\"ureq/native-tls\"],\"openssl\":[\"dep:native-tls\"],\"rustls-tls\":[\"ureq/tls\"],\"vendored-openssl\":[\"openssl\",\"native-tls/vendored\"]}}", - "wasm-bindgen-futures_0.4.55": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"js-sys\",\"req\":\"=0.3.82\"},{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.105\"},{\"default_features\":false,\"features\":[\"MessageEvent\",\"Worker\"],\"name\":\"web-sys\",\"req\":\"=0.3.82\",\"target\":\"cfg(target_feature = \\\"atomics\\\")\"}],\"features\":{\"default\":[\"std\"],\"futures-core-03-stream\":[\"futures-core\"],\"std\":[\"wasm-bindgen/std\",\"js-sys/std\",\"web-sys/std\"]}}", - "wasm-bindgen-futures_0.4.68": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"features\":[\"futures\"],\"name\":\"js-sys\",\"req\":\"=0.3.95\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.118\"}],\"features\":{\"default\":[\"std\"],\"futures-core-03-stream\":[\"js-sys/futures-core-03-stream\"],\"std\":[\"wasm-bindgen/std\",\"js-sys/std\"]}}", - "wasm-bindgen-macro-support_0.2.105": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.0.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit\",\"visit-mut\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"wasm-bindgen-shared\",\"req\":\"=0.2.105\"}],\"features\":{\"extra-traits\":[\"syn/extra-traits\"],\"strict-macro\":[]}}", - "wasm-bindgen-macro-support_0.2.118": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.0.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit\",\"visit-mut\",\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"wasm-bindgen-shared\",\"req\":\"=0.2.118\"}],\"features\":{\"extra-traits\":[\"syn/extra-traits\"],\"strict-macro\":[]}}", - "wasm-bindgen-macro_0.2.105": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"name\":\"wasm-bindgen-macro-support\",\"req\":\"=0.2.105\"}],\"features\":{\"strict-macro\":[\"wasm-bindgen-macro-support/strict-macro\"]}}", - "wasm-bindgen-macro_0.2.118": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"name\":\"wasm-bindgen-macro-support\",\"req\":\"=0.2.118\"}],\"features\":{\"strict-macro\":[\"wasm-bindgen-macro-support/strict-macro\"]}}", - "wasm-bindgen-shared_0.2.105": "{\"dependencies\":[{\"name\":\"unicode-ident\",\"req\":\"^1.0.5\"}],\"features\":{}}", - "wasm-bindgen-shared_0.2.118": "{\"dependencies\":[{\"name\":\"unicode-ident\",\"req\":\"^1.0.5\"}],\"features\":{}}", - "wasm-bindgen-test-macro_0.3.55": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\",\"derive\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{}}", - "wasm-bindgen-test_0.3.55": "{\"dependencies\":[{\"name\":\"gg-alloc\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"js-sys\",\"req\":\"=0.3.82\"},{\"name\":\"minicov\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", wasm_bindgen_unstable_test_coverage))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.105\"},{\"default_features\":false,\"name\":\"wasm-bindgen-futures\",\"req\":\"=0.4.55\"},{\"name\":\"wasm-bindgen-test-macro\",\"req\":\"=0.3.55\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\",\"js-sys/std\",\"wasm-bindgen-futures/std\"]}}", - "wasm-bindgen_0.2.105": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"build\",\"name\":\"rustversion-compat\",\"package\":\"rustversion\",\"req\":\"^1.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"wasm-bindgen-macro\",\"req\":\"=0.2.105\"},{\"name\":\"wasm-bindgen-shared\",\"req\":\"=0.2.105\"}],\"features\":{\"default\":[\"std\"],\"enable-interning\":[\"std\"],\"gg-alloc\":[],\"msrv\":[],\"rustversion\":[],\"serde-serialize\":[\"serde\",\"serde_json\",\"std\"],\"spans\":[],\"std\":[],\"strict-macro\":[\"wasm-bindgen-macro/strict-macro\"],\"xxx_debug_only_print_generated_code\":[]}}", - "wasm-bindgen_0.2.118": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"build\",\"name\":\"rustversion-compat\",\"package\":\"rustversion\",\"req\":\"^1.0.6\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"wasm-bindgen-macro\",\"req\":\"=0.2.118\"},{\"name\":\"wasm-bindgen-shared\",\"req\":\"=0.2.118\"}],\"features\":{\"default\":[\"std\"],\"enable-interning\":[\"std\"],\"gg-alloc\":[],\"msrv\":[],\"rustversion\":[],\"serde-serialize\":[\"serde\",\"serde_json\",\"std\"],\"spans\":[],\"std\":[],\"strict-macro\":[\"wasm-bindgen-macro/strict-macro\"],\"xxx_debug_only_print_generated_code\":[]}}", - "wasm-encoder_0.240.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"default_features\":false,\"name\":\"leb128fmt\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"features\":[\"simd\",\"simd\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.240.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.240.0\"}],\"features\":{\"component-model\":[\"wasmparser?/component-model\"],\"default\":[\"std\",\"component-model\"],\"std\":[\"wasmparser?/std\"]}}", - "wasm-encoder_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"default_features\":false,\"name\":\"leb128fmt\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"features\":[\"simd\",\"simd\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.244.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.244.0\"}],\"features\":{\"component-model\":[\"wasmparser?/component-model\"],\"default\":[\"std\",\"component-model\"],\"std\":[\"wasmparser?/std\"]}}", - "wasm-metadata_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"auditable-serde\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"spdx\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"component-model\"],\"name\":\"wasm-encoder\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"hash-collections\"],\"name\":\"wasmparser\",\"req\":\"^0.244.0\"}],\"features\":{\"default\":[\"oci\",\"serde\"],\"oci\":[\"dep:auditable-serde\",\"dep:flate2\",\"dep:url\",\"dep:spdx\",\"dep:serde_json\",\"serde\"],\"serde\":[\"dep:serde_derive\",\"dep:serde\"]}}", - "wasm-streams_0.5.0": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.85\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.108\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.58\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.58\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.85\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.85\"}],\"features\":{}}", - "wasmparser_0.214.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.4.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"ahash\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"}],\"features\":{\"default\":[\"std\",\"validate\",\"serde\"],\"no-hash-maps\":[],\"serde\":[\"dep:serde\",\"indexmap/serde\",\"hashbrown/serde\"],\"std\":[\"indexmap/std\"],\"validate\":[\"dep:indexmap\",\"dep:semver\",\"dep:hashbrown\",\"dep:ahash\"]}}", - "wasmparser_0.240.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.4.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"}],\"features\":{\"component-model\":[\"dep:semver\"],\"default\":[\"std\",\"validate\",\"serde\",\"features\",\"component-model\",\"hash-collections\",\"simd\"],\"features\":[],\"hash-collections\":[\"dep:hashbrown\",\"dep:indexmap\"],\"prefer-btree-collections\":[],\"serde\":[\"dep:serde\",\"indexmap?/serde\",\"hashbrown?/serde\"],\"simd\":[],\"std\":[\"indexmap?/std\"],\"validate\":[]}}", - "wasmparser_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.4.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"}],\"features\":{\"component-model\":[\"dep:semver\"],\"default\":[\"std\",\"validate\",\"serde\",\"features\",\"component-model\",\"hash-collections\",\"simd\"],\"features\":[],\"hash-collections\":[\"dep:hashbrown\",\"dep:indexmap\"],\"prefer-btree-collections\":[],\"serde\":[\"dep:serde\",\"indexmap?/serde\",\"hashbrown?/serde\"],\"simd\":[],\"std\":[\"indexmap?/std\"],\"validate\":[]}}", - "web-sys_0.3.82": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"js-sys\",\"req\":\"=0.3.82\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.105\"}],\"features\":{\"AbortController\":[],\"AbortSignal\":[\"EventTarget\"],\"AddEventListenerOptions\":[],\"AesCbcParams\":[],\"AesCtrParams\":[],\"AesDerivedKeyParams\":[],\"AesGcmParams\":[],\"AesKeyAlgorithm\":[],\"AesKeyGenParams\":[],\"Algorithm\":[],\"AlignSetting\":[],\"AllowedBluetoothDevice\":[],\"AllowedUsbDevice\":[],\"AlphaOption\":[],\"AnalyserNode\":[\"AudioNode\",\"EventTarget\"],\"AnalyserOptions\":[],\"AngleInstancedArrays\":[],\"Animation\":[\"EventTarget\"],\"AnimationEffect\":[],\"AnimationEvent\":[\"Event\"],\"AnimationEventInit\":[],\"AnimationPlayState\":[],\"AnimationPlaybackEvent\":[\"Event\"],\"AnimationPlaybackEventInit\":[],\"AnimationPropertyDetails\":[],\"AnimationPropertyValueDetails\":[],\"AnimationTimeline\":[],\"AssignedNodesOptions\":[],\"AttestationConveyancePreference\":[],\"Attr\":[\"EventTarget\",\"Node\"],\"AttributeNameValue\":[],\"AudioBuffer\":[],\"AudioBufferOptions\":[],\"AudioBufferSourceNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"AudioBufferSourceOptions\":[],\"AudioConfiguration\":[],\"AudioContext\":[\"BaseAudioContext\",\"EventTarget\"],\"AudioContextLatencyCategory\":[],\"AudioContextOptions\":[],\"AudioContextState\":[],\"AudioData\":[],\"AudioDataCopyToOptions\":[],\"AudioDataInit\":[],\"AudioDecoder\":[],\"AudioDecoderConfig\":[],\"AudioDecoderInit\":[],\"AudioDecoderSupport\":[],\"AudioDestinationNode\":[\"AudioNode\",\"EventTarget\"],\"AudioEncoder\":[],\"AudioEncoderConfig\":[],\"AudioEncoderInit\":[],\"AudioEncoderSupport\":[],\"AudioListener\":[],\"AudioNode\":[\"EventTarget\"],\"AudioNodeOptions\":[],\"AudioParam\":[],\"AudioParamMap\":[],\"AudioProcessingEvent\":[\"Event\"],\"AudioSampleFormat\":[],\"AudioScheduledSourceNode\":[\"AudioNode\",\"EventTarget\"],\"AudioSinkInfo\":[],\"AudioSinkOptions\":[],\"AudioSinkType\":[],\"AudioStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"AudioTrack\":[],\"AudioTrackList\":[\"EventTarget\"],\"AudioWorklet\":[\"Worklet\"],\"AudioWorkletGlobalScope\":[\"WorkletGlobalScope\"],\"AudioWorkletNode\":[\"AudioNode\",\"EventTarget\"],\"AudioWorkletNodeOptions\":[],\"AudioWorkletProcessor\":[],\"AuthenticationExtensionsClientInputs\":[],\"AuthenticationExtensionsClientInputsJson\":[],\"AuthenticationExtensionsClientOutputs\":[],\"AuthenticationExtensionsClientOutputsJson\":[],\"AuthenticationExtensionsDevicePublicKeyInputs\":[],\"AuthenticationExtensionsDevicePublicKeyOutputs\":[],\"AuthenticationExtensionsLargeBlobInputs\":[],\"AuthenticationExtensionsLargeBlobOutputs\":[],\"AuthenticationExtensionsPrfInputs\":[],\"AuthenticationExtensionsPrfOutputs\":[],\"AuthenticationExtensionsPrfValues\":[],\"AuthenticationResponseJson\":[],\"AuthenticatorAssertionResponse\":[\"AuthenticatorResponse\"],\"AuthenticatorAssertionResponseJson\":[],\"AuthenticatorAttachment\":[],\"AuthenticatorAttestationResponse\":[\"AuthenticatorResponse\"],\"AuthenticatorAttestationResponseJson\":[],\"AuthenticatorResponse\":[],\"AuthenticatorSelectionCriteria\":[],\"AuthenticatorTransport\":[],\"AutoKeyword\":[],\"AutocompleteInfo\":[],\"BarProp\":[],\"BaseAudioContext\":[\"EventTarget\"],\"BaseComputedKeyframe\":[],\"BaseKeyframe\":[],\"BasePropertyIndexedKeyframe\":[],\"BasicCardRequest\":[],\"BasicCardResponse\":[],\"BasicCardType\":[],\"BatteryManager\":[\"EventTarget\"],\"BeforeUnloadEvent\":[\"Event\"],\"BinaryType\":[],\"BiquadFilterNode\":[\"AudioNode\",\"EventTarget\"],\"BiquadFilterOptions\":[],\"BiquadFilterType\":[],\"Blob\":[],\"BlobEvent\":[\"Event\"],\"BlobEventInit\":[],\"BlobPropertyBag\":[],\"BlockParsingOptions\":[],\"Bluetooth\":[\"EventTarget\"],\"BluetoothAdvertisingEvent\":[\"Event\"],\"BluetoothAdvertisingEventInit\":[],\"BluetoothCharacteristicProperties\":[],\"BluetoothDataFilterInit\":[],\"BluetoothDevice\":[\"EventTarget\"],\"BluetoothLeScanFilterInit\":[],\"BluetoothManufacturerDataMap\":[],\"BluetoothPermissionDescriptor\":[],\"BluetoothPermissionResult\":[\"EventTarget\",\"PermissionStatus\"],\"BluetoothPermissionStorage\":[],\"BluetoothRemoteGattCharacteristic\":[\"EventTarget\"],\"BluetoothRemoteGattDescriptor\":[],\"BluetoothRemoteGattServer\":[],\"BluetoothRemoteGattService\":[\"EventTarget\"],\"BluetoothServiceDataMap\":[],\"BluetoothUuid\":[],\"BoxQuadOptions\":[],\"BroadcastChannel\":[\"EventTarget\"],\"BrowserElementDownloadOptions\":[],\"BrowserElementExecuteScriptOptions\":[],\"BrowserFeedWriter\":[],\"BrowserFindCaseSensitivity\":[],\"BrowserFindDirection\":[],\"ByteLengthQueuingStrategy\":[],\"Cache\":[],\"CacheBatchOperation\":[],\"CacheQueryOptions\":[],\"CacheStorage\":[],\"CacheStorageNamespace\":[],\"CanvasCaptureMediaStream\":[\"EventTarget\",\"MediaStream\"],\"CanvasCaptureMediaStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"CanvasGradient\":[],\"CanvasPattern\":[],\"CanvasRenderingContext2d\":[],\"CanvasWindingRule\":[],\"CaretChangedReason\":[],\"CaretPosition\":[],\"CaretStateChangedEventInit\":[],\"CdataSection\":[\"CharacterData\",\"EventTarget\",\"Node\",\"Text\"],\"ChannelCountMode\":[],\"ChannelInterpretation\":[],\"ChannelMergerNode\":[\"AudioNode\",\"EventTarget\"],\"ChannelMergerOptions\":[],\"ChannelSplitterNode\":[\"AudioNode\",\"EventTarget\"],\"ChannelSplitterOptions\":[],\"CharacterData\":[\"EventTarget\",\"Node\"],\"CheckerboardReason\":[],\"CheckerboardReport\":[],\"CheckerboardReportService\":[],\"ChromeFilePropertyBag\":[],\"ChromeWorker\":[\"EventTarget\",\"Worker\"],\"Client\":[],\"ClientQueryOptions\":[],\"ClientRectsAndTexts\":[],\"ClientType\":[],\"Clients\":[],\"Clipboard\":[\"EventTarget\"],\"ClipboardEvent\":[\"Event\"],\"ClipboardEventInit\":[],\"ClipboardItem\":[],\"ClipboardItemOptions\":[],\"ClipboardPermissionDescriptor\":[],\"ClipboardUnsanitizedFormats\":[],\"CloseEvent\":[\"Event\"],\"CloseEventInit\":[],\"CodecState\":[],\"CollectedClientData\":[],\"ColorSpaceConversion\":[],\"Comment\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"CompositeOperation\":[],\"CompositionEvent\":[\"Event\",\"UiEvent\"],\"CompositionEventInit\":[],\"CompressionFormat\":[],\"CompressionStream\":[],\"ComputedEffectTiming\":[],\"ConnStatusDict\":[],\"ConnectionType\":[],\"ConsoleCounter\":[],\"ConsoleCounterError\":[],\"ConsoleEvent\":[],\"ConsoleInstance\":[],\"ConsoleInstanceOptions\":[],\"ConsoleLevel\":[],\"ConsoleLogLevel\":[],\"ConsoleProfileEvent\":[],\"ConsoleStackEntry\":[],\"ConsoleTimerError\":[],\"ConsoleTimerLogOrEnd\":[],\"ConsoleTimerStart\":[],\"ConstantSourceNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"ConstantSourceOptions\":[],\"ConstrainBooleanParameters\":[],\"ConstrainDomStringParameters\":[],\"ConstrainDoubleRange\":[],\"ConstrainLongRange\":[],\"ContextAttributes2d\":[],\"ConvertCoordinateOptions\":[],\"ConvolverNode\":[\"AudioNode\",\"EventTarget\"],\"ConvolverOptions\":[],\"CookieChangeEvent\":[\"Event\"],\"CookieChangeEventInit\":[],\"CookieInit\":[],\"CookieListItem\":[],\"CookieSameSite\":[],\"CookieStore\":[\"EventTarget\"],\"CookieStoreDeleteOptions\":[],\"CookieStoreGetOptions\":[],\"CookieStoreManager\":[],\"Coordinates\":[],\"CountQueuingStrategy\":[],\"Credential\":[],\"CredentialCreationOptions\":[],\"CredentialPropertiesOutput\":[],\"CredentialRequestOptions\":[],\"CredentialsContainer\":[],\"Crypto\":[],\"CryptoKey\":[],\"CryptoKeyPair\":[],\"CssAnimation\":[\"Animation\",\"EventTarget\"],\"CssBoxType\":[],\"CssConditionRule\":[\"CssGroupingRule\",\"CssRule\"],\"CssCounterStyleRule\":[\"CssRule\"],\"CssFontFaceRule\":[\"CssRule\"],\"CssFontFeatureValuesRule\":[\"CssRule\"],\"CssGroupingRule\":[\"CssRule\"],\"CssImportRule\":[\"CssRule\"],\"CssKeyframeRule\":[\"CssRule\"],\"CssKeyframesRule\":[\"CssRule\"],\"CssMediaRule\":[\"CssConditionRule\",\"CssGroupingRule\",\"CssRule\"],\"CssNamespaceRule\":[\"CssRule\"],\"CssPageRule\":[\"CssRule\"],\"CssPseudoElement\":[],\"CssRule\":[],\"CssRuleList\":[],\"CssStyleDeclaration\":[],\"CssStyleRule\":[\"CssRule\"],\"CssStyleSheet\":[\"StyleSheet\"],\"CssStyleSheetParsingMode\":[],\"CssSupportsRule\":[\"CssConditionRule\",\"CssGroupingRule\",\"CssRule\"],\"CssTransition\":[\"Animation\",\"EventTarget\"],\"CustomElementRegistry\":[],\"CustomEvent\":[\"Event\"],\"CustomEventInit\":[],\"DataTransfer\":[],\"DataTransferItem\":[],\"DataTransferItemList\":[],\"DateTimeValue\":[],\"DecoderDoctorNotification\":[],\"DecoderDoctorNotificationType\":[],\"DecompressionStream\":[],\"DedicatedWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"DelayNode\":[\"AudioNode\",\"EventTarget\"],\"DelayOptions\":[],\"DeviceAcceleration\":[],\"DeviceAccelerationInit\":[],\"DeviceLightEvent\":[\"Event\"],\"DeviceLightEventInit\":[],\"DeviceMotionEvent\":[\"Event\"],\"DeviceMotionEventInit\":[],\"DeviceOrientationEvent\":[\"Event\"],\"DeviceOrientationEventInit\":[],\"DeviceProximityEvent\":[\"Event\"],\"DeviceProximityEventInit\":[],\"DeviceRotationRate\":[],\"DeviceRotationRateInit\":[],\"DhKeyDeriveParams\":[],\"DirectionSetting\":[],\"Directory\":[],\"DirectoryPickerOptions\":[],\"DisplayMediaStreamConstraints\":[],\"DisplayNameOptions\":[],\"DisplayNameResult\":[],\"DistanceModelType\":[],\"DnsCacheDict\":[],\"DnsCacheEntry\":[],\"DnsLookupDict\":[],\"Document\":[\"EventTarget\",\"Node\"],\"DocumentFragment\":[\"EventTarget\",\"Node\"],\"DocumentTimeline\":[\"AnimationTimeline\"],\"DocumentTimelineOptions\":[],\"DocumentType\":[\"EventTarget\",\"Node\"],\"DomError\":[],\"DomException\":[],\"DomImplementation\":[],\"DomMatrix\":[\"DomMatrixReadOnly\"],\"DomMatrix2dInit\":[],\"DomMatrixInit\":[],\"DomMatrixReadOnly\":[],\"DomParser\":[],\"DomPoint\":[\"DomPointReadOnly\"],\"DomPointInit\":[],\"DomPointReadOnly\":[],\"DomQuad\":[],\"DomQuadInit\":[],\"DomQuadJson\":[],\"DomRect\":[\"DomRectReadOnly\"],\"DomRectInit\":[],\"DomRectList\":[],\"DomRectReadOnly\":[],\"DomRequest\":[\"EventTarget\"],\"DomRequestReadyState\":[],\"DomStringList\":[],\"DomStringMap\":[],\"DomTokenList\":[],\"DomWindowResizeEventDetail\":[],\"DoubleRange\":[],\"DragEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"DragEventInit\":[],\"DynamicsCompressorNode\":[\"AudioNode\",\"EventTarget\"],\"DynamicsCompressorOptions\":[],\"EcKeyAlgorithm\":[],\"EcKeyGenParams\":[],\"EcKeyImportParams\":[],\"EcdhKeyDeriveParams\":[],\"EcdsaParams\":[],\"EffectTiming\":[],\"Element\":[\"EventTarget\",\"Node\"],\"ElementCreationOptions\":[],\"ElementDefinitionOptions\":[],\"EncodedAudioChunk\":[],\"EncodedAudioChunkInit\":[],\"EncodedAudioChunkMetadata\":[],\"EncodedAudioChunkType\":[],\"EncodedVideoChunk\":[],\"EncodedVideoChunkInit\":[],\"EncodedVideoChunkMetadata\":[],\"EncodedVideoChunkType\":[],\"EndingTypes\":[],\"ErrorCallback\":[],\"ErrorEvent\":[\"Event\"],\"ErrorEventInit\":[],\"Event\":[],\"EventInit\":[],\"EventListener\":[],\"EventListenerOptions\":[],\"EventModifierInit\":[],\"EventSource\":[\"EventTarget\"],\"EventSourceInit\":[],\"EventTarget\":[],\"Exception\":[],\"ExtBlendMinmax\":[],\"ExtColorBufferFloat\":[],\"ExtColorBufferHalfFloat\":[],\"ExtDisjointTimerQuery\":[],\"ExtFragDepth\":[],\"ExtSRgb\":[],\"ExtShaderTextureLod\":[],\"ExtTextureFilterAnisotropic\":[],\"ExtTextureNorm16\":[],\"ExtendableCookieChangeEvent\":[\"Event\",\"ExtendableEvent\"],\"ExtendableCookieChangeEventInit\":[],\"ExtendableEvent\":[\"Event\"],\"ExtendableEventInit\":[],\"ExtendableMessageEvent\":[\"Event\",\"ExtendableEvent\"],\"ExtendableMessageEventInit\":[],\"External\":[],\"FakePluginMimeEntry\":[],\"FakePluginTagInit\":[],\"FetchEvent\":[\"Event\",\"ExtendableEvent\"],\"FetchEventInit\":[],\"FetchObserver\":[\"EventTarget\"],\"FetchReadableStreamReadDataArray\":[],\"FetchReadableStreamReadDataDone\":[],\"FetchState\":[],\"File\":[\"Blob\"],\"FileCallback\":[],\"FileList\":[],\"FilePickerAcceptType\":[],\"FilePickerOptions\":[],\"FilePropertyBag\":[],\"FileReader\":[\"EventTarget\"],\"FileReaderSync\":[],\"FileSystem\":[],\"FileSystemCreateWritableOptions\":[],\"FileSystemDirectoryEntry\":[\"FileSystemEntry\"],\"FileSystemDirectoryHandle\":[\"FileSystemHandle\"],\"FileSystemDirectoryReader\":[],\"FileSystemEntriesCallback\":[],\"FileSystemEntry\":[],\"FileSystemEntryCallback\":[],\"FileSystemFileEntry\":[\"FileSystemEntry\"],\"FileSystemFileHandle\":[\"FileSystemHandle\"],\"FileSystemFlags\":[],\"FileSystemGetDirectoryOptions\":[],\"FileSystemGetFileOptions\":[],\"FileSystemHandle\":[],\"FileSystemHandleKind\":[],\"FileSystemHandlePermissionDescriptor\":[],\"FileSystemPermissionDescriptor\":[],\"FileSystemPermissionMode\":[],\"FileSystemReadWriteOptions\":[],\"FileSystemRemoveOptions\":[],\"FileSystemSyncAccessHandle\":[],\"FileSystemWritableFileStream\":[\"WritableStream\"],\"FillMode\":[],\"FlashClassification\":[],\"FlowControlType\":[],\"FocusEvent\":[\"Event\",\"UiEvent\"],\"FocusEventInit\":[],\"FocusOptions\":[],\"FontData\":[],\"FontFace\":[],\"FontFaceDescriptors\":[],\"FontFaceLoadStatus\":[],\"FontFaceSet\":[\"EventTarget\"],\"FontFaceSetIterator\":[],\"FontFaceSetIteratorResult\":[],\"FontFaceSetLoadEvent\":[\"Event\"],\"FontFaceSetLoadEventInit\":[],\"FontFaceSetLoadStatus\":[],\"FormData\":[],\"FrameType\":[],\"FuzzingFunctions\":[],\"GainNode\":[\"AudioNode\",\"EventTarget\"],\"GainOptions\":[],\"Gamepad\":[],\"GamepadButton\":[],\"GamepadEffectParameters\":[],\"GamepadEvent\":[\"Event\"],\"GamepadEventInit\":[],\"GamepadHand\":[],\"GamepadHapticActuator\":[],\"GamepadHapticActuatorType\":[],\"GamepadHapticEffectType\":[],\"GamepadHapticsResult\":[],\"GamepadMappingType\":[],\"GamepadPose\":[],\"GamepadTouch\":[],\"Geolocation\":[],\"GestureEvent\":[\"Event\",\"UiEvent\"],\"GetAnimationsOptions\":[],\"GetRootNodeOptions\":[],\"GetUserMediaRequest\":[],\"Gpu\":[],\"GpuAdapter\":[],\"GpuAdapterInfo\":[],\"GpuAddressMode\":[],\"GpuAutoLayoutMode\":[],\"GpuBindGroup\":[],\"GpuBindGroupDescriptor\":[],\"GpuBindGroupEntry\":[],\"GpuBindGroupLayout\":[],\"GpuBindGroupLayoutDescriptor\":[],\"GpuBindGroupLayoutEntry\":[],\"GpuBlendComponent\":[],\"GpuBlendFactor\":[],\"GpuBlendOperation\":[],\"GpuBlendState\":[],\"GpuBuffer\":[],\"GpuBufferBinding\":[],\"GpuBufferBindingLayout\":[],\"GpuBufferBindingType\":[],\"GpuBufferDescriptor\":[],\"GpuBufferMapState\":[],\"GpuCanvasAlphaMode\":[],\"GpuCanvasConfiguration\":[],\"GpuCanvasContext\":[],\"GpuCanvasToneMapping\":[],\"GpuCanvasToneMappingMode\":[],\"GpuColorDict\":[],\"GpuColorTargetState\":[],\"GpuCommandBuffer\":[],\"GpuCommandBufferDescriptor\":[],\"GpuCommandEncoder\":[],\"GpuCommandEncoderDescriptor\":[],\"GpuCompareFunction\":[],\"GpuCompilationInfo\":[],\"GpuCompilationMessage\":[],\"GpuCompilationMessageType\":[],\"GpuComputePassDescriptor\":[],\"GpuComputePassEncoder\":[],\"GpuComputePassTimestampWrites\":[],\"GpuComputePipeline\":[],\"GpuComputePipelineDescriptor\":[],\"GpuCopyExternalImageDestInfo\":[],\"GpuCopyExternalImageSourceInfo\":[],\"GpuCullMode\":[],\"GpuDepthStencilState\":[],\"GpuDevice\":[\"EventTarget\"],\"GpuDeviceDescriptor\":[],\"GpuDeviceLostInfo\":[],\"GpuDeviceLostReason\":[],\"GpuError\":[],\"GpuErrorFilter\":[],\"GpuExtent3dDict\":[],\"GpuExternalTexture\":[],\"GpuExternalTextureBindingLayout\":[],\"GpuExternalTextureDescriptor\":[],\"GpuFeatureName\":[],\"GpuFilterMode\":[],\"GpuFragmentState\":[],\"GpuFrontFace\":[],\"GpuIndexFormat\":[],\"GpuInternalError\":[\"GpuError\"],\"GpuLoadOp\":[],\"GpuMipmapFilterMode\":[],\"GpuMultisampleState\":[],\"GpuObjectDescriptorBase\":[],\"GpuOrigin2dDict\":[],\"GpuOrigin3dDict\":[],\"GpuOutOfMemoryError\":[\"GpuError\"],\"GpuPipelineDescriptorBase\":[],\"GpuPipelineError\":[\"DomException\"],\"GpuPipelineErrorInit\":[],\"GpuPipelineErrorReason\":[],\"GpuPipelineLayout\":[],\"GpuPipelineLayoutDescriptor\":[],\"GpuPowerPreference\":[],\"GpuPrimitiveState\":[],\"GpuPrimitiveTopology\":[],\"GpuProgrammableStage\":[],\"GpuQuerySet\":[],\"GpuQuerySetDescriptor\":[],\"GpuQueryType\":[],\"GpuQueue\":[],\"GpuQueueDescriptor\":[],\"GpuRenderBundle\":[],\"GpuRenderBundleDescriptor\":[],\"GpuRenderBundleEncoder\":[],\"GpuRenderBundleEncoderDescriptor\":[],\"GpuRenderPassColorAttachment\":[],\"GpuRenderPassDepthStencilAttachment\":[],\"GpuRenderPassDescriptor\":[],\"GpuRenderPassEncoder\":[],\"GpuRenderPassLayout\":[],\"GpuRenderPassTimestampWrites\":[],\"GpuRenderPipeline\":[],\"GpuRenderPipelineDescriptor\":[],\"GpuRequestAdapterOptions\":[],\"GpuSampler\":[],\"GpuSamplerBindingLayout\":[],\"GpuSamplerBindingType\":[],\"GpuSamplerDescriptor\":[],\"GpuShaderModule\":[],\"GpuShaderModuleCompilationHint\":[],\"GpuShaderModuleDescriptor\":[],\"GpuStencilFaceState\":[],\"GpuStencilOperation\":[],\"GpuStorageTextureAccess\":[],\"GpuStorageTextureBindingLayout\":[],\"GpuStoreOp\":[],\"GpuSupportedFeatures\":[],\"GpuSupportedLimits\":[],\"GpuTexelCopyBufferInfo\":[],\"GpuTexelCopyBufferLayout\":[],\"GpuTexelCopyTextureInfo\":[],\"GpuTexture\":[],\"GpuTextureAspect\":[],\"GpuTextureBindingLayout\":[],\"GpuTextureDescriptor\":[],\"GpuTextureDimension\":[],\"GpuTextureFormat\":[],\"GpuTextureSampleType\":[],\"GpuTextureView\":[],\"GpuTextureViewDescriptor\":[],\"GpuTextureViewDimension\":[],\"GpuUncapturedErrorEvent\":[\"Event\"],\"GpuUncapturedErrorEventInit\":[],\"GpuValidationError\":[\"GpuError\"],\"GpuVertexAttribute\":[],\"GpuVertexBufferLayout\":[],\"GpuVertexFormat\":[],\"GpuVertexState\":[],\"GpuVertexStepMode\":[],\"GroupedHistoryEventInit\":[],\"HalfOpenInfoDict\":[],\"HardwareAcceleration\":[],\"HashChangeEvent\":[\"Event\"],\"HashChangeEventInit\":[],\"Headers\":[],\"HeadersGuardEnum\":[],\"Hid\":[\"EventTarget\"],\"HidCollectionInfo\":[],\"HidConnectionEvent\":[\"Event\"],\"HidConnectionEventInit\":[],\"HidDevice\":[\"EventTarget\"],\"HidDeviceFilter\":[],\"HidDeviceRequestOptions\":[],\"HidInputReportEvent\":[\"Event\"],\"HidInputReportEventInit\":[],\"HidReportInfo\":[],\"HidReportItem\":[],\"HidUnitSystem\":[],\"HiddenPluginEventInit\":[],\"History\":[],\"HitRegionOptions\":[],\"HkdfParams\":[],\"HmacDerivedKeyParams\":[],\"HmacImportParams\":[],\"HmacKeyAlgorithm\":[],\"HmacKeyGenParams\":[],\"HtmlAllCollection\":[],\"HtmlAnchorElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlAreaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlAudioElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"HtmlMediaElement\",\"Node\"],\"HtmlBaseElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlBodyElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlBrElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlButtonElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlCanvasElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlCollection\":[],\"HtmlDListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDataElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDataListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDetailsElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDialogElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDirectoryElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDivElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDocument\":[\"Document\",\"EventTarget\",\"Node\"],\"HtmlElement\":[\"Element\",\"EventTarget\",\"Node\"],\"HtmlEmbedElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFieldSetElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFontElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFormControlsCollection\":[\"HtmlCollection\"],\"HtmlFormElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFrameElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFrameSetElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHeadElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHeadingElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHrElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHtmlElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlIFrameElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlImageElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlInputElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLabelElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLegendElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLiElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLinkElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMapElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMediaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMenuElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMenuItemElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMetaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMeterElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlModElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlObjectElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptGroupElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptionsCollection\":[\"HtmlCollection\"],\"HtmlOutputElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlParagraphElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlParamElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlPictureElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlPreElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlProgressElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlQuoteElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlScriptElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSelectElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSlotElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSourceElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSpanElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlStyleElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableCaptionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableCellElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableColElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableRowElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableSectionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTemplateElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTextAreaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTimeElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTitleElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTrackElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlUListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlUnknownElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlVideoElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"HtmlMediaElement\",\"Node\"],\"HttpConnDict\":[],\"HttpConnInfo\":[],\"HttpConnectionElement\":[],\"IdbCursor\":[],\"IdbCursorDirection\":[],\"IdbCursorWithValue\":[\"IdbCursor\"],\"IdbDatabase\":[\"EventTarget\"],\"IdbFactory\":[],\"IdbFileHandle\":[\"EventTarget\"],\"IdbFileMetadataParameters\":[],\"IdbFileRequest\":[\"DomRequest\",\"EventTarget\"],\"IdbIndex\":[],\"IdbIndexParameters\":[],\"IdbKeyRange\":[],\"IdbLocaleAwareKeyRange\":[\"IdbKeyRange\"],\"IdbMutableFile\":[\"EventTarget\"],\"IdbObjectStore\":[],\"IdbObjectStoreParameters\":[],\"IdbOpenDbOptions\":[],\"IdbOpenDbRequest\":[\"EventTarget\",\"IdbRequest\"],\"IdbRequest\":[\"EventTarget\"],\"IdbRequestReadyState\":[],\"IdbTransaction\":[\"EventTarget\"],\"IdbTransactionDurability\":[],\"IdbTransactionMode\":[],\"IdbTransactionOptions\":[],\"IdbVersionChangeEvent\":[\"Event\"],\"IdbVersionChangeEventInit\":[],\"IdleDeadline\":[],\"IdleRequestOptions\":[],\"IirFilterNode\":[\"AudioNode\",\"EventTarget\"],\"IirFilterOptions\":[],\"ImageBitmap\":[],\"ImageBitmapOptions\":[],\"ImageBitmapRenderingContext\":[],\"ImageCapture\":[],\"ImageCaptureError\":[],\"ImageCaptureErrorEvent\":[\"Event\"],\"ImageCaptureErrorEventInit\":[],\"ImageData\":[],\"ImageDecodeOptions\":[],\"ImageDecodeResult\":[],\"ImageDecoder\":[],\"ImageDecoderInit\":[],\"ImageEncodeOptions\":[],\"ImageOrientation\":[],\"ImageTrack\":[\"EventTarget\"],\"ImageTrackList\":[],\"InputDeviceInfo\":[\"MediaDeviceInfo\"],\"InputEvent\":[\"Event\",\"UiEvent\"],\"InputEventInit\":[],\"IntersectionObserver\":[],\"IntersectionObserverEntry\":[],\"IntersectionObserverEntryInit\":[],\"IntersectionObserverInit\":[],\"IntlUtils\":[],\"IsInputPendingOptions\":[],\"IterableKeyAndValueResult\":[],\"IterableKeyOrValueResult\":[],\"IterationCompositeOperation\":[],\"JsonWebKey\":[],\"KeyAlgorithm\":[],\"KeyEvent\":[],\"KeyFrameRequestEvent\":[\"Event\"],\"KeyIdsInitData\":[],\"KeyboardEvent\":[\"Event\",\"UiEvent\"],\"KeyboardEventInit\":[],\"KeyframeAnimationOptions\":[],\"KeyframeEffect\":[\"AnimationEffect\"],\"KeyframeEffectOptions\":[],\"L10nElement\":[],\"L10nValue\":[],\"LargeBlobSupport\":[],\"LatencyMode\":[],\"LifecycleCallbacks\":[],\"LineAlignSetting\":[],\"ListBoxObject\":[],\"LocalMediaStream\":[\"EventTarget\",\"MediaStream\"],\"LocaleInfo\":[],\"Location\":[],\"Lock\":[],\"LockInfo\":[],\"LockManager\":[],\"LockManagerSnapshot\":[],\"LockMode\":[],\"LockOptions\":[],\"MathMlElement\":[\"Element\",\"EventTarget\",\"Node\"],\"MediaCapabilities\":[],\"MediaCapabilitiesInfo\":[],\"MediaConfiguration\":[],\"MediaDecodingConfiguration\":[],\"MediaDecodingType\":[],\"MediaDeviceInfo\":[],\"MediaDeviceKind\":[],\"MediaDevices\":[\"EventTarget\"],\"MediaElementAudioSourceNode\":[\"AudioNode\",\"EventTarget\"],\"MediaElementAudioSourceOptions\":[],\"MediaEncodingConfiguration\":[],\"MediaEncodingType\":[],\"MediaEncryptedEvent\":[\"Event\"],\"MediaError\":[],\"MediaImage\":[],\"MediaKeyError\":[\"Event\"],\"MediaKeyMessageEvent\":[\"Event\"],\"MediaKeyMessageEventInit\":[],\"MediaKeyMessageType\":[],\"MediaKeyNeededEventInit\":[],\"MediaKeySession\":[\"EventTarget\"],\"MediaKeySessionType\":[],\"MediaKeyStatus\":[],\"MediaKeyStatusMap\":[],\"MediaKeySystemAccess\":[],\"MediaKeySystemConfiguration\":[],\"MediaKeySystemMediaCapability\":[],\"MediaKeySystemStatus\":[],\"MediaKeys\":[],\"MediaKeysPolicy\":[],\"MediaKeysRequirement\":[],\"MediaList\":[],\"MediaMetadata\":[],\"MediaMetadataInit\":[],\"MediaPositionState\":[],\"MediaQueryList\":[\"EventTarget\"],\"MediaQueryListEvent\":[\"Event\"],\"MediaQueryListEventInit\":[],\"MediaRecorder\":[\"EventTarget\"],\"MediaRecorderErrorEvent\":[\"Event\"],\"MediaRecorderErrorEventInit\":[],\"MediaRecorderOptions\":[],\"MediaSession\":[],\"MediaSessionAction\":[],\"MediaSessionActionDetails\":[],\"MediaSessionPlaybackState\":[],\"MediaSource\":[\"EventTarget\"],\"MediaSourceEndOfStreamError\":[],\"MediaSourceEnum\":[],\"MediaSourceReadyState\":[],\"MediaStream\":[\"EventTarget\"],\"MediaStreamAudioDestinationNode\":[\"AudioNode\",\"EventTarget\"],\"MediaStreamAudioSourceNode\":[\"AudioNode\",\"EventTarget\"],\"MediaStreamAudioSourceOptions\":[],\"MediaStreamConstraints\":[],\"MediaStreamError\":[],\"MediaStreamEvent\":[\"Event\"],\"MediaStreamEventInit\":[],\"MediaStreamTrack\":[\"EventTarget\"],\"MediaStreamTrackEvent\":[\"Event\"],\"MediaStreamTrackEventInit\":[],\"MediaStreamTrackGenerator\":[\"EventTarget\",\"MediaStreamTrack\"],\"MediaStreamTrackGeneratorInit\":[],\"MediaStreamTrackProcessor\":[],\"MediaStreamTrackProcessorInit\":[],\"MediaStreamTrackState\":[],\"MediaTrackCapabilities\":[],\"MediaTrackConstraintSet\":[],\"MediaTrackConstraints\":[],\"MediaTrackSettings\":[],\"MediaTrackSupportedConstraints\":[],\"MemoryAttribution\":[],\"MemoryAttributionContainer\":[],\"MemoryBreakdownEntry\":[],\"MemoryMeasurement\":[],\"MessageChannel\":[],\"MessageEvent\":[\"Event\"],\"MessageEventInit\":[],\"MessagePort\":[\"EventTarget\"],\"MidiAccess\":[\"EventTarget\"],\"MidiConnectionEvent\":[\"Event\"],\"MidiConnectionEventInit\":[],\"MidiInput\":[\"EventTarget\",\"MidiPort\"],\"MidiInputMap\":[],\"MidiMessageEvent\":[\"Event\"],\"MidiMessageEventInit\":[],\"MidiOptions\":[],\"MidiOutput\":[\"EventTarget\",\"MidiPort\"],\"MidiOutputMap\":[],\"MidiPort\":[\"EventTarget\"],\"MidiPortConnectionState\":[],\"MidiPortDeviceState\":[],\"MidiPortType\":[],\"MimeType\":[],\"MimeTypeArray\":[],\"MouseEvent\":[\"Event\",\"UiEvent\"],\"MouseEventInit\":[],\"MouseScrollEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"MozDebug\":[],\"MutationEvent\":[\"Event\"],\"MutationObserver\":[],\"MutationObserverInit\":[],\"MutationObservingInfo\":[],\"MutationRecord\":[],\"NamedNodeMap\":[],\"NativeOsFileReadOptions\":[],\"NativeOsFileWriteAtomicOptions\":[],\"NavigationType\":[],\"Navigator\":[],\"NavigatorAutomationInformation\":[],\"NavigatorUaBrandVersion\":[],\"NavigatorUaData\":[],\"NetworkCommandOptions\":[],\"NetworkInformation\":[\"EventTarget\"],\"NetworkResultOptions\":[],\"Node\":[\"EventTarget\"],\"NodeFilter\":[],\"NodeIterator\":[],\"NodeList\":[],\"Notification\":[\"EventTarget\"],\"NotificationAction\":[],\"NotificationDirection\":[],\"NotificationEvent\":[\"Event\",\"ExtendableEvent\"],\"NotificationEventInit\":[],\"NotificationOptions\":[],\"NotificationPermission\":[],\"ObserverCallback\":[],\"OesElementIndexUint\":[],\"OesStandardDerivatives\":[],\"OesTextureFloat\":[],\"OesTextureFloatLinear\":[],\"OesTextureHalfFloat\":[],\"OesTextureHalfFloatLinear\":[],\"OesVertexArrayObject\":[],\"OfflineAudioCompletionEvent\":[\"Event\"],\"OfflineAudioCompletionEventInit\":[],\"OfflineAudioContext\":[\"BaseAudioContext\",\"EventTarget\"],\"OfflineAudioContextOptions\":[],\"OfflineResourceList\":[\"EventTarget\"],\"OffscreenCanvas\":[\"EventTarget\"],\"OffscreenCanvasRenderingContext2d\":[],\"OpenFilePickerOptions\":[],\"OpenWindowEventDetail\":[],\"OptionalEffectTiming\":[],\"OrientationLockType\":[],\"OrientationType\":[],\"OscillatorNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"OscillatorOptions\":[],\"OscillatorType\":[],\"OverSampleType\":[],\"OvrMultiview2\":[],\"PageTransitionEvent\":[\"Event\"],\"PageTransitionEventInit\":[],\"PaintRequest\":[],\"PaintRequestList\":[],\"PaintWorkletGlobalScope\":[\"WorkletGlobalScope\"],\"PannerNode\":[\"AudioNode\",\"EventTarget\"],\"PannerOptions\":[],\"PanningModelType\":[],\"ParityType\":[],\"Path2d\":[],\"PaymentAddress\":[],\"PaymentComplete\":[],\"PaymentMethodChangeEvent\":[\"Event\",\"PaymentRequestUpdateEvent\"],\"PaymentMethodChangeEventInit\":[],\"PaymentRequestUpdateEvent\":[\"Event\"],\"PaymentRequestUpdateEventInit\":[],\"PaymentResponse\":[],\"Pbkdf2Params\":[],\"PcImplIceConnectionState\":[],\"PcImplIceGatheringState\":[],\"PcImplSignalingState\":[],\"PcObserverStateType\":[],\"Performance\":[\"EventTarget\"],\"PerformanceEntry\":[],\"PerformanceEntryEventInit\":[],\"PerformanceEntryFilterOptions\":[],\"PerformanceMark\":[\"PerformanceEntry\"],\"PerformanceMeasure\":[\"PerformanceEntry\"],\"PerformanceNavigation\":[],\"PerformanceNavigationTiming\":[\"PerformanceEntry\",\"PerformanceResourceTiming\"],\"PerformanceObserver\":[],\"PerformanceObserverEntryList\":[],\"PerformanceObserverInit\":[],\"PerformanceResourceTiming\":[\"PerformanceEntry\"],\"PerformanceServerTiming\":[],\"PerformanceTiming\":[],\"PeriodicWave\":[],\"PeriodicWaveConstraints\":[],\"PeriodicWaveOptions\":[],\"PermissionDescriptor\":[],\"PermissionName\":[],\"PermissionState\":[],\"PermissionStatus\":[\"EventTarget\"],\"Permissions\":[],\"PictureInPictureEvent\":[\"Event\"],\"PictureInPictureEventInit\":[],\"PictureInPictureWindow\":[\"EventTarget\"],\"PlaneLayout\":[],\"PlaybackDirection\":[],\"Plugin\":[],\"PluginArray\":[],\"PluginCrashedEventInit\":[],\"PointerEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"PointerEventInit\":[],\"PopStateEvent\":[\"Event\"],\"PopStateEventInit\":[],\"PopupBlockedEvent\":[\"Event\"],\"PopupBlockedEventInit\":[],\"Position\":[],\"PositionAlignSetting\":[],\"PositionError\":[],\"PositionOptions\":[],\"PremultiplyAlpha\":[],\"Presentation\":[],\"PresentationAvailability\":[\"EventTarget\"],\"PresentationConnection\":[\"EventTarget\"],\"PresentationConnectionAvailableEvent\":[\"Event\"],\"PresentationConnectionAvailableEventInit\":[],\"PresentationConnectionBinaryType\":[],\"PresentationConnectionCloseEvent\":[\"Event\"],\"PresentationConnectionCloseEventInit\":[],\"PresentationConnectionClosedReason\":[],\"PresentationConnectionList\":[\"EventTarget\"],\"PresentationConnectionState\":[],\"PresentationReceiver\":[],\"PresentationRequest\":[\"EventTarget\"],\"PresentationStyle\":[],\"ProcessingInstruction\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"ProfileTimelineLayerRect\":[],\"ProfileTimelineMarker\":[],\"ProfileTimelineMessagePortOperationType\":[],\"ProfileTimelineStackFrame\":[],\"ProfileTimelineWorkerOperationType\":[],\"ProgressEvent\":[\"Event\"],\"ProgressEventInit\":[],\"PromiseNativeHandler\":[],\"PromiseRejectionEvent\":[\"Event\"],\"PromiseRejectionEventInit\":[],\"PublicKeyCredential\":[\"Credential\"],\"PublicKeyCredentialCreationOptions\":[],\"PublicKeyCredentialCreationOptionsJson\":[],\"PublicKeyCredentialDescriptor\":[],\"PublicKeyCredentialDescriptorJson\":[],\"PublicKeyCredentialEntity\":[],\"PublicKeyCredentialHints\":[],\"PublicKeyCredentialParameters\":[],\"PublicKeyCredentialRequestOptions\":[],\"PublicKeyCredentialRequestOptionsJson\":[],\"PublicKeyCredentialRpEntity\":[],\"PublicKeyCredentialType\":[],\"PublicKeyCredentialUserEntity\":[],\"PublicKeyCredentialUserEntityJson\":[],\"PushEncryptionKeyName\":[],\"PushEvent\":[\"Event\",\"ExtendableEvent\"],\"PushEventInit\":[],\"PushManager\":[],\"PushMessageData\":[],\"PushPermissionState\":[],\"PushSubscription\":[],\"PushSubscriptionInit\":[],\"PushSubscriptionJson\":[],\"PushSubscriptionKeys\":[],\"PushSubscriptionOptions\":[],\"PushSubscriptionOptionsInit\":[],\"QueryOptions\":[],\"QueuingStrategy\":[],\"QueuingStrategyInit\":[],\"RadioNodeList\":[\"NodeList\"],\"Range\":[],\"RcwnPerfStats\":[],\"RcwnStatus\":[],\"ReadableByteStreamController\":[],\"ReadableStream\":[],\"ReadableStreamByobReader\":[],\"ReadableStreamByobRequest\":[],\"ReadableStreamDefaultController\":[],\"ReadableStreamDefaultReader\":[],\"ReadableStreamGetReaderOptions\":[],\"ReadableStreamIteratorOptions\":[],\"ReadableStreamReadResult\":[],\"ReadableStreamReaderMode\":[],\"ReadableStreamType\":[],\"ReadableWritablePair\":[],\"RecordingState\":[],\"ReferrerPolicy\":[],\"RegisterRequest\":[],\"RegisterResponse\":[],\"RegisteredKey\":[],\"RegistrationOptions\":[],\"RegistrationResponseJson\":[],\"Request\":[],\"RequestCache\":[],\"RequestCredentials\":[],\"RequestDestination\":[],\"RequestDeviceOptions\":[],\"RequestInit\":[],\"RequestMediaKeySystemAccessNotification\":[],\"RequestMode\":[],\"RequestRedirect\":[],\"ResidentKeyRequirement\":[],\"ResizeObserver\":[],\"ResizeObserverBoxOptions\":[],\"ResizeObserverEntry\":[],\"ResizeObserverOptions\":[],\"ResizeObserverSize\":[],\"ResizeQuality\":[],\"Response\":[],\"ResponseInit\":[],\"ResponseType\":[],\"RsaHashedImportParams\":[],\"RsaOaepParams\":[],\"RsaOtherPrimesInfo\":[],\"RsaPssParams\":[],\"RtcAnswerOptions\":[],\"RtcBundlePolicy\":[],\"RtcCertificate\":[],\"RtcCertificateExpiration\":[],\"RtcCodecStats\":[],\"RtcConfiguration\":[],\"RtcDataChannel\":[\"EventTarget\"],\"RtcDataChannelEvent\":[\"Event\"],\"RtcDataChannelEventInit\":[],\"RtcDataChannelInit\":[],\"RtcDataChannelState\":[],\"RtcDataChannelType\":[],\"RtcDegradationPreference\":[],\"RtcEncodedAudioFrame\":[],\"RtcEncodedAudioFrameMetadata\":[],\"RtcEncodedAudioFrameOptions\":[],\"RtcEncodedVideoFrame\":[],\"RtcEncodedVideoFrameMetadata\":[],\"RtcEncodedVideoFrameOptions\":[],\"RtcEncodedVideoFrameType\":[],\"RtcFecParameters\":[],\"RtcIceCandidate\":[],\"RtcIceCandidateInit\":[],\"RtcIceCandidatePairStats\":[],\"RtcIceCandidateStats\":[],\"RtcIceComponentStats\":[],\"RtcIceConnectionState\":[],\"RtcIceCredentialType\":[],\"RtcIceGatheringState\":[],\"RtcIceServer\":[],\"RtcIceTransportPolicy\":[],\"RtcIdentityAssertion\":[],\"RtcIdentityAssertionResult\":[],\"RtcIdentityProvider\":[],\"RtcIdentityProviderDetails\":[],\"RtcIdentityProviderOptions\":[],\"RtcIdentityProviderRegistrar\":[],\"RtcIdentityValidationResult\":[],\"RtcInboundRtpStreamStats\":[],\"RtcMediaStreamStats\":[],\"RtcMediaStreamTrackStats\":[],\"RtcOfferAnswerOptions\":[],\"RtcOfferOptions\":[],\"RtcOutboundRtpStreamStats\":[],\"RtcPeerConnection\":[\"EventTarget\"],\"RtcPeerConnectionIceErrorEvent\":[\"Event\"],\"RtcPeerConnectionIceEvent\":[\"Event\"],\"RtcPeerConnectionIceEventInit\":[],\"RtcPeerConnectionState\":[],\"RtcPriorityType\":[],\"RtcRtcpParameters\":[],\"RtcRtpCapabilities\":[],\"RtcRtpCodecCapability\":[],\"RtcRtpCodecParameters\":[],\"RtcRtpContributingSource\":[],\"RtcRtpEncodingParameters\":[],\"RtcRtpHeaderExtensionCapability\":[],\"RtcRtpHeaderExtensionParameters\":[],\"RtcRtpParameters\":[],\"RtcRtpReceiver\":[],\"RtcRtpScriptTransform\":[],\"RtcRtpScriptTransformer\":[\"EventTarget\"],\"RtcRtpSender\":[],\"RtcRtpSourceEntry\":[],\"RtcRtpSourceEntryType\":[],\"RtcRtpSynchronizationSource\":[],\"RtcRtpTransceiver\":[],\"RtcRtpTransceiverDirection\":[],\"RtcRtpTransceiverInit\":[],\"RtcRtxParameters\":[],\"RtcSdpType\":[],\"RtcSessionDescription\":[],\"RtcSessionDescriptionInit\":[],\"RtcSignalingState\":[],\"RtcStats\":[],\"RtcStatsIceCandidatePairState\":[],\"RtcStatsIceCandidateType\":[],\"RtcStatsReport\":[],\"RtcStatsReportInternal\":[],\"RtcStatsType\":[],\"RtcTrackEvent\":[\"Event\"],\"RtcTrackEventInit\":[],\"RtcTransformEvent\":[\"Event\"],\"RtcTransportStats\":[],\"RtcdtmfSender\":[\"EventTarget\"],\"RtcdtmfToneChangeEvent\":[\"Event\"],\"RtcdtmfToneChangeEventInit\":[],\"RtcrtpContributingSourceStats\":[],\"RtcrtpStreamStats\":[],\"SFrameTransform\":[\"EventTarget\"],\"SFrameTransformErrorEvent\":[\"Event\"],\"SFrameTransformErrorEventInit\":[],\"SFrameTransformErrorEventType\":[],\"SFrameTransformOptions\":[],\"SFrameTransformRole\":[],\"SaveFilePickerOptions\":[],\"Scheduler\":[],\"SchedulerPostTaskOptions\":[],\"Scheduling\":[],\"Screen\":[\"EventTarget\"],\"ScreenColorGamut\":[],\"ScreenLuminance\":[],\"ScreenOrientation\":[\"EventTarget\"],\"ScriptProcessorNode\":[\"AudioNode\",\"EventTarget\"],\"ScrollAreaEvent\":[\"Event\",\"UiEvent\"],\"ScrollBehavior\":[],\"ScrollBoxObject\":[],\"ScrollIntoViewOptions\":[],\"ScrollLogicalPosition\":[],\"ScrollOptions\":[],\"ScrollRestoration\":[],\"ScrollSetting\":[],\"ScrollState\":[],\"ScrollToOptions\":[],\"ScrollViewChangeEventInit\":[],\"SecurityPolicyViolationEvent\":[\"Event\"],\"SecurityPolicyViolationEventDisposition\":[],\"SecurityPolicyViolationEventInit\":[],\"Selection\":[],\"SelectionMode\":[],\"Serial\":[\"EventTarget\"],\"SerialInputSignals\":[],\"SerialOptions\":[],\"SerialOutputSignals\":[],\"SerialPort\":[\"EventTarget\"],\"SerialPortFilter\":[],\"SerialPortInfo\":[],\"SerialPortRequestOptions\":[],\"ServerSocketOptions\":[],\"ServiceWorker\":[\"EventTarget\"],\"ServiceWorkerContainer\":[\"EventTarget\"],\"ServiceWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"ServiceWorkerRegistration\":[\"EventTarget\"],\"ServiceWorkerState\":[],\"ServiceWorkerUpdateViaCache\":[],\"ShadowRoot\":[\"DocumentFragment\",\"EventTarget\",\"Node\"],\"ShadowRootInit\":[],\"ShadowRootMode\":[],\"ShareData\":[],\"SharedWorker\":[\"EventTarget\"],\"SharedWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"SignResponse\":[],\"SocketElement\":[],\"SocketOptions\":[],\"SocketReadyState\":[],\"SocketsDict\":[],\"SourceBuffer\":[\"EventTarget\"],\"SourceBufferAppendMode\":[],\"SourceBufferList\":[\"EventTarget\"],\"SpeechGrammar\":[],\"SpeechGrammarList\":[],\"SpeechRecognition\":[\"EventTarget\"],\"SpeechRecognitionAlternative\":[],\"SpeechRecognitionError\":[\"Event\"],\"SpeechRecognitionErrorCode\":[],\"SpeechRecognitionErrorInit\":[],\"SpeechRecognitionEvent\":[\"Event\"],\"SpeechRecognitionEventInit\":[],\"SpeechRecognitionResult\":[],\"SpeechRecognitionResultList\":[],\"SpeechSynthesis\":[\"EventTarget\"],\"SpeechSynthesisErrorCode\":[],\"SpeechSynthesisErrorEvent\":[\"Event\",\"SpeechSynthesisEvent\"],\"SpeechSynthesisErrorEventInit\":[],\"SpeechSynthesisEvent\":[\"Event\"],\"SpeechSynthesisEventInit\":[],\"SpeechSynthesisUtterance\":[\"EventTarget\"],\"SpeechSynthesisVoice\":[],\"StereoPannerNode\":[\"AudioNode\",\"EventTarget\"],\"StereoPannerOptions\":[],\"Storage\":[],\"StorageEstimate\":[],\"StorageEvent\":[\"Event\"],\"StorageEventInit\":[],\"StorageManager\":[],\"StorageType\":[],\"StreamPipeOptions\":[],\"StyleRuleChangeEventInit\":[],\"StyleSheet\":[],\"StyleSheetApplicableStateChangeEventInit\":[],\"StyleSheetChangeEventInit\":[],\"StyleSheetList\":[],\"SubmitEvent\":[\"Event\"],\"SubmitEventInit\":[],\"SubtleCrypto\":[],\"SupportedType\":[],\"SvcOutputMetadata\":[],\"SvgAngle\":[],\"SvgAnimateElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimateMotionElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimateTransformElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimatedAngle\":[],\"SvgAnimatedBoolean\":[],\"SvgAnimatedEnumeration\":[],\"SvgAnimatedInteger\":[],\"SvgAnimatedLength\":[],\"SvgAnimatedLengthList\":[],\"SvgAnimatedNumber\":[],\"SvgAnimatedNumberList\":[],\"SvgAnimatedPreserveAspectRatio\":[],\"SvgAnimatedRect\":[],\"SvgAnimatedString\":[],\"SvgAnimatedTransformList\":[],\"SvgAnimationElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgBoundingBoxOptions\":[],\"SvgCircleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgClipPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgComponentTransferFunctionElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgDefsElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgDescElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgElement\":[\"Element\",\"EventTarget\",\"Node\"],\"SvgEllipseElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgFilterElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgForeignObjectElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgGeometryElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgGraphicsElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgImageElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgLength\":[],\"SvgLengthList\":[],\"SvgLineElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgLinearGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGradientElement\"],\"SvgMarkerElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgMaskElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgMatrix\":[],\"SvgMetadataElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgNumber\":[],\"SvgNumberList\":[],\"SvgPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPathSeg\":[],\"SvgPathSegArcAbs\":[\"SvgPathSeg\"],\"SvgPathSegArcRel\":[\"SvgPathSeg\"],\"SvgPathSegClosePath\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicSmoothAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicSmoothRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticSmoothAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticSmoothRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoHorizontalAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoHorizontalRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoVerticalAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoVerticalRel\":[\"SvgPathSeg\"],\"SvgPathSegList\":[],\"SvgPathSegMovetoAbs\":[\"SvgPathSeg\"],\"SvgPathSegMovetoRel\":[\"SvgPathSeg\"],\"SvgPatternElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgPoint\":[],\"SvgPointList\":[],\"SvgPolygonElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPolylineElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPreserveAspectRatio\":[],\"SvgRadialGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGradientElement\"],\"SvgRect\":[],\"SvgRectElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgScriptElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgSetElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgStopElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgStringList\":[],\"SvgStyleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgSwitchElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgSymbolElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgTextContentElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgTextElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\",\"SvgTextPositioningElement\"],\"SvgTextPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\"],\"SvgTextPositioningElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\"],\"SvgTitleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgTransform\":[],\"SvgTransformList\":[],\"SvgUnitTypes\":[],\"SvgUseElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgViewElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgZoomAndPan\":[],\"SvgaElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgfeBlendElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeColorMatrixElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeComponentTransferElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeCompositeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeConvolveMatrixElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDiffuseLightingElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDisplacementMapElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDistantLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDropShadowElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeFloodElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeFuncAElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncBElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncGElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncRElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeGaussianBlurElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeImageElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMergeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMergeNodeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMorphologyElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeOffsetElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfePointLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeSpecularLightingElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeSpotLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeTileElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeTurbulenceElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvggElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgmPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgsvgElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgtSpanElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\",\"SvgTextPositioningElement\"],\"TaskController\":[\"AbortController\"],\"TaskControllerInit\":[],\"TaskPriority\":[],\"TaskPriorityChangeEvent\":[\"Event\"],\"TaskPriorityChangeEventInit\":[],\"TaskSignal\":[\"AbortSignal\",\"EventTarget\"],\"TaskSignalAnyInit\":[],\"TcpReadyState\":[],\"TcpServerSocket\":[\"EventTarget\"],\"TcpServerSocketEvent\":[\"Event\"],\"TcpServerSocketEventInit\":[],\"TcpSocket\":[\"EventTarget\"],\"TcpSocketBinaryType\":[],\"TcpSocketErrorEvent\":[\"Event\"],\"TcpSocketErrorEventInit\":[],\"TcpSocketEvent\":[\"Event\"],\"TcpSocketEventInit\":[],\"Text\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"TextDecodeOptions\":[],\"TextDecoder\":[],\"TextDecoderOptions\":[],\"TextEncoder\":[],\"TextMetrics\":[],\"TextTrack\":[\"EventTarget\"],\"TextTrackCue\":[\"EventTarget\"],\"TextTrackCueList\":[],\"TextTrackKind\":[],\"TextTrackList\":[\"EventTarget\"],\"TextTrackMode\":[],\"TimeEvent\":[\"Event\"],\"TimeRanges\":[],\"ToggleEvent\":[\"Event\"],\"ToggleEventInit\":[],\"TokenBinding\":[],\"TokenBindingStatus\":[],\"Touch\":[],\"TouchEvent\":[\"Event\",\"UiEvent\"],\"TouchEventInit\":[],\"TouchInit\":[],\"TouchList\":[],\"TrackEvent\":[\"Event\"],\"TrackEventInit\":[],\"TransformStream\":[],\"TransformStreamDefaultController\":[],\"Transformer\":[],\"TransitionEvent\":[\"Event\"],\"TransitionEventInit\":[],\"Transport\":[],\"TreeBoxObject\":[],\"TreeCellInfo\":[],\"TreeView\":[],\"TreeWalker\":[],\"U2f\":[],\"U2fClientData\":[],\"ULongRange\":[],\"UaDataValues\":[],\"UaLowEntropyJson\":[],\"UdpMessageEventInit\":[],\"UdpOptions\":[],\"UiEvent\":[\"Event\"],\"UiEventInit\":[],\"UnderlyingSink\":[],\"UnderlyingSource\":[],\"Url\":[],\"UrlSearchParams\":[],\"Usb\":[\"EventTarget\"],\"UsbAlternateInterface\":[],\"UsbConfiguration\":[],\"UsbConnectionEvent\":[\"Event\"],\"UsbConnectionEventInit\":[],\"UsbControlTransferParameters\":[],\"UsbDevice\":[],\"UsbDeviceFilter\":[],\"UsbDeviceRequestOptions\":[],\"UsbDirection\":[],\"UsbEndpoint\":[],\"UsbEndpointType\":[],\"UsbInTransferResult\":[],\"UsbInterface\":[],\"UsbIsochronousInTransferPacket\":[],\"UsbIsochronousInTransferResult\":[],\"UsbIsochronousOutTransferPacket\":[],\"UsbIsochronousOutTransferResult\":[],\"UsbOutTransferResult\":[],\"UsbPermissionDescriptor\":[],\"UsbPermissionResult\":[\"EventTarget\",\"PermissionStatus\"],\"UsbPermissionStorage\":[],\"UsbRecipient\":[],\"UsbRequestType\":[],\"UsbTransferStatus\":[],\"UserActivation\":[],\"UserProximityEvent\":[\"Event\"],\"UserProximityEventInit\":[],\"UserVerificationRequirement\":[],\"ValidityState\":[],\"ValueEvent\":[\"Event\"],\"ValueEventInit\":[],\"VideoColorPrimaries\":[],\"VideoColorSpace\":[],\"VideoColorSpaceInit\":[],\"VideoConfiguration\":[],\"VideoDecoder\":[],\"VideoDecoderConfig\":[],\"VideoDecoderInit\":[],\"VideoDecoderSupport\":[],\"VideoEncoder\":[],\"VideoEncoderConfig\":[],\"VideoEncoderEncodeOptions\":[],\"VideoEncoderInit\":[],\"VideoEncoderSupport\":[],\"VideoFacingModeEnum\":[],\"VideoFrame\":[],\"VideoFrameBufferInit\":[],\"VideoFrameCopyToOptions\":[],\"VideoFrameInit\":[],\"VideoMatrixCoefficients\":[],\"VideoPixelFormat\":[],\"VideoPlaybackQuality\":[],\"VideoStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"VideoTrack\":[],\"VideoTrackList\":[\"EventTarget\"],\"VideoTransferCharacteristics\":[],\"ViewTransition\":[],\"VisibilityState\":[],\"VisualViewport\":[\"EventTarget\"],\"VoidCallback\":[],\"VrDisplay\":[\"EventTarget\"],\"VrDisplayCapabilities\":[],\"VrEye\":[],\"VrEyeParameters\":[],\"VrFieldOfView\":[],\"VrFrameData\":[],\"VrLayer\":[],\"VrMockController\":[],\"VrMockDisplay\":[],\"VrPose\":[],\"VrServiceTest\":[],\"VrStageParameters\":[],\"VrSubmitFrameResult\":[],\"VttCue\":[\"EventTarget\",\"TextTrackCue\"],\"VttRegion\":[],\"WakeLock\":[],\"WakeLockSentinel\":[\"EventTarget\"],\"WakeLockType\":[],\"WatchAdvertisementsOptions\":[],\"WaveShaperNode\":[\"AudioNode\",\"EventTarget\"],\"WaveShaperOptions\":[],\"WebGl2RenderingContext\":[],\"WebGlActiveInfo\":[],\"WebGlBuffer\":[],\"WebGlContextAttributes\":[],\"WebGlContextEvent\":[\"Event\"],\"WebGlContextEventInit\":[],\"WebGlFramebuffer\":[],\"WebGlPowerPreference\":[],\"WebGlProgram\":[],\"WebGlQuery\":[],\"WebGlRenderbuffer\":[],\"WebGlRenderingContext\":[],\"WebGlSampler\":[],\"WebGlShader\":[],\"WebGlShaderPrecisionFormat\":[],\"WebGlSync\":[],\"WebGlTexture\":[],\"WebGlTransformFeedback\":[],\"WebGlUniformLocation\":[],\"WebGlVertexArrayObject\":[],\"WebKitCssMatrix\":[\"DomMatrix\",\"DomMatrixReadOnly\"],\"WebSocket\":[\"EventTarget\"],\"WebSocketDict\":[],\"WebSocketElement\":[],\"WebTransport\":[],\"WebTransportBidirectionalStream\":[],\"WebTransportCloseInfo\":[],\"WebTransportCongestionControl\":[],\"WebTransportDatagramDuplexStream\":[],\"WebTransportDatagramStats\":[],\"WebTransportError\":[\"DomException\"],\"WebTransportErrorOptions\":[],\"WebTransportErrorSource\":[],\"WebTransportHash\":[],\"WebTransportOptions\":[],\"WebTransportReceiveStream\":[\"ReadableStream\"],\"WebTransportReceiveStreamStats\":[],\"WebTransportReliabilityMode\":[],\"WebTransportSendStream\":[\"WritableStream\"],\"WebTransportSendStreamOptions\":[],\"WebTransportSendStreamStats\":[],\"WebTransportStats\":[],\"WebglColorBufferFloat\":[],\"WebglCompressedTextureAstc\":[],\"WebglCompressedTextureAtc\":[],\"WebglCompressedTextureEtc\":[],\"WebglCompressedTextureEtc1\":[],\"WebglCompressedTexturePvrtc\":[],\"WebglCompressedTextureS3tc\":[],\"WebglCompressedTextureS3tcSrgb\":[],\"WebglDebugRendererInfo\":[],\"WebglDebugShaders\":[],\"WebglDepthTexture\":[],\"WebglDrawBuffers\":[],\"WebglLoseContext\":[],\"WebglMultiDraw\":[],\"WellKnownDirectory\":[],\"WgslLanguageFeatures\":[],\"WheelEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"WheelEventInit\":[],\"WidevineCdmManifest\":[],\"Window\":[\"EventTarget\"],\"WindowClient\":[\"Client\"],\"Worker\":[\"EventTarget\"],\"WorkerDebuggerGlobalScope\":[\"EventTarget\"],\"WorkerGlobalScope\":[\"EventTarget\"],\"WorkerLocation\":[],\"WorkerNavigator\":[],\"WorkerOptions\":[],\"WorkerType\":[],\"Worklet\":[],\"WorkletGlobalScope\":[],\"WorkletOptions\":[],\"WritableStream\":[],\"WritableStreamDefaultController\":[],\"WritableStreamDefaultWriter\":[],\"WriteCommandType\":[],\"WriteParams\":[],\"XPathExpression\":[],\"XPathNsResolver\":[],\"XPathResult\":[],\"XmlDocument\":[\"Document\",\"EventTarget\",\"Node\"],\"XmlHttpRequest\":[\"EventTarget\",\"XmlHttpRequestEventTarget\"],\"XmlHttpRequestEventTarget\":[\"EventTarget\"],\"XmlHttpRequestResponseType\":[],\"XmlHttpRequestUpload\":[\"EventTarget\",\"XmlHttpRequestEventTarget\"],\"XmlSerializer\":[],\"XrBoundedReferenceSpace\":[\"EventTarget\",\"XrReferenceSpace\",\"XrSpace\"],\"XrEye\":[],\"XrFrame\":[],\"XrHand\":[],\"XrHandJoint\":[],\"XrHandedness\":[],\"XrInputSource\":[],\"XrInputSourceArray\":[],\"XrInputSourceEvent\":[\"Event\"],\"XrInputSourceEventInit\":[],\"XrInputSourcesChangeEvent\":[\"Event\"],\"XrInputSourcesChangeEventInit\":[],\"XrJointPose\":[\"XrPose\"],\"XrJointSpace\":[\"EventTarget\",\"XrSpace\"],\"XrLayer\":[\"EventTarget\"],\"XrPermissionDescriptor\":[],\"XrPermissionStatus\":[\"EventTarget\",\"PermissionStatus\"],\"XrPose\":[],\"XrReferenceSpace\":[\"EventTarget\",\"XrSpace\"],\"XrReferenceSpaceEvent\":[\"Event\"],\"XrReferenceSpaceEventInit\":[],\"XrReferenceSpaceType\":[],\"XrRenderState\":[],\"XrRenderStateInit\":[],\"XrRigidTransform\":[],\"XrSession\":[\"EventTarget\"],\"XrSessionEvent\":[\"Event\"],\"XrSessionEventInit\":[],\"XrSessionInit\":[],\"XrSessionMode\":[],\"XrSessionSupportedPermissionDescriptor\":[],\"XrSpace\":[\"EventTarget\"],\"XrSystem\":[\"EventTarget\"],\"XrTargetRayMode\":[],\"XrView\":[],\"XrViewerPose\":[\"XrPose\"],\"XrViewport\":[],\"XrVisibilityState\":[],\"XrWebGlLayer\":[\"EventTarget\",\"XrLayer\"],\"XrWebGlLayerInit\":[],\"XsltProcessor\":[],\"console\":[],\"css\":[],\"default\":[\"std\"],\"gpu_buffer_usage\":[],\"gpu_color_write\":[],\"gpu_map_mode\":[],\"gpu_shader_stage\":[],\"gpu_texture_usage\":[],\"std\":[\"wasm-bindgen/std\",\"js-sys/std\"]}}", - "web-sys_0.3.95": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"js-sys\",\"req\":\"=0.3.95\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.118\"}],\"features\":{\"AbortController\":[],\"AbortSignal\":[\"EventTarget\"],\"AbstractRange\":[],\"AddEventListenerOptions\":[],\"AesCbcParams\":[],\"AesCtrParams\":[],\"AesDerivedKeyParams\":[],\"AesGcmParams\":[],\"AesKeyAlgorithm\":[],\"AesKeyGenParams\":[],\"Algorithm\":[],\"AlignSetting\":[],\"AllowedBluetoothDevice\":[],\"AllowedUsbDevice\":[],\"AlphaOption\":[],\"AnalyserNode\":[\"AudioNode\",\"EventTarget\"],\"AnalyserOptions\":[],\"AngleInstancedArrays\":[],\"Animation\":[\"EventTarget\"],\"AnimationEffect\":[],\"AnimationEvent\":[\"Event\"],\"AnimationEventInit\":[],\"AnimationPlayState\":[],\"AnimationPlaybackEvent\":[\"Event\"],\"AnimationPlaybackEventInit\":[],\"AnimationPropertyDetails\":[],\"AnimationPropertyValueDetails\":[],\"AnimationTimeline\":[],\"AssignedNodesOptions\":[],\"AttestationConveyancePreference\":[],\"Attr\":[\"EventTarget\",\"Node\"],\"AttributeNameValue\":[],\"AudioBuffer\":[],\"AudioBufferOptions\":[],\"AudioBufferSourceNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"AudioBufferSourceOptions\":[],\"AudioConfiguration\":[],\"AudioContext\":[\"BaseAudioContext\",\"EventTarget\"],\"AudioContextLatencyCategory\":[],\"AudioContextOptions\":[],\"AudioContextState\":[],\"AudioData\":[],\"AudioDataCopyToOptions\":[],\"AudioDataInit\":[],\"AudioDecoder\":[\"EventTarget\"],\"AudioDecoderConfig\":[],\"AudioDecoderInit\":[],\"AudioDecoderSupport\":[],\"AudioDestinationNode\":[\"AudioNode\",\"EventTarget\"],\"AudioEncoder\":[\"EventTarget\"],\"AudioEncoderConfig\":[],\"AudioEncoderInit\":[],\"AudioEncoderSupport\":[],\"AudioListener\":[],\"AudioNode\":[\"EventTarget\"],\"AudioNodeOptions\":[],\"AudioParam\":[],\"AudioParamMap\":[],\"AudioProcessingEvent\":[\"Event\"],\"AudioSampleFormat\":[],\"AudioScheduledSourceNode\":[\"AudioNode\",\"EventTarget\"],\"AudioSinkInfo\":[],\"AudioSinkOptions\":[],\"AudioSinkType\":[],\"AudioStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"AudioTrack\":[],\"AudioTrackList\":[\"EventTarget\"],\"AudioWorklet\":[\"Worklet\"],\"AudioWorkletGlobalScope\":[\"WorkletGlobalScope\"],\"AudioWorkletNode\":[\"AudioNode\",\"EventTarget\"],\"AudioWorkletNodeOptions\":[],\"AudioWorkletProcessor\":[],\"AuthenticationExtensionsClientInputs\":[],\"AuthenticationExtensionsClientInputsJson\":[],\"AuthenticationExtensionsClientOutputs\":[],\"AuthenticationExtensionsClientOutputsJson\":[],\"AuthenticationExtensionsDevicePublicKeyInputs\":[],\"AuthenticationExtensionsDevicePublicKeyOutputs\":[],\"AuthenticationExtensionsLargeBlobInputs\":[],\"AuthenticationExtensionsLargeBlobOutputs\":[],\"AuthenticationExtensionsPrfInputs\":[],\"AuthenticationExtensionsPrfOutputs\":[],\"AuthenticationExtensionsPrfValues\":[],\"AuthenticationResponseJson\":[],\"AuthenticatorAssertionResponse\":[\"AuthenticatorResponse\"],\"AuthenticatorAssertionResponseJson\":[],\"AuthenticatorAttachment\":[],\"AuthenticatorAttestationResponse\":[\"AuthenticatorResponse\"],\"AuthenticatorAttestationResponseJson\":[],\"AuthenticatorResponse\":[],\"AuthenticatorSelectionCriteria\":[],\"AuthenticatorTransport\":[],\"AutoKeyword\":[],\"AutocompleteInfo\":[],\"BarProp\":[],\"BaseAudioContext\":[\"EventTarget\"],\"BaseComputedKeyframe\":[],\"BaseKeyframe\":[],\"BasePropertyIndexedKeyframe\":[],\"BasicCardRequest\":[],\"BasicCardResponse\":[],\"BasicCardType\":[],\"BatteryManager\":[\"EventTarget\"],\"BeforeUnloadEvent\":[\"Event\"],\"BinaryType\":[],\"BiquadFilterNode\":[\"AudioNode\",\"EventTarget\"],\"BiquadFilterOptions\":[],\"BiquadFilterType\":[],\"BitrateMode\":[],\"Blob\":[],\"BlobEvent\":[\"Event\"],\"BlobEventInit\":[],\"BlobPropertyBag\":[],\"BlockParsingOptions\":[],\"Bluetooth\":[\"EventTarget\"],\"BluetoothAdvertisingEvent\":[\"Event\"],\"BluetoothAdvertisingEventInit\":[],\"BluetoothCharacteristicProperties\":[],\"BluetoothDataFilterInit\":[],\"BluetoothDevice\":[\"EventTarget\"],\"BluetoothLeScanFilterInit\":[],\"BluetoothManufacturerDataMap\":[],\"BluetoothPermissionDescriptor\":[],\"BluetoothPermissionResult\":[\"EventTarget\",\"PermissionStatus\"],\"BluetoothPermissionStorage\":[],\"BluetoothRemoteGattCharacteristic\":[\"EventTarget\"],\"BluetoothRemoteGattDescriptor\":[],\"BluetoothRemoteGattServer\":[],\"BluetoothRemoteGattService\":[\"EventTarget\"],\"BluetoothServiceDataMap\":[],\"BluetoothUuid\":[],\"BoxQuadOptions\":[],\"BroadcastChannel\":[\"EventTarget\"],\"BrowserElementDownloadOptions\":[],\"BrowserElementExecuteScriptOptions\":[],\"BrowserFeedWriter\":[],\"BrowserFindCaseSensitivity\":[],\"BrowserFindDirection\":[],\"ByteLengthQueuingStrategy\":[],\"Cache\":[],\"CacheBatchOperation\":[],\"CacheQueryOptions\":[],\"CacheStorage\":[],\"CacheStorageNamespace\":[],\"CanvasCaptureMediaStream\":[\"EventTarget\",\"MediaStream\"],\"CanvasCaptureMediaStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"CanvasGradient\":[],\"CanvasPattern\":[],\"CanvasRenderingContext2d\":[],\"CanvasWindingRule\":[],\"CaretChangedReason\":[],\"CaretPosition\":[],\"CaretStateChangedEventInit\":[],\"CdataSection\":[\"CharacterData\",\"EventTarget\",\"Node\",\"Text\"],\"ChannelCountMode\":[],\"ChannelInterpretation\":[],\"ChannelMergerNode\":[\"AudioNode\",\"EventTarget\"],\"ChannelMergerOptions\":[],\"ChannelSplitterNode\":[\"AudioNode\",\"EventTarget\"],\"ChannelSplitterOptions\":[],\"CharacterData\":[\"EventTarget\",\"Node\"],\"CheckerboardReason\":[],\"CheckerboardReport\":[],\"CheckerboardReportService\":[],\"ChromeFilePropertyBag\":[],\"ChromeWorker\":[\"EventTarget\",\"Worker\"],\"Client\":[],\"ClientQueryOptions\":[],\"ClientRectsAndTexts\":[],\"ClientType\":[],\"Clients\":[],\"Clipboard\":[\"EventTarget\"],\"ClipboardEvent\":[\"Event\"],\"ClipboardEventInit\":[],\"ClipboardItem\":[],\"ClipboardItemOptions\":[],\"ClipboardPermissionDescriptor\":[],\"ClipboardUnsanitizedFormats\":[],\"CloseEvent\":[\"Event\"],\"CloseEventInit\":[],\"CodecState\":[],\"CollectedClientData\":[],\"ColorSpaceConversion\":[],\"CommandEvent\":[\"Event\"],\"CommandEventInit\":[],\"Comment\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"CompositeOperation\":[],\"CompositionEvent\":[\"Event\",\"UiEvent\"],\"CompositionEventInit\":[],\"CompressionFormat\":[],\"CompressionStream\":[],\"ComputedEffectTiming\":[],\"ConnStatusDict\":[],\"ConnectionType\":[],\"ConsoleCounter\":[],\"ConsoleCounterError\":[],\"ConsoleEvent\":[],\"ConsoleInstance\":[],\"ConsoleInstanceOptions\":[],\"ConsoleLevel\":[],\"ConsoleLogLevel\":[],\"ConsoleProfileEvent\":[],\"ConsoleStackEntry\":[],\"ConsoleTimerError\":[],\"ConsoleTimerLogOrEnd\":[],\"ConsoleTimerStart\":[],\"ConstantSourceNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"ConstantSourceOptions\":[],\"ConstrainBooleanParameters\":[],\"ConstrainDomStringParameters\":[],\"ConstrainDoubleRange\":[],\"ConstrainLongRange\":[],\"ContextAttributes2d\":[],\"ConvertCoordinateOptions\":[],\"ConvolverNode\":[\"AudioNode\",\"EventTarget\"],\"ConvolverOptions\":[],\"CookieChangeEvent\":[\"Event\"],\"CookieChangeEventInit\":[],\"CookieInit\":[],\"CookieListItem\":[],\"CookieSameSite\":[],\"CookieStore\":[\"EventTarget\"],\"CookieStoreDeleteOptions\":[],\"CookieStoreGetOptions\":[],\"CookieStoreManager\":[],\"Coordinates\":[],\"CountQueuingStrategy\":[],\"Credential\":[],\"CredentialCreationOptions\":[],\"CredentialPropertiesOutput\":[],\"CredentialRequestOptions\":[],\"CredentialsContainer\":[],\"Crypto\":[],\"CryptoKey\":[],\"CryptoKeyPair\":[],\"CssAnimation\":[\"Animation\",\"EventTarget\"],\"CssBoxType\":[],\"CssConditionRule\":[\"CssGroupingRule\",\"CssRule\"],\"CssCounterStyleRule\":[\"CssRule\"],\"CssFontFaceRule\":[\"CssRule\"],\"CssFontFeatureValuesRule\":[\"CssRule\"],\"CssGroupingRule\":[\"CssRule\"],\"CssImportRule\":[\"CssRule\"],\"CssKeyframeRule\":[\"CssRule\"],\"CssKeyframesRule\":[\"CssRule\"],\"CssMediaRule\":[\"CssConditionRule\",\"CssGroupingRule\",\"CssRule\"],\"CssNamespaceRule\":[\"CssRule\"],\"CssPageRule\":[\"CssRule\"],\"CssPseudoElement\":[],\"CssRule\":[],\"CssRuleList\":[],\"CssStyleDeclaration\":[],\"CssStyleRule\":[\"CssRule\"],\"CssStyleSheet\":[\"StyleSheet\"],\"CssStyleSheetParsingMode\":[],\"CssSupportsRule\":[\"CssConditionRule\",\"CssGroupingRule\",\"CssRule\"],\"CssTransition\":[\"Animation\",\"EventTarget\"],\"CustomElementRegistry\":[],\"CustomEvent\":[\"Event\"],\"CustomEventInit\":[],\"DataTransfer\":[],\"DataTransferItem\":[],\"DataTransferItemList\":[],\"DateTimeValue\":[],\"DecoderDoctorNotification\":[],\"DecoderDoctorNotificationType\":[],\"DecompressionStream\":[],\"DedicatedWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"DelayNode\":[\"AudioNode\",\"EventTarget\"],\"DelayOptions\":[],\"DeviceAcceleration\":[],\"DeviceAccelerationInit\":[],\"DeviceLightEvent\":[\"Event\"],\"DeviceLightEventInit\":[],\"DeviceMotionEvent\":[\"Event\"],\"DeviceMotionEventInit\":[],\"DeviceOrientationEvent\":[\"Event\"],\"DeviceOrientationEventInit\":[],\"DeviceProximityEvent\":[\"Event\"],\"DeviceProximityEventInit\":[],\"DeviceRotationRate\":[],\"DeviceRotationRateInit\":[],\"DhKeyDeriveParams\":[],\"DirectionSetting\":[],\"Directory\":[],\"DirectoryPickerOptions\":[],\"DisplayMediaStreamConstraints\":[],\"DisplayNameOptions\":[],\"DisplayNameResult\":[],\"DistanceModelType\":[],\"DnsCacheDict\":[],\"DnsCacheEntry\":[],\"DnsLookupDict\":[],\"Document\":[\"EventTarget\",\"Node\"],\"DocumentFragment\":[\"EventTarget\",\"Node\"],\"DocumentTimeline\":[\"AnimationTimeline\"],\"DocumentTimelineOptions\":[],\"DocumentType\":[\"EventTarget\",\"Node\"],\"DomError\":[],\"DomException\":[],\"DomImplementation\":[],\"DomMatrix\":[\"DomMatrixReadOnly\"],\"DomMatrix2dInit\":[],\"DomMatrixInit\":[],\"DomMatrixReadOnly\":[],\"DomParser\":[],\"DomPoint\":[\"DomPointReadOnly\"],\"DomPointInit\":[],\"DomPointReadOnly\":[],\"DomQuad\":[],\"DomQuadInit\":[],\"DomQuadJson\":[],\"DomRect\":[\"DomRectReadOnly\"],\"DomRectInit\":[],\"DomRectList\":[],\"DomRectReadOnly\":[],\"DomRequest\":[\"EventTarget\"],\"DomRequestReadyState\":[],\"DomStringList\":[],\"DomStringMap\":[],\"DomTokenList\":[],\"DomWindowResizeEventDetail\":[],\"DoubleRange\":[],\"DragEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"DragEventInit\":[],\"DynamicsCompressorNode\":[\"AudioNode\",\"EventTarget\"],\"DynamicsCompressorOptions\":[],\"EcKeyAlgorithm\":[],\"EcKeyGenParams\":[],\"EcKeyImportParams\":[],\"EcdhKeyDeriveParams\":[],\"EcdsaParams\":[],\"EffectTiming\":[],\"Element\":[\"EventTarget\",\"Node\"],\"ElementCreationOptions\":[],\"ElementDefinitionOptions\":[],\"EncodedAudioChunk\":[],\"EncodedAudioChunkInit\":[],\"EncodedAudioChunkMetadata\":[],\"EncodedAudioChunkType\":[],\"EncodedVideoChunk\":[],\"EncodedVideoChunkInit\":[],\"EncodedVideoChunkMetadata\":[],\"EncodedVideoChunkType\":[],\"EndingTypes\":[],\"ErrorCallback\":[],\"ErrorEvent\":[\"Event\"],\"ErrorEventInit\":[],\"Event\":[],\"EventInit\":[],\"EventListener\":[],\"EventListenerOptions\":[],\"EventModifierInit\":[],\"EventSource\":[\"EventTarget\"],\"EventSourceInit\":[],\"EventTarget\":[],\"Exception\":[],\"ExtBlendMinmax\":[],\"ExtColorBufferFloat\":[],\"ExtColorBufferHalfFloat\":[],\"ExtDisjointTimerQuery\":[],\"ExtFragDepth\":[],\"ExtSRgb\":[],\"ExtShaderTextureLod\":[],\"ExtTextureFilterAnisotropic\":[],\"ExtTextureNorm16\":[],\"ExtendableCookieChangeEvent\":[\"Event\",\"ExtendableEvent\"],\"ExtendableCookieChangeEventInit\":[],\"ExtendableEvent\":[\"Event\"],\"ExtendableEventInit\":[],\"ExtendableMessageEvent\":[\"Event\",\"ExtendableEvent\"],\"ExtendableMessageEventInit\":[],\"External\":[],\"FakePluginMimeEntry\":[],\"FakePluginTagInit\":[],\"FetchEvent\":[\"Event\",\"ExtendableEvent\"],\"FetchEventInit\":[],\"FetchObserver\":[\"EventTarget\"],\"FetchReadableStreamReadDataArray\":[],\"FetchReadableStreamReadDataDone\":[],\"FetchState\":[],\"File\":[\"Blob\"],\"FileCallback\":[],\"FileList\":[],\"FilePickerAcceptType\":[],\"FilePickerOptions\":[],\"FilePropertyBag\":[],\"FileReader\":[\"EventTarget\"],\"FileReaderSync\":[],\"FileSystem\":[],\"FileSystemCreateWritableOptions\":[],\"FileSystemDirectoryEntry\":[\"FileSystemEntry\"],\"FileSystemDirectoryHandle\":[\"FileSystemHandle\"],\"FileSystemDirectoryReader\":[],\"FileSystemEntriesCallback\":[],\"FileSystemEntry\":[],\"FileSystemEntryCallback\":[],\"FileSystemFileEntry\":[\"FileSystemEntry\"],\"FileSystemFileHandle\":[\"FileSystemHandle\"],\"FileSystemFlags\":[],\"FileSystemGetDirectoryOptions\":[],\"FileSystemGetFileOptions\":[],\"FileSystemHandle\":[],\"FileSystemHandleKind\":[],\"FileSystemHandlePermissionDescriptor\":[],\"FileSystemPermissionDescriptor\":[],\"FileSystemPermissionMode\":[],\"FileSystemReadWriteOptions\":[],\"FileSystemRemoveOptions\":[],\"FileSystemSyncAccessHandle\":[],\"FileSystemSyncAccessHandleMode\":[],\"FileSystemSyncAccessHandleOptions\":[],\"FileSystemWritableFileStream\":[\"WritableStream\"],\"FillLightMode\":[],\"FillMode\":[],\"FlashClassification\":[],\"FlowControlType\":[],\"FocusEvent\":[\"Event\",\"UiEvent\"],\"FocusEventInit\":[],\"FocusOptions\":[],\"FontData\":[],\"FontFace\":[],\"FontFaceDescriptors\":[],\"FontFaceLoadStatus\":[],\"FontFaceSet\":[\"EventTarget\"],\"FontFaceSetIterator\":[],\"FontFaceSetIteratorResult\":[],\"FontFaceSetLoadEvent\":[\"Event\"],\"FontFaceSetLoadEventInit\":[],\"FontFaceSetLoadStatus\":[],\"FormData\":[],\"FrameType\":[],\"FuzzingFunctions\":[],\"GainNode\":[\"AudioNode\",\"EventTarget\"],\"GainOptions\":[],\"Gamepad\":[],\"GamepadButton\":[],\"GamepadEffectParameters\":[],\"GamepadEvent\":[\"Event\"],\"GamepadEventInit\":[],\"GamepadHand\":[],\"GamepadHapticActuator\":[],\"GamepadHapticActuatorType\":[],\"GamepadHapticEffectType\":[],\"GamepadHapticsResult\":[],\"GamepadMappingType\":[],\"GamepadPose\":[],\"GamepadTouch\":[],\"Geolocation\":[],\"GeolocationCoordinates\":[],\"GeolocationPosition\":[],\"GeolocationPositionError\":[],\"GestureEvent\":[\"Event\",\"UiEvent\"],\"GetAnimationsOptions\":[],\"GetRootNodeOptions\":[],\"GetUserMediaRequest\":[],\"Gpu\":[],\"GpuAdapter\":[],\"GpuAdapterInfo\":[],\"GpuAddressMode\":[],\"GpuAutoLayoutMode\":[],\"GpuBindGroup\":[],\"GpuBindGroupDescriptor\":[],\"GpuBindGroupEntry\":[],\"GpuBindGroupLayout\":[],\"GpuBindGroupLayoutDescriptor\":[],\"GpuBindGroupLayoutEntry\":[],\"GpuBlendComponent\":[],\"GpuBlendFactor\":[],\"GpuBlendOperation\":[],\"GpuBlendState\":[],\"GpuBuffer\":[],\"GpuBufferBinding\":[],\"GpuBufferBindingLayout\":[],\"GpuBufferBindingType\":[],\"GpuBufferDescriptor\":[],\"GpuBufferMapState\":[],\"GpuCanvasAlphaMode\":[],\"GpuCanvasConfiguration\":[],\"GpuCanvasContext\":[],\"GpuCanvasToneMapping\":[],\"GpuCanvasToneMappingMode\":[],\"GpuColorDict\":[],\"GpuColorTargetState\":[],\"GpuCommandBuffer\":[],\"GpuCommandBufferDescriptor\":[],\"GpuCommandEncoder\":[],\"GpuCommandEncoderDescriptor\":[],\"GpuCompareFunction\":[],\"GpuCompilationInfo\":[],\"GpuCompilationMessage\":[],\"GpuCompilationMessageType\":[],\"GpuComputePassDescriptor\":[],\"GpuComputePassEncoder\":[],\"GpuComputePassTimestampWrites\":[],\"GpuComputePipeline\":[],\"GpuComputePipelineDescriptor\":[],\"GpuCopyExternalImageDestInfo\":[],\"GpuCopyExternalImageSourceInfo\":[],\"GpuCullMode\":[],\"GpuDepthStencilState\":[],\"GpuDevice\":[\"EventTarget\"],\"GpuDeviceDescriptor\":[],\"GpuDeviceLostInfo\":[],\"GpuDeviceLostReason\":[],\"GpuError\":[],\"GpuErrorFilter\":[],\"GpuExtent3dDict\":[],\"GpuExternalTexture\":[],\"GpuExternalTextureBindingLayout\":[],\"GpuExternalTextureDescriptor\":[],\"GpuFeatureName\":[],\"GpuFilterMode\":[],\"GpuFragmentState\":[],\"GpuFrontFace\":[],\"GpuIndexFormat\":[],\"GpuInternalError\":[\"GpuError\"],\"GpuLoadOp\":[],\"GpuMipmapFilterMode\":[],\"GpuMultisampleState\":[],\"GpuObjectDescriptorBase\":[],\"GpuOrigin2dDict\":[],\"GpuOrigin3dDict\":[],\"GpuOutOfMemoryError\":[\"GpuError\"],\"GpuPipelineDescriptorBase\":[],\"GpuPipelineError\":[\"DomException\"],\"GpuPipelineErrorInit\":[],\"GpuPipelineErrorReason\":[],\"GpuPipelineLayout\":[],\"GpuPipelineLayoutDescriptor\":[],\"GpuPowerPreference\":[],\"GpuPrimitiveState\":[],\"GpuPrimitiveTopology\":[],\"GpuProgrammableStage\":[],\"GpuQuerySet\":[],\"GpuQuerySetDescriptor\":[],\"GpuQueryType\":[],\"GpuQueue\":[],\"GpuQueueDescriptor\":[],\"GpuRenderBundle\":[],\"GpuRenderBundleDescriptor\":[],\"GpuRenderBundleEncoder\":[],\"GpuRenderBundleEncoderDescriptor\":[],\"GpuRenderPassColorAttachment\":[],\"GpuRenderPassDepthStencilAttachment\":[],\"GpuRenderPassDescriptor\":[],\"GpuRenderPassEncoder\":[],\"GpuRenderPassLayout\":[],\"GpuRenderPassTimestampWrites\":[],\"GpuRenderPipeline\":[],\"GpuRenderPipelineDescriptor\":[],\"GpuRequestAdapterOptions\":[],\"GpuSampler\":[],\"GpuSamplerBindingLayout\":[],\"GpuSamplerBindingType\":[],\"GpuSamplerDescriptor\":[],\"GpuShaderModule\":[],\"GpuShaderModuleCompilationHint\":[],\"GpuShaderModuleDescriptor\":[],\"GpuStencilFaceState\":[],\"GpuStencilOperation\":[],\"GpuStorageTextureAccess\":[],\"GpuStorageTextureBindingLayout\":[],\"GpuStoreOp\":[],\"GpuSupportedFeatures\":[],\"GpuSupportedLimits\":[],\"GpuTexelCopyBufferInfo\":[],\"GpuTexelCopyBufferLayout\":[],\"GpuTexelCopyTextureInfo\":[],\"GpuTexture\":[],\"GpuTextureAspect\":[],\"GpuTextureBindingLayout\":[],\"GpuTextureDescriptor\":[],\"GpuTextureDimension\":[],\"GpuTextureFormat\":[],\"GpuTextureSampleType\":[],\"GpuTextureView\":[],\"GpuTextureViewDescriptor\":[],\"GpuTextureViewDimension\":[],\"GpuUncapturedErrorEvent\":[\"Event\"],\"GpuUncapturedErrorEventInit\":[],\"GpuValidationError\":[\"GpuError\"],\"GpuVertexAttribute\":[],\"GpuVertexBufferLayout\":[],\"GpuVertexFormat\":[],\"GpuVertexState\":[],\"GpuVertexStepMode\":[],\"GroupedHistoryEventInit\":[],\"HalfOpenInfoDict\":[],\"HardwareAcceleration\":[],\"HashChangeEvent\":[\"Event\"],\"HashChangeEventInit\":[],\"Headers\":[],\"HeadersGuardEnum\":[],\"Hid\":[\"EventTarget\"],\"HidCollectionInfo\":[],\"HidConnectionEvent\":[\"Event\"],\"HidConnectionEventInit\":[],\"HidDevice\":[\"EventTarget\"],\"HidDeviceFilter\":[],\"HidDeviceRequestOptions\":[],\"HidInputReportEvent\":[\"Event\"],\"HidInputReportEventInit\":[],\"HidReportInfo\":[],\"HidReportItem\":[],\"HidUnitSystem\":[],\"HiddenPluginEventInit\":[],\"Highlight\":[],\"HighlightHitResult\":[],\"HighlightRegistry\":[],\"HighlightType\":[],\"HighlightsFromPointOptions\":[],\"History\":[],\"HitRegionOptions\":[],\"HkdfParams\":[],\"HmacDerivedKeyParams\":[],\"HmacImportParams\":[],\"HmacKeyAlgorithm\":[],\"HmacKeyGenParams\":[],\"HtmlAllCollection\":[],\"HtmlAnchorElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlAreaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlAudioElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"HtmlMediaElement\",\"Node\"],\"HtmlBaseElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlBodyElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlBrElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlButtonElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlCanvasElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlCollection\":[],\"HtmlDListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDataElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDataListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDetailsElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDialogElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDirectoryElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDivElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDocument\":[\"Document\",\"EventTarget\",\"Node\"],\"HtmlElement\":[\"Element\",\"EventTarget\",\"Node\"],\"HtmlEmbedElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFieldSetElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFontElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFormControlsCollection\":[\"HtmlCollection\"],\"HtmlFormElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFrameElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFrameSetElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHeadElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHeadingElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHrElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHtmlElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlIFrameElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlImageElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlInputElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLabelElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLegendElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLiElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLinkElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMapElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMediaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMenuElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMenuItemElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMetaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMeterElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlModElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlObjectElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptGroupElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptionsCollection\":[\"HtmlCollection\"],\"HtmlOutputElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlParagraphElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlParamElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlPictureElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlPreElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlProgressElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlQuoteElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlScriptElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSelectElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSlotElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSourceElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSpanElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlStyleElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableCaptionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableCellElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableColElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableRowElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableSectionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTemplateElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTextAreaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTimeElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTitleElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTrackElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlUListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlUnknownElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlVideoElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"HtmlMediaElement\",\"Node\"],\"HttpConnDict\":[],\"HttpConnInfo\":[],\"HttpConnectionElement\":[],\"IdbCursor\":[],\"IdbCursorDirection\":[],\"IdbCursorWithValue\":[\"IdbCursor\"],\"IdbDatabase\":[\"EventTarget\"],\"IdbFactory\":[],\"IdbFileHandle\":[\"EventTarget\"],\"IdbFileMetadataParameters\":[],\"IdbFileRequest\":[\"DomRequest\",\"EventTarget\"],\"IdbIndex\":[],\"IdbIndexParameters\":[],\"IdbKeyRange\":[],\"IdbLocaleAwareKeyRange\":[\"IdbKeyRange\"],\"IdbMutableFile\":[\"EventTarget\"],\"IdbObjectStore\":[],\"IdbObjectStoreParameters\":[],\"IdbOpenDbOptions\":[],\"IdbOpenDbRequest\":[\"EventTarget\",\"IdbRequest\"],\"IdbRequest\":[\"EventTarget\"],\"IdbRequestReadyState\":[],\"IdbTransaction\":[\"EventTarget\"],\"IdbTransactionDurability\":[],\"IdbTransactionMode\":[],\"IdbTransactionOptions\":[],\"IdbVersionChangeEvent\":[\"Event\"],\"IdbVersionChangeEventInit\":[],\"IdleDeadline\":[],\"IdleRequestOptions\":[],\"IirFilterNode\":[\"AudioNode\",\"EventTarget\"],\"IirFilterOptions\":[],\"ImageBitmap\":[],\"ImageBitmapOptions\":[],\"ImageBitmapRenderingContext\":[],\"ImageCapture\":[],\"ImageCaptureError\":[],\"ImageCaptureErrorEvent\":[\"Event\"],\"ImageCaptureErrorEventInit\":[],\"ImageData\":[],\"ImageDecodeOptions\":[],\"ImageDecodeResult\":[],\"ImageDecoder\":[],\"ImageDecoderInit\":[],\"ImageEncodeOptions\":[],\"ImageOrientation\":[],\"ImageTrack\":[],\"ImageTrackList\":[],\"InputDeviceInfo\":[\"MediaDeviceInfo\"],\"InputEvent\":[\"Event\",\"UiEvent\"],\"InputEventInit\":[],\"IntersectionObserver\":[],\"IntersectionObserverEntry\":[],\"IntersectionObserverEntryInit\":[],\"IntersectionObserverInit\":[],\"IntlUtils\":[],\"IsInputPendingOptions\":[],\"IterableKeyAndValueResult\":[],\"IterableKeyOrValueResult\":[],\"IterationCompositeOperation\":[],\"JsonWebKey\":[],\"KeyAlgorithm\":[],\"KeyEvent\":[],\"KeyFrameRequestEvent\":[\"Event\"],\"KeyIdsInitData\":[],\"KeyboardEvent\":[\"Event\",\"UiEvent\"],\"KeyboardEventInit\":[],\"KeyframeAnimationOptions\":[],\"KeyframeEffect\":[\"AnimationEffect\"],\"KeyframeEffectOptions\":[],\"L10nElement\":[],\"L10nValue\":[],\"LargeBlobSupport\":[],\"LatencyMode\":[],\"LifecycleCallbacks\":[],\"LineAlignSetting\":[],\"ListBoxObject\":[],\"LocalMediaStream\":[\"EventTarget\",\"MediaStream\"],\"LocaleInfo\":[],\"Location\":[],\"Lock\":[],\"LockInfo\":[],\"LockManager\":[],\"LockManagerSnapshot\":[],\"LockMode\":[],\"LockOptions\":[],\"MathMlElement\":[\"Element\",\"EventTarget\",\"Node\"],\"MediaCapabilities\":[],\"MediaCapabilitiesInfo\":[],\"MediaConfiguration\":[],\"MediaDecodingConfiguration\":[],\"MediaDecodingType\":[],\"MediaDeviceInfo\":[],\"MediaDeviceKind\":[],\"MediaDevices\":[\"EventTarget\"],\"MediaElementAudioSourceNode\":[\"AudioNode\",\"EventTarget\"],\"MediaElementAudioSourceOptions\":[],\"MediaEncodingConfiguration\":[],\"MediaEncodingType\":[],\"MediaEncryptedEvent\":[\"Event\"],\"MediaError\":[],\"MediaImage\":[],\"MediaKeyError\":[\"Event\"],\"MediaKeyMessageEvent\":[\"Event\"],\"MediaKeyMessageEventInit\":[],\"MediaKeyMessageType\":[],\"MediaKeyNeededEventInit\":[],\"MediaKeySession\":[\"EventTarget\"],\"MediaKeySessionType\":[],\"MediaKeyStatus\":[],\"MediaKeyStatusMap\":[],\"MediaKeySystemAccess\":[],\"MediaKeySystemConfiguration\":[],\"MediaKeySystemMediaCapability\":[],\"MediaKeySystemStatus\":[],\"MediaKeys\":[],\"MediaKeysPolicy\":[],\"MediaKeysRequirement\":[],\"MediaList\":[],\"MediaMetadata\":[],\"MediaMetadataInit\":[],\"MediaPositionState\":[],\"MediaQueryList\":[\"EventTarget\"],\"MediaQueryListEvent\":[\"Event\"],\"MediaQueryListEventInit\":[],\"MediaRecorder\":[\"EventTarget\"],\"MediaRecorderErrorEvent\":[\"Event\"],\"MediaRecorderErrorEventInit\":[],\"MediaRecorderOptions\":[],\"MediaSession\":[],\"MediaSessionAction\":[],\"MediaSessionActionDetails\":[],\"MediaSessionPlaybackState\":[],\"MediaSettingsRange\":[],\"MediaSource\":[\"EventTarget\"],\"MediaSourceEndOfStreamError\":[],\"MediaSourceEnum\":[],\"MediaSourceReadyState\":[],\"MediaStream\":[\"EventTarget\"],\"MediaStreamAudioDestinationNode\":[\"AudioNode\",\"EventTarget\"],\"MediaStreamAudioSourceNode\":[\"AudioNode\",\"EventTarget\"],\"MediaStreamAudioSourceOptions\":[],\"MediaStreamConstraints\":[],\"MediaStreamError\":[],\"MediaStreamEvent\":[\"Event\"],\"MediaStreamEventInit\":[],\"MediaStreamTrack\":[\"EventTarget\"],\"MediaStreamTrackEvent\":[\"Event\"],\"MediaStreamTrackEventInit\":[],\"MediaStreamTrackGenerator\":[\"EventTarget\",\"MediaStreamTrack\"],\"MediaStreamTrackGeneratorInit\":[],\"MediaStreamTrackProcessor\":[],\"MediaStreamTrackProcessorInit\":[],\"MediaStreamTrackState\":[],\"MediaTrackCapabilities\":[],\"MediaTrackConstraintSet\":[],\"MediaTrackConstraints\":[],\"MediaTrackSettings\":[],\"MediaTrackSupportedConstraints\":[],\"MemoryAttribution\":[],\"MemoryAttributionContainer\":[],\"MemoryBreakdownEntry\":[],\"MemoryMeasurement\":[],\"MessageChannel\":[],\"MessageEvent\":[\"Event\"],\"MessageEventInit\":[],\"MessagePort\":[\"EventTarget\"],\"MeteringMode\":[],\"MidiAccess\":[\"EventTarget\"],\"MidiConnectionEvent\":[\"Event\"],\"MidiConnectionEventInit\":[],\"MidiInput\":[\"EventTarget\",\"MidiPort\"],\"MidiInputMap\":[],\"MidiMessageEvent\":[\"Event\"],\"MidiMessageEventInit\":[],\"MidiOptions\":[],\"MidiOutput\":[\"EventTarget\",\"MidiPort\"],\"MidiOutputMap\":[],\"MidiPort\":[\"EventTarget\"],\"MidiPortConnectionState\":[],\"MidiPortDeviceState\":[],\"MidiPortType\":[],\"MimeType\":[],\"MimeTypeArray\":[],\"MouseEvent\":[\"Event\",\"UiEvent\"],\"MouseEventInit\":[],\"MouseScrollEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"MozDebug\":[],\"MutationEvent\":[\"Event\"],\"MutationObserver\":[],\"MutationObserverInit\":[],\"MutationObservingInfo\":[],\"MutationRecord\":[],\"NamedNodeMap\":[],\"NativeOsFileReadOptions\":[],\"NativeOsFileWriteAtomicOptions\":[],\"NavigationType\":[],\"Navigator\":[],\"NavigatorAutomationInformation\":[],\"NavigatorUaBrandVersion\":[],\"NavigatorUaData\":[],\"NetworkCommandOptions\":[],\"NetworkInformation\":[\"EventTarget\"],\"NetworkResultOptions\":[],\"Node\":[\"EventTarget\"],\"NodeFilter\":[],\"NodeIterator\":[],\"NodeList\":[],\"Notification\":[\"EventTarget\"],\"NotificationAction\":[],\"NotificationDirection\":[],\"NotificationEvent\":[\"Event\",\"ExtendableEvent\"],\"NotificationEventInit\":[],\"NotificationOptions\":[],\"NotificationPermission\":[],\"ObserverCallback\":[],\"OesElementIndexUint\":[],\"OesStandardDerivatives\":[],\"OesTextureFloat\":[],\"OesTextureFloatLinear\":[],\"OesTextureHalfFloat\":[],\"OesTextureHalfFloatLinear\":[],\"OesVertexArrayObject\":[],\"OfflineAudioCompletionEvent\":[\"Event\"],\"OfflineAudioCompletionEventInit\":[],\"OfflineAudioContext\":[\"BaseAudioContext\",\"EventTarget\"],\"OfflineAudioContextOptions\":[],\"OfflineResourceList\":[\"EventTarget\"],\"OffscreenCanvas\":[\"EventTarget\"],\"OffscreenCanvasRenderingContext2d\":[],\"OpenFilePickerOptions\":[],\"OpenWindowEventDetail\":[],\"OptionalEffectTiming\":[],\"OrientationLockType\":[],\"OrientationType\":[],\"OscillatorNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"OscillatorOptions\":[],\"OscillatorType\":[],\"OverSampleType\":[],\"OvrMultiview2\":[],\"PageTransitionEvent\":[\"Event\"],\"PageTransitionEventInit\":[],\"PaintRequest\":[],\"PaintRequestList\":[],\"PaintWorkletGlobalScope\":[\"WorkletGlobalScope\"],\"PannerNode\":[\"AudioNode\",\"EventTarget\"],\"PannerOptions\":[],\"PanningModelType\":[],\"ParityType\":[],\"Path2d\":[],\"PaymentAddress\":[],\"PaymentComplete\":[],\"PaymentMethodChangeEvent\":[\"Event\",\"PaymentRequestUpdateEvent\"],\"PaymentMethodChangeEventInit\":[],\"PaymentRequestUpdateEvent\":[\"Event\"],\"PaymentRequestUpdateEventInit\":[],\"PaymentResponse\":[],\"Pbkdf2Params\":[],\"PcImplIceConnectionState\":[],\"PcImplIceGatheringState\":[],\"PcImplSignalingState\":[],\"PcObserverStateType\":[],\"Performance\":[\"EventTarget\"],\"PerformanceEntry\":[],\"PerformanceEntryEventInit\":[],\"PerformanceEntryFilterOptions\":[],\"PerformanceMark\":[\"PerformanceEntry\"],\"PerformanceMarkOptions\":[],\"PerformanceMeasure\":[\"PerformanceEntry\"],\"PerformanceMeasureOptions\":[],\"PerformanceNavigation\":[],\"PerformanceNavigationTiming\":[\"PerformanceEntry\",\"PerformanceResourceTiming\"],\"PerformanceObserver\":[],\"PerformanceObserverEntryList\":[],\"PerformanceObserverInit\":[],\"PerformanceResourceTiming\":[\"PerformanceEntry\"],\"PerformanceServerTiming\":[],\"PerformanceTiming\":[],\"PeriodicWave\":[],\"PeriodicWaveConstraints\":[],\"PeriodicWaveOptions\":[],\"PermissionDescriptor\":[],\"PermissionName\":[],\"PermissionState\":[],\"PermissionStatus\":[\"EventTarget\"],\"Permissions\":[],\"PhotoCapabilities\":[],\"PhotoSettings\":[],\"PictureInPictureEvent\":[\"Event\"],\"PictureInPictureEventInit\":[],\"PictureInPictureWindow\":[\"EventTarget\"],\"PlaneLayout\":[],\"PlaybackDirection\":[],\"Plugin\":[],\"PluginArray\":[],\"PluginCrashedEventInit\":[],\"Point2d\":[],\"PointerEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"PointerEventInit\":[],\"PopStateEvent\":[\"Event\"],\"PopStateEventInit\":[],\"PopupBlockedEvent\":[\"Event\"],\"PopupBlockedEventInit\":[],\"Position\":[],\"PositionAlignSetting\":[],\"PositionError\":[],\"PositionOptions\":[],\"PremultiplyAlpha\":[],\"Presentation\":[],\"PresentationAvailability\":[\"EventTarget\"],\"PresentationConnection\":[\"EventTarget\"],\"PresentationConnectionAvailableEvent\":[\"Event\"],\"PresentationConnectionAvailableEventInit\":[],\"PresentationConnectionBinaryType\":[],\"PresentationConnectionCloseEvent\":[\"Event\"],\"PresentationConnectionCloseEventInit\":[],\"PresentationConnectionClosedReason\":[],\"PresentationConnectionList\":[\"EventTarget\"],\"PresentationConnectionState\":[],\"PresentationReceiver\":[],\"PresentationRequest\":[\"EventTarget\"],\"PresentationStyle\":[],\"ProcessingInstruction\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"ProfileTimelineLayerRect\":[],\"ProfileTimelineMarker\":[],\"ProfileTimelineMessagePortOperationType\":[],\"ProfileTimelineStackFrame\":[],\"ProfileTimelineWorkerOperationType\":[],\"ProgressEvent\":[\"Event\"],\"ProgressEventInit\":[],\"PromiseNativeHandler\":[],\"PromiseRejectionEvent\":[\"Event\"],\"PromiseRejectionEventInit\":[],\"PublicKeyCredential\":[\"Credential\"],\"PublicKeyCredentialCreationOptions\":[],\"PublicKeyCredentialCreationOptionsJson\":[],\"PublicKeyCredentialDescriptor\":[],\"PublicKeyCredentialDescriptorJson\":[],\"PublicKeyCredentialEntity\":[],\"PublicKeyCredentialHints\":[],\"PublicKeyCredentialParameters\":[],\"PublicKeyCredentialRequestOptions\":[],\"PublicKeyCredentialRequestOptionsJson\":[],\"PublicKeyCredentialRpEntity\":[],\"PublicKeyCredentialType\":[],\"PublicKeyCredentialUserEntity\":[],\"PublicKeyCredentialUserEntityJson\":[],\"PushEncryptionKeyName\":[],\"PushEvent\":[\"Event\",\"ExtendableEvent\"],\"PushEventInit\":[],\"PushManager\":[],\"PushMessageData\":[],\"PushPermissionState\":[],\"PushSubscription\":[],\"PushSubscriptionInit\":[],\"PushSubscriptionJson\":[],\"PushSubscriptionKeys\":[],\"PushSubscriptionOptions\":[],\"PushSubscriptionOptionsInit\":[],\"QueryOptions\":[],\"QueuingStrategy\":[],\"QueuingStrategyInit\":[],\"RadioNodeList\":[\"NodeList\"],\"Range\":[\"AbstractRange\"],\"RcwnPerfStats\":[],\"RcwnStatus\":[],\"ReadableByteStreamController\":[],\"ReadableStream\":[],\"ReadableStreamByobReader\":[],\"ReadableStreamByobRequest\":[],\"ReadableStreamDefaultController\":[],\"ReadableStreamDefaultReader\":[],\"ReadableStreamGetReaderOptions\":[],\"ReadableStreamIteratorOptions\":[],\"ReadableStreamReadResult\":[],\"ReadableStreamReaderMode\":[],\"ReadableStreamType\":[],\"ReadableWritablePair\":[],\"RecordingState\":[],\"RedEyeReduction\":[],\"ReferrerPolicy\":[],\"RegisterRequest\":[],\"RegisterResponse\":[],\"RegisteredKey\":[],\"RegistrationOptions\":[],\"RegistrationResponseJson\":[],\"Request\":[],\"RequestCache\":[],\"RequestCredentials\":[],\"RequestDestination\":[],\"RequestDeviceOptions\":[],\"RequestInit\":[],\"RequestMediaKeySystemAccessNotification\":[],\"RequestMode\":[],\"RequestRedirect\":[],\"ResidentKeyRequirement\":[],\"ResizeObserver\":[],\"ResizeObserverBoxOptions\":[],\"ResizeObserverEntry\":[],\"ResizeObserverOptions\":[],\"ResizeObserverSize\":[],\"ResizeQuality\":[],\"Response\":[],\"ResponseInit\":[],\"ResponseType\":[],\"RsaHashedImportParams\":[],\"RsaOaepParams\":[],\"RsaOtherPrimesInfo\":[],\"RsaPssParams\":[],\"RtcAnswerOptions\":[],\"RtcBundlePolicy\":[],\"RtcCertificate\":[],\"RtcCertificateExpiration\":[],\"RtcCodecStats\":[],\"RtcConfiguration\":[],\"RtcDataChannel\":[\"EventTarget\"],\"RtcDataChannelEvent\":[\"Event\"],\"RtcDataChannelEventInit\":[],\"RtcDataChannelInit\":[],\"RtcDataChannelState\":[],\"RtcDataChannelType\":[],\"RtcDegradationPreference\":[],\"RtcEncodedAudioFrame\":[],\"RtcEncodedAudioFrameMetadata\":[],\"RtcEncodedAudioFrameOptions\":[],\"RtcEncodedVideoFrame\":[],\"RtcEncodedVideoFrameMetadata\":[],\"RtcEncodedVideoFrameOptions\":[],\"RtcEncodedVideoFrameType\":[],\"RtcFecParameters\":[],\"RtcIceCandidate\":[],\"RtcIceCandidateInit\":[],\"RtcIceCandidatePairStats\":[],\"RtcIceCandidateStats\":[],\"RtcIceComponentStats\":[],\"RtcIceConnectionState\":[],\"RtcIceCredentialType\":[],\"RtcIceGatheringState\":[],\"RtcIceServer\":[],\"RtcIceTransportPolicy\":[],\"RtcIdentityAssertion\":[],\"RtcIdentityAssertionResult\":[],\"RtcIdentityProvider\":[],\"RtcIdentityProviderDetails\":[],\"RtcIdentityProviderOptions\":[],\"RtcIdentityProviderRegistrar\":[],\"RtcIdentityValidationResult\":[],\"RtcInboundRtpStreamStats\":[],\"RtcMediaStreamStats\":[],\"RtcMediaStreamTrackStats\":[],\"RtcOfferAnswerOptions\":[],\"RtcOfferOptions\":[],\"RtcOutboundRtpStreamStats\":[],\"RtcPeerConnection\":[\"EventTarget\"],\"RtcPeerConnectionIceErrorEvent\":[\"Event\"],\"RtcPeerConnectionIceEvent\":[\"Event\"],\"RtcPeerConnectionIceEventInit\":[],\"RtcPeerConnectionState\":[],\"RtcPriorityType\":[],\"RtcRtcpParameters\":[],\"RtcRtpCapabilities\":[],\"RtcRtpCodecCapability\":[],\"RtcRtpCodecParameters\":[],\"RtcRtpContributingSource\":[],\"RtcRtpEncodingParameters\":[],\"RtcRtpHeaderExtensionCapability\":[],\"RtcRtpHeaderExtensionParameters\":[],\"RtcRtpParameters\":[],\"RtcRtpReceiver\":[],\"RtcRtpScriptTransform\":[],\"RtcRtpScriptTransformer\":[\"EventTarget\"],\"RtcRtpSender\":[],\"RtcRtpSourceEntry\":[],\"RtcRtpSourceEntryType\":[],\"RtcRtpSynchronizationSource\":[],\"RtcRtpTransceiver\":[],\"RtcRtpTransceiverDirection\":[],\"RtcRtpTransceiverInit\":[],\"RtcRtxParameters\":[],\"RtcSdpType\":[],\"RtcSessionDescription\":[],\"RtcSessionDescriptionInit\":[],\"RtcSignalingState\":[],\"RtcStats\":[],\"RtcStatsIceCandidatePairState\":[],\"RtcStatsIceCandidateType\":[],\"RtcStatsReport\":[],\"RtcStatsReportInternal\":[],\"RtcStatsType\":[],\"RtcTrackEvent\":[\"Event\"],\"RtcTrackEventInit\":[],\"RtcTransformEvent\":[\"Event\"],\"RtcTransportStats\":[],\"RtcdtmfSender\":[\"EventTarget\"],\"RtcdtmfToneChangeEvent\":[\"Event\"],\"RtcdtmfToneChangeEventInit\":[],\"RtcrtpContributingSourceStats\":[],\"RtcrtpStreamStats\":[],\"SFrameTransform\":[\"EventTarget\"],\"SFrameTransformErrorEvent\":[\"Event\"],\"SFrameTransformErrorEventInit\":[],\"SFrameTransformErrorEventType\":[],\"SFrameTransformOptions\":[],\"SFrameTransformRole\":[],\"SaveFilePickerOptions\":[],\"Scheduler\":[],\"SchedulerPostTaskOptions\":[],\"Scheduling\":[],\"Screen\":[\"EventTarget\"],\"ScreenColorGamut\":[],\"ScreenDetailed\":[\"EventTarget\",\"Screen\"],\"ScreenDetails\":[\"EventTarget\"],\"ScreenLuminance\":[],\"ScreenOrientation\":[\"EventTarget\"],\"ScriptProcessorNode\":[\"AudioNode\",\"EventTarget\"],\"ScrollAreaEvent\":[\"Event\",\"UiEvent\"],\"ScrollBehavior\":[],\"ScrollBoxObject\":[],\"ScrollIntoViewContainer\":[],\"ScrollIntoViewOptions\":[],\"ScrollLogicalPosition\":[],\"ScrollOptions\":[],\"ScrollRestoration\":[],\"ScrollSetting\":[],\"ScrollState\":[],\"ScrollToOptions\":[],\"ScrollViewChangeEventInit\":[],\"SecurityPolicyViolationEvent\":[\"Event\"],\"SecurityPolicyViolationEventDisposition\":[],\"SecurityPolicyViolationEventInit\":[],\"Selection\":[],\"SelectionMode\":[],\"Serial\":[\"EventTarget\"],\"SerialInputSignals\":[],\"SerialOptions\":[],\"SerialOutputSignals\":[],\"SerialPort\":[\"EventTarget\"],\"SerialPortFilter\":[],\"SerialPortInfo\":[],\"SerialPortRequestOptions\":[],\"ServerSocketOptions\":[],\"ServiceWorker\":[\"EventTarget\"],\"ServiceWorkerContainer\":[\"EventTarget\"],\"ServiceWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"ServiceWorkerRegistration\":[\"EventTarget\"],\"ServiceWorkerState\":[],\"ServiceWorkerUpdateViaCache\":[],\"ShadowRoot\":[\"DocumentFragment\",\"EventTarget\",\"Node\"],\"ShadowRootInit\":[],\"ShadowRootMode\":[],\"ShareData\":[],\"SharedWorker\":[\"EventTarget\"],\"SharedWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"ShowPopoverOptions\":[],\"SignResponse\":[],\"SocketElement\":[],\"SocketOptions\":[],\"SocketReadyState\":[],\"SocketsDict\":[],\"SourceBuffer\":[\"EventTarget\"],\"SourceBufferAppendMode\":[],\"SourceBufferList\":[\"EventTarget\"],\"SpeechGrammar\":[],\"SpeechGrammarList\":[],\"SpeechRecognition\":[\"EventTarget\"],\"SpeechRecognitionAlternative\":[],\"SpeechRecognitionError\":[\"Event\"],\"SpeechRecognitionErrorCode\":[],\"SpeechRecognitionErrorInit\":[],\"SpeechRecognitionEvent\":[\"Event\"],\"SpeechRecognitionEventInit\":[],\"SpeechRecognitionResult\":[],\"SpeechRecognitionResultList\":[],\"SpeechSynthesis\":[\"EventTarget\"],\"SpeechSynthesisErrorCode\":[],\"SpeechSynthesisErrorEvent\":[\"Event\",\"SpeechSynthesisEvent\"],\"SpeechSynthesisErrorEventInit\":[],\"SpeechSynthesisEvent\":[\"Event\"],\"SpeechSynthesisEventInit\":[],\"SpeechSynthesisUtterance\":[\"EventTarget\"],\"SpeechSynthesisVoice\":[],\"StaticRange\":[\"AbstractRange\"],\"StaticRangeInit\":[],\"StereoPannerNode\":[\"AudioNode\",\"EventTarget\"],\"StereoPannerOptions\":[],\"Storage\":[],\"StorageEstimate\":[],\"StorageEvent\":[\"Event\"],\"StorageEventInit\":[],\"StorageManager\":[],\"StorageType\":[],\"StreamPipeOptions\":[],\"StyleRuleChangeEventInit\":[],\"StyleSheet\":[],\"StyleSheetApplicableStateChangeEventInit\":[],\"StyleSheetChangeEventInit\":[],\"StyleSheetList\":[],\"SubmitEvent\":[\"Event\"],\"SubmitEventInit\":[],\"SubtleCrypto\":[],\"SupportedType\":[],\"SvcOutputMetadata\":[],\"SvgAngle\":[],\"SvgAnimateElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimateMotionElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimateTransformElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimatedAngle\":[],\"SvgAnimatedBoolean\":[],\"SvgAnimatedEnumeration\":[],\"SvgAnimatedInteger\":[],\"SvgAnimatedLength\":[],\"SvgAnimatedLengthList\":[],\"SvgAnimatedNumber\":[],\"SvgAnimatedNumberList\":[],\"SvgAnimatedPreserveAspectRatio\":[],\"SvgAnimatedRect\":[],\"SvgAnimatedString\":[],\"SvgAnimatedTransformList\":[],\"SvgAnimationElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgBoundingBoxOptions\":[],\"SvgCircleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgClipPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgComponentTransferFunctionElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgDefsElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgDescElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgElement\":[\"Element\",\"EventTarget\",\"Node\"],\"SvgEllipseElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgFilterElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgForeignObjectElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgGeometryElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgGraphicsElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgImageElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgLength\":[],\"SvgLengthList\":[],\"SvgLineElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgLinearGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGradientElement\"],\"SvgMarkerElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgMaskElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgMatrix\":[],\"SvgMetadataElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgNumber\":[],\"SvgNumberList\":[],\"SvgPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPathSeg\":[],\"SvgPathSegArcAbs\":[\"SvgPathSeg\"],\"SvgPathSegArcRel\":[\"SvgPathSeg\"],\"SvgPathSegClosePath\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicSmoothAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicSmoothRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticSmoothAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticSmoothRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoHorizontalAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoHorizontalRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoVerticalAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoVerticalRel\":[\"SvgPathSeg\"],\"SvgPathSegList\":[],\"SvgPathSegMovetoAbs\":[\"SvgPathSeg\"],\"SvgPathSegMovetoRel\":[\"SvgPathSeg\"],\"SvgPatternElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgPoint\":[],\"SvgPointList\":[],\"SvgPolygonElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPolylineElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPreserveAspectRatio\":[],\"SvgRadialGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGradientElement\"],\"SvgRect\":[],\"SvgRectElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgScriptElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgSetElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgStopElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgStringList\":[],\"SvgStyleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgSwitchElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgSymbolElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgTextContentElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgTextElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\",\"SvgTextPositioningElement\"],\"SvgTextPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\"],\"SvgTextPositioningElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\"],\"SvgTitleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgTransform\":[],\"SvgTransformList\":[],\"SvgUnitTypes\":[],\"SvgUseElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgViewElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgZoomAndPan\":[],\"SvgaElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgfeBlendElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeColorMatrixElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeComponentTransferElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeCompositeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeConvolveMatrixElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDiffuseLightingElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDisplacementMapElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDistantLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDropShadowElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeFloodElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeFuncAElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncBElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncGElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncRElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeGaussianBlurElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeImageElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMergeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMergeNodeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMorphologyElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeOffsetElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfePointLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeSpecularLightingElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeSpotLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeTileElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeTurbulenceElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvggElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgmPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgsvgElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgtSpanElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\",\"SvgTextPositioningElement\"],\"TaskController\":[\"AbortController\"],\"TaskControllerInit\":[],\"TaskPriority\":[],\"TaskPriorityChangeEvent\":[\"Event\"],\"TaskPriorityChangeEventInit\":[],\"TaskSignal\":[\"AbortSignal\",\"EventTarget\"],\"TaskSignalAnyInit\":[],\"TcpReadyState\":[],\"TcpServerSocket\":[\"EventTarget\"],\"TcpServerSocketEvent\":[\"Event\"],\"TcpServerSocketEventInit\":[],\"TcpSocket\":[\"EventTarget\"],\"TcpSocketBinaryType\":[],\"TcpSocketErrorEvent\":[\"Event\"],\"TcpSocketErrorEventInit\":[],\"TcpSocketEvent\":[\"Event\"],\"TcpSocketEventInit\":[],\"Text\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"TextDecodeOptions\":[],\"TextDecoder\":[],\"TextDecoderOptions\":[],\"TextEncoder\":[],\"TextMetrics\":[],\"TextTrack\":[\"EventTarget\"],\"TextTrackCue\":[\"EventTarget\"],\"TextTrackCueList\":[],\"TextTrackKind\":[],\"TextTrackList\":[\"EventTarget\"],\"TextTrackMode\":[],\"TimeEvent\":[\"Event\"],\"TimeRanges\":[],\"ToggleEvent\":[\"Event\"],\"ToggleEventInit\":[],\"TogglePopoverOptions\":[],\"TokenBinding\":[],\"TokenBindingStatus\":[],\"Touch\":[],\"TouchEvent\":[\"Event\",\"UiEvent\"],\"TouchEventInit\":[],\"TouchInit\":[],\"TouchList\":[],\"TrackEvent\":[\"Event\"],\"TrackEventInit\":[],\"TransformStream\":[],\"TransformStreamDefaultController\":[],\"Transformer\":[],\"TransitionEvent\":[\"Event\"],\"TransitionEventInit\":[],\"Transport\":[],\"TreeBoxObject\":[],\"TreeCellInfo\":[],\"TreeView\":[],\"TreeWalker\":[],\"U2f\":[],\"U2fClientData\":[],\"ULongRange\":[],\"UaDataValues\":[],\"UaLowEntropyJson\":[],\"UdpMessageEventInit\":[],\"UdpOptions\":[],\"UiEvent\":[\"Event\"],\"UiEventInit\":[],\"UnderlyingSink\":[],\"UnderlyingSource\":[],\"Url\":[],\"UrlSearchParams\":[],\"Usb\":[\"EventTarget\"],\"UsbAlternateInterface\":[],\"UsbConfiguration\":[],\"UsbConnectionEvent\":[\"Event\"],\"UsbConnectionEventInit\":[],\"UsbControlTransferParameters\":[],\"UsbDevice\":[],\"UsbDeviceFilter\":[],\"UsbDeviceRequestOptions\":[],\"UsbDirection\":[],\"UsbEndpoint\":[],\"UsbEndpointType\":[],\"UsbInTransferResult\":[],\"UsbInterface\":[],\"UsbIsochronousInTransferPacket\":[],\"UsbIsochronousInTransferResult\":[],\"UsbIsochronousOutTransferPacket\":[],\"UsbIsochronousOutTransferResult\":[],\"UsbOutTransferResult\":[],\"UsbPermissionDescriptor\":[],\"UsbPermissionResult\":[\"EventTarget\",\"PermissionStatus\"],\"UsbPermissionStorage\":[],\"UsbRecipient\":[],\"UsbRequestType\":[],\"UsbTransferStatus\":[],\"UserActivation\":[],\"UserProximityEvent\":[\"Event\"],\"UserProximityEventInit\":[],\"UserVerificationRequirement\":[],\"ValidityState\":[],\"ValueEvent\":[\"Event\"],\"ValueEventInit\":[],\"VideoColorPrimaries\":[],\"VideoColorSpace\":[],\"VideoColorSpaceInit\":[],\"VideoConfiguration\":[],\"VideoDecoder\":[\"EventTarget\"],\"VideoDecoderConfig\":[],\"VideoDecoderInit\":[],\"VideoDecoderSupport\":[],\"VideoEncoder\":[\"EventTarget\"],\"VideoEncoderBitrateMode\":[],\"VideoEncoderConfig\":[],\"VideoEncoderEncodeOptions\":[],\"VideoEncoderInit\":[],\"VideoEncoderSupport\":[],\"VideoFacingModeEnum\":[],\"VideoFrame\":[],\"VideoFrameBufferInit\":[],\"VideoFrameCopyToOptions\":[],\"VideoFrameInit\":[],\"VideoFrameMetadata\":[],\"VideoMatrixCoefficients\":[],\"VideoPixelFormat\":[],\"VideoPlaybackQuality\":[],\"VideoStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"VideoTrack\":[],\"VideoTrackList\":[\"EventTarget\"],\"VideoTransferCharacteristics\":[],\"ViewTransition\":[],\"VisibilityState\":[],\"VisualViewport\":[\"EventTarget\"],\"VoidCallback\":[],\"VrDisplay\":[\"EventTarget\"],\"VrDisplayCapabilities\":[],\"VrEye\":[],\"VrEyeParameters\":[],\"VrFieldOfView\":[],\"VrFrameData\":[],\"VrLayer\":[],\"VrMockController\":[],\"VrMockDisplay\":[],\"VrPose\":[],\"VrServiceTest\":[],\"VrStageParameters\":[],\"VrSubmitFrameResult\":[],\"VttCue\":[\"EventTarget\",\"TextTrackCue\"],\"VttRegion\":[],\"WakeLock\":[],\"WakeLockSentinel\":[\"EventTarget\"],\"WakeLockType\":[],\"WatchAdvertisementsOptions\":[],\"WaveShaperNode\":[\"AudioNode\",\"EventTarget\"],\"WaveShaperOptions\":[],\"WebGl2RenderingContext\":[],\"WebGlActiveInfo\":[],\"WebGlBuffer\":[],\"WebGlContextAttributes\":[],\"WebGlContextEvent\":[\"Event\"],\"WebGlContextEventInit\":[],\"WebGlFramebuffer\":[],\"WebGlPowerPreference\":[],\"WebGlProgram\":[],\"WebGlQuery\":[],\"WebGlRenderbuffer\":[],\"WebGlRenderingContext\":[],\"WebGlSampler\":[],\"WebGlShader\":[],\"WebGlShaderPrecisionFormat\":[],\"WebGlSync\":[],\"WebGlTexture\":[],\"WebGlTransformFeedback\":[],\"WebGlUniformLocation\":[],\"WebGlVertexArrayObject\":[],\"WebKitCssMatrix\":[\"DomMatrix\",\"DomMatrixReadOnly\"],\"WebSocket\":[\"EventTarget\"],\"WebSocketDict\":[],\"WebSocketElement\":[],\"WebTransport\":[],\"WebTransportBidirectionalStream\":[],\"WebTransportCloseInfo\":[],\"WebTransportCongestionControl\":[],\"WebTransportDatagramDuplexStream\":[],\"WebTransportDatagramStats\":[],\"WebTransportError\":[\"DomException\"],\"WebTransportErrorOptions\":[],\"WebTransportErrorSource\":[],\"WebTransportHash\":[],\"WebTransportOptions\":[],\"WebTransportReceiveStream\":[\"ReadableStream\"],\"WebTransportReceiveStreamStats\":[],\"WebTransportReliabilityMode\":[],\"WebTransportSendStream\":[\"WritableStream\"],\"WebTransportSendStreamOptions\":[],\"WebTransportSendStreamStats\":[],\"WebTransportStats\":[],\"WebglColorBufferFloat\":[],\"WebglCompressedTextureAstc\":[],\"WebglCompressedTextureAtc\":[],\"WebglCompressedTextureEtc\":[],\"WebglCompressedTextureEtc1\":[],\"WebglCompressedTexturePvrtc\":[],\"WebglCompressedTextureS3tc\":[],\"WebglCompressedTextureS3tcSrgb\":[],\"WebglDebugRendererInfo\":[],\"WebglDebugShaders\":[],\"WebglDepthTexture\":[],\"WebglDrawBuffers\":[],\"WebglLoseContext\":[],\"WebglMultiDraw\":[],\"WellKnownDirectory\":[],\"WgslLanguageFeatures\":[],\"WheelEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"WheelEventInit\":[],\"WidevineCdmManifest\":[],\"Window\":[\"EventTarget\"],\"WindowClient\":[\"Client\"],\"Worker\":[\"EventTarget\"],\"WorkerDebuggerGlobalScope\":[\"EventTarget\"],\"WorkerGlobalScope\":[\"EventTarget\"],\"WorkerLocation\":[],\"WorkerNavigator\":[],\"WorkerOptions\":[],\"WorkerType\":[],\"Worklet\":[],\"WorkletGlobalScope\":[],\"WorkletOptions\":[],\"WritableStream\":[],\"WritableStreamDefaultController\":[],\"WritableStreamDefaultWriter\":[],\"WriteCommandType\":[],\"WriteParams\":[],\"XPathExpression\":[],\"XPathNsResolver\":[],\"XPathResult\":[],\"XmlDocument\":[\"Document\",\"EventTarget\",\"Node\"],\"XmlHttpRequest\":[\"EventTarget\",\"XmlHttpRequestEventTarget\"],\"XmlHttpRequestEventTarget\":[\"EventTarget\"],\"XmlHttpRequestResponseType\":[],\"XmlHttpRequestUpload\":[\"EventTarget\",\"XmlHttpRequestEventTarget\"],\"XmlSerializer\":[],\"XrBoundedReferenceSpace\":[\"EventTarget\",\"XrReferenceSpace\",\"XrSpace\"],\"XrEye\":[],\"XrFrame\":[],\"XrHand\":[],\"XrHandJoint\":[],\"XrHandedness\":[],\"XrInputSource\":[],\"XrInputSourceArray\":[],\"XrInputSourceEvent\":[\"Event\"],\"XrInputSourceEventInit\":[],\"XrInputSourcesChangeEvent\":[\"Event\"],\"XrInputSourcesChangeEventInit\":[],\"XrJointPose\":[\"XrPose\"],\"XrJointSpace\":[\"EventTarget\",\"XrSpace\"],\"XrLayer\":[\"EventTarget\"],\"XrPermissionDescriptor\":[],\"XrPermissionStatus\":[\"EventTarget\",\"PermissionStatus\"],\"XrPose\":[],\"XrReferenceSpace\":[\"EventTarget\",\"XrSpace\"],\"XrReferenceSpaceEvent\":[\"Event\"],\"XrReferenceSpaceEventInit\":[],\"XrReferenceSpaceType\":[],\"XrRenderState\":[],\"XrRenderStateInit\":[],\"XrRigidTransform\":[],\"XrSession\":[\"EventTarget\"],\"XrSessionEvent\":[\"Event\"],\"XrSessionEventInit\":[],\"XrSessionInit\":[],\"XrSessionMode\":[],\"XrSessionSupportedPermissionDescriptor\":[],\"XrSpace\":[\"EventTarget\"],\"XrSystem\":[\"EventTarget\"],\"XrTargetRayMode\":[],\"XrView\":[],\"XrViewerPose\":[\"XrPose\"],\"XrViewport\":[],\"XrVisibilityState\":[],\"XrWebGlLayer\":[\"EventTarget\",\"XrLayer\"],\"XrWebGlLayerInit\":[],\"XsltProcessor\":[],\"console\":[],\"css\":[],\"default\":[\"std\"],\"gpu_buffer_usage\":[],\"gpu_color_write\":[],\"gpu_map_mode\":[],\"gpu_shader_stage\":[],\"gpu_texture_usage\":[],\"std\":[\"wasm-bindgen/std\",\"js-sys/std\"]}}", - "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", - "webpki-root-certs_1.0.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aws-lc-rs\",\"req\":\"^1.15.2\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", - "webpki-roots_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", - "webpki-roots_1.0.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aws-lc-rs\",\"req\":\"^1.15.2\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.6\"}],\"features\":{}}", - "whoami_1.6.1": "{\"dependencies\":[{\"name\":\"libredox\",\"req\":\"^0.1.1\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"wasite\",\"req\":\"^0.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"web\"],\"web\":[\"web-sys\"]}}", - "winapi-i686-pc-windows-gnu_0.4.0": "{\"dependencies\":[],\"features\":{}}", - "winapi-util_0.1.11": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_Console\",\"Win32_System_SystemInformation\"],\"name\":\"windows-sys\",\"req\":\">=0.48.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "winapi-x86_64-pc-windows-gnu_0.4.0": "{\"dependencies\":[],\"features\":{}}", - "winapi_0.3.9": "{\"dependencies\":[{\"name\":\"winapi-i686-pc-windows-gnu\",\"req\":\"^0.4\",\"target\":\"i686-pc-windows-gnu\"},{\"name\":\"winapi-x86_64-pc-windows-gnu\",\"req\":\"^0.4\",\"target\":\"x86_64-pc-windows-gnu\"}],\"features\":{\"accctrl\":[],\"aclapi\":[],\"activation\":[],\"adhoc\":[],\"appmgmt\":[],\"audioclient\":[],\"audiosessiontypes\":[],\"avrt\":[],\"basetsd\":[],\"bcrypt\":[],\"bits\":[],\"bits10_1\":[],\"bits1_5\":[],\"bits2_0\":[],\"bits2_5\":[],\"bits3_0\":[],\"bits4_0\":[],\"bits5_0\":[],\"bitscfg\":[],\"bitsmsg\":[],\"bluetoothapis\":[],\"bluetoothleapis\":[],\"bthdef\":[],\"bthioctl\":[],\"bthledef\":[],\"bthsdpdef\":[],\"bugcodes\":[],\"cderr\":[],\"cfg\":[],\"cfgmgr32\":[],\"cguid\":[],\"combaseapi\":[],\"coml2api\":[],\"commapi\":[],\"commctrl\":[],\"commdlg\":[],\"commoncontrols\":[],\"consoleapi\":[],\"corecrt\":[],\"corsym\":[],\"d2d1\":[],\"d2d1_1\":[],\"d2d1_2\":[],\"d2d1_3\":[],\"d2d1effectauthor\":[],\"d2d1effects\":[],\"d2d1effects_1\":[],\"d2d1effects_2\":[],\"d2d1svg\":[],\"d2dbasetypes\":[],\"d3d\":[],\"d3d10\":[],\"d3d10_1\":[],\"d3d10_1shader\":[],\"d3d10effect\":[],\"d3d10misc\":[],\"d3d10sdklayers\":[],\"d3d10shader\":[],\"d3d11\":[],\"d3d11_1\":[],\"d3d11_2\":[],\"d3d11_3\":[],\"d3d11_4\":[],\"d3d11on12\":[],\"d3d11sdklayers\":[],\"d3d11shader\":[],\"d3d11tokenizedprogramformat\":[],\"d3d12\":[],\"d3d12sdklayers\":[],\"d3d12shader\":[],\"d3d9\":[],\"d3d9caps\":[],\"d3d9types\":[],\"d3dcommon\":[],\"d3dcompiler\":[],\"d3dcsx\":[],\"d3dkmdt\":[],\"d3dkmthk\":[],\"d3dukmdt\":[],\"d3dx10core\":[],\"d3dx10math\":[],\"d3dx10mesh\":[],\"datetimeapi\":[],\"davclnt\":[],\"dbghelp\":[],\"dbt\":[],\"dcommon\":[],\"dcomp\":[],\"dcompanimation\":[],\"dcomptypes\":[],\"dde\":[],\"ddraw\":[],\"ddrawi\":[],\"ddrawint\":[],\"debug\":[\"impl-debug\"],\"debugapi\":[],\"devguid\":[],\"devicetopology\":[],\"devpkey\":[],\"devpropdef\":[],\"dinput\":[],\"dinputd\":[],\"dispex\":[],\"dmksctl\":[],\"dmusicc\":[],\"docobj\":[],\"documenttarget\":[],\"dot1x\":[],\"dpa_dsa\":[],\"dpapi\":[],\"dsgetdc\":[],\"dsound\":[],\"dsrole\":[],\"dvp\":[],\"dwmapi\":[],\"dwrite\":[],\"dwrite_1\":[],\"dwrite_2\":[],\"dwrite_3\":[],\"dxdiag\":[],\"dxfile\":[],\"dxgi\":[],\"dxgi1_2\":[],\"dxgi1_3\":[],\"dxgi1_4\":[],\"dxgi1_5\":[],\"dxgi1_6\":[],\"dxgidebug\":[],\"dxgiformat\":[],\"dxgitype\":[],\"dxva2api\":[],\"dxvahd\":[],\"eaptypes\":[],\"enclaveapi\":[],\"endpointvolume\":[],\"errhandlingapi\":[],\"everything\":[],\"evntcons\":[],\"evntprov\":[],\"evntrace\":[],\"excpt\":[],\"exdisp\":[],\"fibersapi\":[],\"fileapi\":[],\"functiondiscoverykeys_devpkey\":[],\"gl-gl\":[],\"guiddef\":[],\"handleapi\":[],\"heapapi\":[],\"hidclass\":[],\"hidpi\":[],\"hidsdi\":[],\"hidusage\":[],\"highlevelmonitorconfigurationapi\":[],\"hstring\":[],\"http\":[],\"ifdef\":[],\"ifmib\":[],\"imm\":[],\"impl-debug\":[],\"impl-default\":[],\"in6addr\":[],\"inaddr\":[],\"inspectable\":[],\"interlockedapi\":[],\"intsafe\":[],\"ioapiset\":[],\"ipexport\":[],\"iphlpapi\":[],\"ipifcons\":[],\"ipmib\":[],\"iprtrmib\":[],\"iptypes\":[],\"jobapi\":[],\"jobapi2\":[],\"knownfolders\":[],\"ks\":[],\"ksmedia\":[],\"ktmtypes\":[],\"ktmw32\":[],\"l2cmn\":[],\"libloaderapi\":[],\"limits\":[],\"lmaccess\":[],\"lmalert\":[],\"lmapibuf\":[],\"lmat\":[],\"lmcons\":[],\"lmdfs\":[],\"lmerrlog\":[],\"lmjoin\":[],\"lmmsg\":[],\"lmremutl\":[],\"lmrepl\":[],\"lmserver\":[],\"lmshare\":[],\"lmstats\":[],\"lmsvc\":[],\"lmuse\":[],\"lmwksta\":[],\"lowlevelmonitorconfigurationapi\":[],\"lsalookup\":[],\"memoryapi\":[],\"minschannel\":[],\"minwinbase\":[],\"minwindef\":[],\"mmdeviceapi\":[],\"mmeapi\":[],\"mmreg\":[],\"mmsystem\":[],\"mprapidef\":[],\"msaatext\":[],\"mscat\":[],\"mschapp\":[],\"mssip\":[],\"mstcpip\":[],\"mswsock\":[],\"mswsockdef\":[],\"namedpipeapi\":[],\"namespaceapi\":[],\"nb30\":[],\"ncrypt\":[],\"netioapi\":[],\"nldef\":[],\"ntddndis\":[],\"ntddscsi\":[],\"ntddser\":[],\"ntdef\":[],\"ntlsa\":[],\"ntsecapi\":[],\"ntstatus\":[],\"oaidl\":[],\"objbase\":[],\"objidl\":[],\"objidlbase\":[],\"ocidl\":[],\"ole2\":[],\"oleauto\":[],\"olectl\":[],\"oleidl\":[],\"opmapi\":[],\"pdh\":[],\"perflib\":[],\"physicalmonitorenumerationapi\":[],\"playsoundapi\":[],\"portabledevice\":[],\"portabledeviceapi\":[],\"portabledevicetypes\":[],\"powerbase\":[],\"powersetting\":[],\"powrprof\":[],\"processenv\":[],\"processsnapshot\":[],\"processthreadsapi\":[],\"processtopologyapi\":[],\"profileapi\":[],\"propidl\":[],\"propkey\":[],\"propkeydef\":[],\"propsys\":[],\"prsht\":[],\"psapi\":[],\"qos\":[],\"realtimeapiset\":[],\"reason\":[],\"restartmanager\":[],\"restrictederrorinfo\":[],\"rmxfguid\":[],\"roapi\":[],\"robuffer\":[],\"roerrorapi\":[],\"rpc\":[],\"rpcdce\":[],\"rpcndr\":[],\"rtinfo\":[],\"sapi\":[],\"sapi51\":[],\"sapi53\":[],\"sapiddk\":[],\"sapiddk51\":[],\"schannel\":[],\"sddl\":[],\"securityappcontainer\":[],\"securitybaseapi\":[],\"servprov\":[],\"setupapi\":[],\"shellapi\":[],\"shellscalingapi\":[],\"shlobj\":[],\"shobjidl\":[],\"shobjidl_core\":[],\"shtypes\":[],\"softpub\":[],\"spapidef\":[],\"spellcheck\":[],\"sporder\":[],\"sql\":[],\"sqlext\":[],\"sqltypes\":[],\"sqlucode\":[],\"sspi\":[],\"std\":[],\"stralign\":[],\"stringapiset\":[],\"strmif\":[],\"subauth\":[],\"synchapi\":[],\"sysinfoapi\":[],\"systemtopologyapi\":[],\"taskschd\":[],\"tcpestats\":[],\"tcpmib\":[],\"textstor\":[],\"threadpoolapiset\":[],\"threadpoollegacyapiset\":[],\"timeapi\":[],\"timezoneapi\":[],\"tlhelp32\":[],\"transportsettingcommon\":[],\"tvout\":[],\"udpmib\":[],\"unknwnbase\":[],\"urlhist\":[],\"urlmon\":[],\"usb\":[],\"usbioctl\":[],\"usbiodef\":[],\"usbscan\":[],\"usbspec\":[],\"userenv\":[],\"usp10\":[],\"utilapiset\":[],\"uxtheme\":[],\"vadefs\":[],\"vcruntime\":[],\"vsbackup\":[],\"vss\":[],\"vsserror\":[],\"vswriter\":[],\"wbemads\":[],\"wbemcli\":[],\"wbemdisp\":[],\"wbemprov\":[],\"wbemtran\":[],\"wct\":[],\"werapi\":[],\"winbase\":[],\"wincodec\":[],\"wincodecsdk\":[],\"wincon\":[],\"wincontypes\":[],\"wincred\":[],\"wincrypt\":[],\"windef\":[],\"windot11\":[],\"windowsceip\":[],\"windowsx\":[],\"winefs\":[],\"winerror\":[],\"winevt\":[],\"wingdi\":[],\"winhttp\":[],\"wininet\":[],\"winineti\":[],\"winioctl\":[],\"winnetwk\":[],\"winnls\":[],\"winnt\":[],\"winreg\":[],\"winsafer\":[],\"winscard\":[],\"winsmcrd\":[],\"winsock2\":[],\"winspool\":[],\"winstring\":[],\"winsvc\":[],\"wintrust\":[],\"winusb\":[],\"winusbio\":[],\"winuser\":[],\"winver\":[],\"wlanapi\":[],\"wlanihv\":[],\"wlanihvtypes\":[],\"wlantypes\":[],\"wlclient\":[],\"wmistr\":[],\"wnnc\":[],\"wow64apiset\":[],\"wpdmtpextensions\":[],\"ws2bth\":[],\"ws2def\":[],\"ws2ipdef\":[],\"ws2spi\":[],\"ws2tcpip\":[],\"wtsapi32\":[],\"wtypes\":[],\"wtypesbase\":[],\"xinput\":[]}}", - "windows-collections_0.3.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-core\",\"req\":\"^0.62.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"windows-strings\",\"req\":\"^0.5.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"windows-core/std\"]}}", - "windows-core_0.62.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-implement\",\"req\":\"^0.60.2\"},{\"default_features\":false,\"name\":\"windows-interface\",\"req\":\"^0.59.3\"},{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"windows-result\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"windows-strings\",\"req\":\"^0.5.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"windows-result/std\",\"windows-strings/std\"]}}", - "windows-future_0.3.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-core\",\"req\":\"^0.62.2\"},{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"windows-threading\",\"req\":\"^0.2.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"windows-core/std\"]}}", - "windows-implement_0.60.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\",\"printing\",\"full\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "windows-interface_0.59.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\",\"printing\",\"full\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "windows-link_0.2.1": "{\"dependencies\":[],\"features\":{}}", - "windows-numerics_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-core\",\"req\":\"^0.62.2\"},{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"windows-core/std\"]}}", - "windows-result_0.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "windows-strings_0.5.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "windows-sys_0.45.0": "{\"dependencies\":[{\"name\":\"windows-targets\",\"req\":\"^0.42.1\",\"target\":\"cfg(not(windows_raw_dylib))\"}],\"features\":{\"Win32\":[],\"Win32_Data\":[\"Win32\"],\"Win32_Data_HtmlHelp\":[\"Win32_Data\"],\"Win32_Data_RightsManagement\":[\"Win32_Data\"],\"Win32_Data_Xml\":[\"Win32_Data\"],\"Win32_Data_Xml_MsXml\":[\"Win32_Data_Xml\"],\"Win32_Data_Xml_XmlLite\":[\"Win32_Data_Xml\"],\"Win32_Devices\":[\"Win32\"],\"Win32_Devices_AllJoyn\":[\"Win32_Devices\"],\"Win32_Devices_BiometricFramework\":[\"Win32_Devices\"],\"Win32_Devices_Bluetooth\":[\"Win32_Devices\"],\"Win32_Devices_Communication\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAccess\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAndDriverInstallation\":[\"Win32_Devices\"],\"Win32_Devices_DeviceQuery\":[\"Win32_Devices\"],\"Win32_Devices_Display\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration_Pnp\":[\"Win32_Devices_Enumeration\"],\"Win32_Devices_Fax\":[\"Win32_Devices\"],\"Win32_Devices_FunctionDiscovery\":[\"Win32_Devices\"],\"Win32_Devices_Geolocation\":[\"Win32_Devices\"],\"Win32_Devices_HumanInterfaceDevice\":[\"Win32_Devices\"],\"Win32_Devices_ImageAcquisition\":[\"Win32_Devices\"],\"Win32_Devices_PortableDevices\":[\"Win32_Devices\"],\"Win32_Devices_Properties\":[\"Win32_Devices\"],\"Win32_Devices_Pwm\":[\"Win32_Devices\"],\"Win32_Devices_Sensors\":[\"Win32_Devices\"],\"Win32_Devices_SerialCommunication\":[\"Win32_Devices\"],\"Win32_Devices_Tapi\":[\"Win32_Devices\"],\"Win32_Devices_Usb\":[\"Win32_Devices\"],\"Win32_Devices_WebServicesOnDevices\":[\"Win32_Devices\"],\"Win32_Foundation\":[\"Win32\"],\"Win32_Gaming\":[\"Win32\"],\"Win32_Globalization\":[\"Win32\"],\"Win32_Graphics\":[\"Win32\"],\"Win32_Graphics_Dwm\":[\"Win32_Graphics\"],\"Win32_Graphics_Gdi\":[\"Win32_Graphics\"],\"Win32_Graphics_Hlsl\":[\"Win32_Graphics\"],\"Win32_Graphics_OpenGL\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing_PrintTicket\":[\"Win32_Graphics_Printing\"],\"Win32_Management\":[\"Win32\"],\"Win32_Management_MobileDeviceManagementRegistration\":[\"Win32_Management\"],\"Win32_Media\":[\"Win32\"],\"Win32_Media_Audio\":[\"Win32_Media\"],\"Win32_Media_Audio_Apo\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_DirectMusic\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_Endpoints\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_XAudio2\":[\"Win32_Media_Audio\"],\"Win32_Media_DeviceManager\":[\"Win32_Media\"],\"Win32_Media_DxMediaObjects\":[\"Win32_Media\"],\"Win32_Media_KernelStreaming\":[\"Win32_Media\"],\"Win32_Media_LibrarySharingServices\":[\"Win32_Media\"],\"Win32_Media_MediaPlayer\":[\"Win32_Media\"],\"Win32_Media_Multimedia\":[\"Win32_Media\"],\"Win32_Media_Speech\":[\"Win32_Media\"],\"Win32_Media_Streaming\":[\"Win32_Media\"],\"Win32_Media_WindowsMediaFormat\":[\"Win32_Media\"],\"Win32_NetworkManagement\":[\"Win32\"],\"Win32_NetworkManagement_Dhcp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Dns\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_InternetConnectionWizard\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_IpHelper\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_MobileBroadband\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Multicast\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Ndis\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetBios\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetManagement\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetShell\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkDiagnosticsFramework\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkPolicyServer\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_P2P\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_QoS\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Rras\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Snmp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WNet\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WebDav\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WiFi\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectNow\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectionManager\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFilteringPlatform\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFirewall\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsNetworkVirtualization\":[\"Win32_NetworkManagement\"],\"Win32_Networking\":[\"Win32\"],\"Win32_Networking_ActiveDirectory\":[\"Win32_Networking\"],\"Win32_Networking_BackgroundIntelligentTransferService\":[\"Win32_Networking\"],\"Win32_Networking_Clustering\":[\"Win32_Networking\"],\"Win32_Networking_HttpServer\":[\"Win32_Networking\"],\"Win32_Networking_Ldap\":[\"Win32_Networking\"],\"Win32_Networking_NetworkListManager\":[\"Win32_Networking\"],\"Win32_Networking_RemoteDifferentialCompression\":[\"Win32_Networking\"],\"Win32_Networking_WebSocket\":[\"Win32_Networking\"],\"Win32_Networking_WinHttp\":[\"Win32_Networking\"],\"Win32_Networking_WinInet\":[\"Win32_Networking\"],\"Win32_Networking_WinSock\":[\"Win32_Networking\"],\"Win32_Networking_WindowsWebServices\":[\"Win32_Networking\"],\"Win32_Security\":[\"Win32\"],\"Win32_Security_AppLocker\":[\"Win32_Security\"],\"Win32_Security_Authentication\":[\"Win32_Security\"],\"Win32_Security_Authentication_Identity\":[\"Win32_Security_Authentication\"],\"Win32_Security_Authentication_Identity_Provider\":[\"Win32_Security_Authentication_Identity\"],\"Win32_Security_Authorization\":[\"Win32_Security\"],\"Win32_Security_Authorization_UI\":[\"Win32_Security_Authorization\"],\"Win32_Security_ConfigurationSnapin\":[\"Win32_Security\"],\"Win32_Security_Credentials\":[\"Win32_Security\"],\"Win32_Security_Cryptography\":[\"Win32_Security\"],\"Win32_Security_Cryptography_Catalog\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Certificates\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Sip\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_UI\":[\"Win32_Security_Cryptography\"],\"Win32_Security_DiagnosticDataQuery\":[\"Win32_Security\"],\"Win32_Security_DirectoryServices\":[\"Win32_Security\"],\"Win32_Security_EnterpriseData\":[\"Win32_Security\"],\"Win32_Security_ExtensibleAuthenticationProtocol\":[\"Win32_Security\"],\"Win32_Security_Isolation\":[\"Win32_Security\"],\"Win32_Security_LicenseProtection\":[\"Win32_Security\"],\"Win32_Security_NetworkAccessProtection\":[\"Win32_Security\"],\"Win32_Security_Tpm\":[\"Win32_Security\"],\"Win32_Security_WinTrust\":[\"Win32_Security\"],\"Win32_Security_WinWlx\":[\"Win32_Security\"],\"Win32_Storage\":[\"Win32\"],\"Win32_Storage_Cabinets\":[\"Win32_Storage\"],\"Win32_Storage_CloudFilters\":[\"Win32_Storage\"],\"Win32_Storage_Compression\":[\"Win32_Storage\"],\"Win32_Storage_DataDeduplication\":[\"Win32_Storage\"],\"Win32_Storage_DistributedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_EnhancedStorage\":[\"Win32_Storage\"],\"Win32_Storage_FileHistory\":[\"Win32_Storage\"],\"Win32_Storage_FileServerResourceManager\":[\"Win32_Storage\"],\"Win32_Storage_FileSystem\":[\"Win32_Storage\"],\"Win32_Storage_Imapi\":[\"Win32_Storage\"],\"Win32_Storage_IndexServer\":[\"Win32_Storage\"],\"Win32_Storage_InstallableFileSystems\":[\"Win32_Storage\"],\"Win32_Storage_IscsiDisc\":[\"Win32_Storage\"],\"Win32_Storage_Jet\":[\"Win32_Storage\"],\"Win32_Storage_OfflineFiles\":[\"Win32_Storage\"],\"Win32_Storage_OperationRecorder\":[\"Win32_Storage\"],\"Win32_Storage_Packaging\":[\"Win32_Storage\"],\"Win32_Storage_Packaging_Appx\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_Packaging_Opc\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_ProjectedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_StructuredStorage\":[\"Win32_Storage\"],\"Win32_Storage_Vhd\":[\"Win32_Storage\"],\"Win32_Storage_VirtualDiskService\":[\"Win32_Storage\"],\"Win32_Storage_Vss\":[\"Win32_Storage\"],\"Win32_Storage_Xps\":[\"Win32_Storage\"],\"Win32_Storage_Xps_Printing\":[\"Win32_Storage_Xps\"],\"Win32_System\":[\"Win32\"],\"Win32_System_AddressBook\":[\"Win32_System\"],\"Win32_System_Antimalware\":[\"Win32_System\"],\"Win32_System_ApplicationInstallationAndServicing\":[\"Win32_System\"],\"Win32_System_ApplicationVerifier\":[\"Win32_System\"],\"Win32_System_AssessmentTool\":[\"Win32_System\"],\"Win32_System_Com\":[\"Win32_System\"],\"Win32_System_Com_CallObj\":[\"Win32_System_Com\"],\"Win32_System_Com_ChannelCredentials\":[\"Win32_System_Com\"],\"Win32_System_Com_Events\":[\"Win32_System_Com\"],\"Win32_System_Com_Marshal\":[\"Win32_System_Com\"],\"Win32_System_Com_StructuredStorage\":[\"Win32_System_Com\"],\"Win32_System_Com_UI\":[\"Win32_System_Com\"],\"Win32_System_Com_Urlmon\":[\"Win32_System_Com\"],\"Win32_System_ComponentServices\":[\"Win32_System\"],\"Win32_System_Console\":[\"Win32_System\"],\"Win32_System_Contacts\":[\"Win32_System\"],\"Win32_System_CorrelationVector\":[\"Win32_System\"],\"Win32_System_DataExchange\":[\"Win32_System\"],\"Win32_System_DeploymentServices\":[\"Win32_System\"],\"Win32_System_DesktopSharing\":[\"Win32_System\"],\"Win32_System_DeveloperLicensing\":[\"Win32_System\"],\"Win32_System_Diagnostics\":[\"Win32_System\"],\"Win32_System_Diagnostics_Ceip\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Etw\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ProcessSnapshotting\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ToolHelp\":[\"Win32_System_Diagnostics\"],\"Win32_System_DistributedTransactionCoordinator\":[\"Win32_System\"],\"Win32_System_Environment\":[\"Win32_System\"],\"Win32_System_ErrorReporting\":[\"Win32_System\"],\"Win32_System_EventCollector\":[\"Win32_System\"],\"Win32_System_EventLog\":[\"Win32_System\"],\"Win32_System_EventNotificationService\":[\"Win32_System\"],\"Win32_System_GroupPolicy\":[\"Win32_System\"],\"Win32_System_HostCompute\":[\"Win32_System\"],\"Win32_System_HostComputeNetwork\":[\"Win32_System\"],\"Win32_System_HostComputeSystem\":[\"Win32_System\"],\"Win32_System_Hypervisor\":[\"Win32_System\"],\"Win32_System_IO\":[\"Win32_System\"],\"Win32_System_Iis\":[\"Win32_System\"],\"Win32_System_Ioctl\":[\"Win32_System\"],\"Win32_System_JobObjects\":[\"Win32_System\"],\"Win32_System_Js\":[\"Win32_System\"],\"Win32_System_Kernel\":[\"Win32_System\"],\"Win32_System_LibraryLoader\":[\"Win32_System\"],\"Win32_System_Mailslots\":[\"Win32_System\"],\"Win32_System_Mapi\":[\"Win32_System\"],\"Win32_System_Memory\":[\"Win32_System\"],\"Win32_System_Memory_NonVolatile\":[\"Win32_System_Memory\"],\"Win32_System_MessageQueuing\":[\"Win32_System\"],\"Win32_System_MixedReality\":[\"Win32_System\"],\"Win32_System_Mmc\":[\"Win32_System\"],\"Win32_System_Ole\":[\"Win32_System\"],\"Win32_System_ParentalControls\":[\"Win32_System\"],\"Win32_System_PasswordManagement\":[\"Win32_System\"],\"Win32_System_Performance\":[\"Win32_System\"],\"Win32_System_Performance_HardwareCounterProfiling\":[\"Win32_System_Performance\"],\"Win32_System_Pipes\":[\"Win32_System\"],\"Win32_System_Power\":[\"Win32_System\"],\"Win32_System_ProcessStatus\":[\"Win32_System\"],\"Win32_System_RealTimeCommunications\":[\"Win32_System\"],\"Win32_System_Recovery\":[\"Win32_System\"],\"Win32_System_Registry\":[\"Win32_System\"],\"Win32_System_RemoteAssistance\":[\"Win32_System\"],\"Win32_System_RemoteDesktop\":[\"Win32_System\"],\"Win32_System_RemoteManagement\":[\"Win32_System\"],\"Win32_System_RestartManager\":[\"Win32_System\"],\"Win32_System_Restore\":[\"Win32_System\"],\"Win32_System_Rpc\":[\"Win32_System\"],\"Win32_System_Search\":[\"Win32_System\"],\"Win32_System_Search_Common\":[\"Win32_System_Search\"],\"Win32_System_SecurityCenter\":[\"Win32_System\"],\"Win32_System_ServerBackup\":[\"Win32_System\"],\"Win32_System_Services\":[\"Win32_System\"],\"Win32_System_SettingsManagementInfrastructure\":[\"Win32_System\"],\"Win32_System_SetupAndMigration\":[\"Win32_System\"],\"Win32_System_Shutdown\":[\"Win32_System\"],\"Win32_System_StationsAndDesktops\":[\"Win32_System\"],\"Win32_System_SubsystemForLinux\":[\"Win32_System\"],\"Win32_System_SystemInformation\":[\"Win32_System\"],\"Win32_System_SystemServices\":[\"Win32_System\"],\"Win32_System_TaskScheduler\":[\"Win32_System\"],\"Win32_System_Threading\":[\"Win32_System\"],\"Win32_System_Time\":[\"Win32_System\"],\"Win32_System_TpmBaseServices\":[\"Win32_System\"],\"Win32_System_UpdateAgent\":[\"Win32_System\"],\"Win32_System_UpdateAssessment\":[\"Win32_System\"],\"Win32_System_UserAccessLogging\":[\"Win32_System\"],\"Win32_System_VirtualDosMachines\":[\"Win32_System\"],\"Win32_System_WindowsProgramming\":[\"Win32_System\"],\"Win32_System_WindowsSync\":[\"Win32_System\"],\"Win32_System_Wmi\":[\"Win32_System\"],\"Win32_UI\":[\"Win32\"],\"Win32_UI_Accessibility\":[\"Win32_UI\"],\"Win32_UI_Animation\":[\"Win32_UI\"],\"Win32_UI_ColorSystem\":[\"Win32_UI\"],\"Win32_UI_Controls\":[\"Win32_UI\"],\"Win32_UI_Controls_Dialogs\":[\"Win32_UI_Controls\"],\"Win32_UI_Controls_RichEdit\":[\"Win32_UI_Controls\"],\"Win32_UI_HiDpi\":[\"Win32_UI\"],\"Win32_UI_Input\":[\"Win32_UI\"],\"Win32_UI_Input_Ime\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Ink\":[\"Win32_UI_Input\"],\"Win32_UI_Input_KeyboardAndMouse\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Pointer\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Radial\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Touch\":[\"Win32_UI_Input\"],\"Win32_UI_Input_XboxController\":[\"Win32_UI_Input\"],\"Win32_UI_InteractionContext\":[\"Win32_UI\"],\"Win32_UI_LegacyWindowsEnvironmentFeatures\":[\"Win32_UI\"],\"Win32_UI_Magnification\":[\"Win32_UI\"],\"Win32_UI_Notifications\":[\"Win32_UI\"],\"Win32_UI_Ribbon\":[\"Win32_UI\"],\"Win32_UI_Shell\":[\"Win32_UI\"],\"Win32_UI_Shell_Common\":[\"Win32_UI_Shell\"],\"Win32_UI_Shell_PropertiesSystem\":[\"Win32_UI_Shell\"],\"Win32_UI_TabletPC\":[\"Win32_UI\"],\"Win32_UI_TextServices\":[\"Win32_UI\"],\"Win32_UI_WindowsAndMessaging\":[\"Win32_UI\"],\"Win32_UI_Wpf\":[\"Win32_UI\"],\"default\":[]}}", - "windows-sys_0.48.0": "{\"dependencies\":[{\"name\":\"windows-targets\",\"req\":\"^0.48.0\"}],\"features\":{\"Wdk\":[],\"Wdk_System\":[\"Wdk\"],\"Wdk_System_OfflineRegistry\":[\"Wdk_System\"],\"Win32\":[],\"Win32_Data\":[\"Win32\"],\"Win32_Data_HtmlHelp\":[\"Win32_Data\"],\"Win32_Data_RightsManagement\":[\"Win32_Data\"],\"Win32_Data_Xml\":[\"Win32_Data\"],\"Win32_Data_Xml_MsXml\":[\"Win32_Data_Xml\"],\"Win32_Data_Xml_XmlLite\":[\"Win32_Data_Xml\"],\"Win32_Devices\":[\"Win32\"],\"Win32_Devices_AllJoyn\":[\"Win32_Devices\"],\"Win32_Devices_BiometricFramework\":[\"Win32_Devices\"],\"Win32_Devices_Bluetooth\":[\"Win32_Devices\"],\"Win32_Devices_Communication\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAccess\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAndDriverInstallation\":[\"Win32_Devices\"],\"Win32_Devices_DeviceQuery\":[\"Win32_Devices\"],\"Win32_Devices_Display\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration_Pnp\":[\"Win32_Devices_Enumeration\"],\"Win32_Devices_Fax\":[\"Win32_Devices\"],\"Win32_Devices_FunctionDiscovery\":[\"Win32_Devices\"],\"Win32_Devices_Geolocation\":[\"Win32_Devices\"],\"Win32_Devices_HumanInterfaceDevice\":[\"Win32_Devices\"],\"Win32_Devices_ImageAcquisition\":[\"Win32_Devices\"],\"Win32_Devices_PortableDevices\":[\"Win32_Devices\"],\"Win32_Devices_Properties\":[\"Win32_Devices\"],\"Win32_Devices_Pwm\":[\"Win32_Devices\"],\"Win32_Devices_Sensors\":[\"Win32_Devices\"],\"Win32_Devices_SerialCommunication\":[\"Win32_Devices\"],\"Win32_Devices_Tapi\":[\"Win32_Devices\"],\"Win32_Devices_Usb\":[\"Win32_Devices\"],\"Win32_Devices_WebServicesOnDevices\":[\"Win32_Devices\"],\"Win32_Foundation\":[\"Win32\"],\"Win32_Gaming\":[\"Win32\"],\"Win32_Globalization\":[\"Win32\"],\"Win32_Graphics\":[\"Win32\"],\"Win32_Graphics_Dwm\":[\"Win32_Graphics\"],\"Win32_Graphics_Gdi\":[\"Win32_Graphics\"],\"Win32_Graphics_Hlsl\":[\"Win32_Graphics\"],\"Win32_Graphics_OpenGL\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing_PrintTicket\":[\"Win32_Graphics_Printing\"],\"Win32_Management\":[\"Win32\"],\"Win32_Management_MobileDeviceManagementRegistration\":[\"Win32_Management\"],\"Win32_Media\":[\"Win32\"],\"Win32_Media_Audio\":[\"Win32_Media\"],\"Win32_Media_Audio_Apo\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_DirectMusic\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_Endpoints\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_XAudio2\":[\"Win32_Media_Audio\"],\"Win32_Media_DeviceManager\":[\"Win32_Media\"],\"Win32_Media_DxMediaObjects\":[\"Win32_Media\"],\"Win32_Media_KernelStreaming\":[\"Win32_Media\"],\"Win32_Media_LibrarySharingServices\":[\"Win32_Media\"],\"Win32_Media_MediaPlayer\":[\"Win32_Media\"],\"Win32_Media_Multimedia\":[\"Win32_Media\"],\"Win32_Media_Speech\":[\"Win32_Media\"],\"Win32_Media_Streaming\":[\"Win32_Media\"],\"Win32_Media_WindowsMediaFormat\":[\"Win32_Media\"],\"Win32_NetworkManagement\":[\"Win32\"],\"Win32_NetworkManagement_Dhcp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Dns\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_InternetConnectionWizard\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_IpHelper\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_MobileBroadband\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Multicast\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Ndis\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetBios\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetManagement\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetShell\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkDiagnosticsFramework\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkPolicyServer\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_P2P\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_QoS\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Rras\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Snmp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WNet\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WebDav\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WiFi\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectNow\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectionManager\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFilteringPlatform\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFirewall\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsNetworkVirtualization\":[\"Win32_NetworkManagement\"],\"Win32_Networking\":[\"Win32\"],\"Win32_Networking_ActiveDirectory\":[\"Win32_Networking\"],\"Win32_Networking_BackgroundIntelligentTransferService\":[\"Win32_Networking\"],\"Win32_Networking_Clustering\":[\"Win32_Networking\"],\"Win32_Networking_HttpServer\":[\"Win32_Networking\"],\"Win32_Networking_Ldap\":[\"Win32_Networking\"],\"Win32_Networking_NetworkListManager\":[\"Win32_Networking\"],\"Win32_Networking_RemoteDifferentialCompression\":[\"Win32_Networking\"],\"Win32_Networking_WebSocket\":[\"Win32_Networking\"],\"Win32_Networking_WinHttp\":[\"Win32_Networking\"],\"Win32_Networking_WinInet\":[\"Win32_Networking\"],\"Win32_Networking_WinSock\":[\"Win32_Networking\"],\"Win32_Networking_WindowsWebServices\":[\"Win32_Networking\"],\"Win32_Security\":[\"Win32\"],\"Win32_Security_AppLocker\":[\"Win32_Security\"],\"Win32_Security_Authentication\":[\"Win32_Security\"],\"Win32_Security_Authentication_Identity\":[\"Win32_Security_Authentication\"],\"Win32_Security_Authentication_Identity_Provider\":[\"Win32_Security_Authentication_Identity\"],\"Win32_Security_Authorization\":[\"Win32_Security\"],\"Win32_Security_Authorization_UI\":[\"Win32_Security_Authorization\"],\"Win32_Security_ConfigurationSnapin\":[\"Win32_Security\"],\"Win32_Security_Credentials\":[\"Win32_Security\"],\"Win32_Security_Cryptography\":[\"Win32_Security\"],\"Win32_Security_Cryptography_Catalog\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Certificates\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Sip\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_UI\":[\"Win32_Security_Cryptography\"],\"Win32_Security_DiagnosticDataQuery\":[\"Win32_Security\"],\"Win32_Security_DirectoryServices\":[\"Win32_Security\"],\"Win32_Security_EnterpriseData\":[\"Win32_Security\"],\"Win32_Security_ExtensibleAuthenticationProtocol\":[\"Win32_Security\"],\"Win32_Security_Isolation\":[\"Win32_Security\"],\"Win32_Security_LicenseProtection\":[\"Win32_Security\"],\"Win32_Security_NetworkAccessProtection\":[\"Win32_Security\"],\"Win32_Security_Tpm\":[\"Win32_Security\"],\"Win32_Security_WinTrust\":[\"Win32_Security\"],\"Win32_Security_WinWlx\":[\"Win32_Security\"],\"Win32_Storage\":[\"Win32\"],\"Win32_Storage_Cabinets\":[\"Win32_Storage\"],\"Win32_Storage_CloudFilters\":[\"Win32_Storage\"],\"Win32_Storage_Compression\":[\"Win32_Storage\"],\"Win32_Storage_DataDeduplication\":[\"Win32_Storage\"],\"Win32_Storage_DistributedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_EnhancedStorage\":[\"Win32_Storage\"],\"Win32_Storage_FileHistory\":[\"Win32_Storage\"],\"Win32_Storage_FileServerResourceManager\":[\"Win32_Storage\"],\"Win32_Storage_FileSystem\":[\"Win32_Storage\"],\"Win32_Storage_Imapi\":[\"Win32_Storage\"],\"Win32_Storage_IndexServer\":[\"Win32_Storage\"],\"Win32_Storage_InstallableFileSystems\":[\"Win32_Storage\"],\"Win32_Storage_IscsiDisc\":[\"Win32_Storage\"],\"Win32_Storage_Jet\":[\"Win32_Storage\"],\"Win32_Storage_OfflineFiles\":[\"Win32_Storage\"],\"Win32_Storage_OperationRecorder\":[\"Win32_Storage\"],\"Win32_Storage_Packaging\":[\"Win32_Storage\"],\"Win32_Storage_Packaging_Appx\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_Packaging_Opc\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_ProjectedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_StructuredStorage\":[\"Win32_Storage\"],\"Win32_Storage_Vhd\":[\"Win32_Storage\"],\"Win32_Storage_VirtualDiskService\":[\"Win32_Storage\"],\"Win32_Storage_Vss\":[\"Win32_Storage\"],\"Win32_Storage_Xps\":[\"Win32_Storage\"],\"Win32_Storage_Xps_Printing\":[\"Win32_Storage_Xps\"],\"Win32_System\":[\"Win32\"],\"Win32_System_AddressBook\":[\"Win32_System\"],\"Win32_System_Antimalware\":[\"Win32_System\"],\"Win32_System_ApplicationInstallationAndServicing\":[\"Win32_System\"],\"Win32_System_ApplicationVerifier\":[\"Win32_System\"],\"Win32_System_AssessmentTool\":[\"Win32_System\"],\"Win32_System_ClrHosting\":[\"Win32_System\"],\"Win32_System_Com\":[\"Win32_System\"],\"Win32_System_Com_CallObj\":[\"Win32_System_Com\"],\"Win32_System_Com_ChannelCredentials\":[\"Win32_System_Com\"],\"Win32_System_Com_Events\":[\"Win32_System_Com\"],\"Win32_System_Com_Marshal\":[\"Win32_System_Com\"],\"Win32_System_Com_StructuredStorage\":[\"Win32_System_Com\"],\"Win32_System_Com_UI\":[\"Win32_System_Com\"],\"Win32_System_Com_Urlmon\":[\"Win32_System_Com\"],\"Win32_System_ComponentServices\":[\"Win32_System\"],\"Win32_System_Console\":[\"Win32_System\"],\"Win32_System_Contacts\":[\"Win32_System\"],\"Win32_System_CorrelationVector\":[\"Win32_System\"],\"Win32_System_DataExchange\":[\"Win32_System\"],\"Win32_System_DeploymentServices\":[\"Win32_System\"],\"Win32_System_DesktopSharing\":[\"Win32_System\"],\"Win32_System_DeveloperLicensing\":[\"Win32_System\"],\"Win32_System_Diagnostics\":[\"Win32_System\"],\"Win32_System_Diagnostics_Ceip\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ClrProfiling\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug_ActiveScript\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Debug_Extensions\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Etw\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ProcessSnapshotting\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ToolHelp\":[\"Win32_System_Diagnostics\"],\"Win32_System_DistributedTransactionCoordinator\":[\"Win32_System\"],\"Win32_System_Environment\":[\"Win32_System\"],\"Win32_System_ErrorReporting\":[\"Win32_System\"],\"Win32_System_EventCollector\":[\"Win32_System\"],\"Win32_System_EventLog\":[\"Win32_System\"],\"Win32_System_EventNotificationService\":[\"Win32_System\"],\"Win32_System_GroupPolicy\":[\"Win32_System\"],\"Win32_System_HostCompute\":[\"Win32_System\"],\"Win32_System_HostComputeNetwork\":[\"Win32_System\"],\"Win32_System_HostComputeSystem\":[\"Win32_System\"],\"Win32_System_Hypervisor\":[\"Win32_System\"],\"Win32_System_IO\":[\"Win32_System\"],\"Win32_System_Iis\":[\"Win32_System\"],\"Win32_System_Ioctl\":[\"Win32_System\"],\"Win32_System_JobObjects\":[\"Win32_System\"],\"Win32_System_Js\":[\"Win32_System\"],\"Win32_System_Kernel\":[\"Win32_System\"],\"Win32_System_LibraryLoader\":[\"Win32_System\"],\"Win32_System_Mailslots\":[\"Win32_System\"],\"Win32_System_Mapi\":[\"Win32_System\"],\"Win32_System_Memory\":[\"Win32_System\"],\"Win32_System_Memory_NonVolatile\":[\"Win32_System_Memory\"],\"Win32_System_MessageQueuing\":[\"Win32_System\"],\"Win32_System_MixedReality\":[\"Win32_System\"],\"Win32_System_Mmc\":[\"Win32_System\"],\"Win32_System_Ole\":[\"Win32_System\"],\"Win32_System_ParentalControls\":[\"Win32_System\"],\"Win32_System_PasswordManagement\":[\"Win32_System\"],\"Win32_System_Performance\":[\"Win32_System\"],\"Win32_System_Performance_HardwareCounterProfiling\":[\"Win32_System_Performance\"],\"Win32_System_Pipes\":[\"Win32_System\"],\"Win32_System_Power\":[\"Win32_System\"],\"Win32_System_ProcessStatus\":[\"Win32_System\"],\"Win32_System_RealTimeCommunications\":[\"Win32_System\"],\"Win32_System_Recovery\":[\"Win32_System\"],\"Win32_System_Registry\":[\"Win32_System\"],\"Win32_System_RemoteAssistance\":[\"Win32_System\"],\"Win32_System_RemoteDesktop\":[\"Win32_System\"],\"Win32_System_RemoteManagement\":[\"Win32_System\"],\"Win32_System_RestartManager\":[\"Win32_System\"],\"Win32_System_Restore\":[\"Win32_System\"],\"Win32_System_Rpc\":[\"Win32_System\"],\"Win32_System_Search\":[\"Win32_System\"],\"Win32_System_Search_Common\":[\"Win32_System_Search\"],\"Win32_System_SecurityCenter\":[\"Win32_System\"],\"Win32_System_ServerBackup\":[\"Win32_System\"],\"Win32_System_Services\":[\"Win32_System\"],\"Win32_System_SettingsManagementInfrastructure\":[\"Win32_System\"],\"Win32_System_SetupAndMigration\":[\"Win32_System\"],\"Win32_System_Shutdown\":[\"Win32_System\"],\"Win32_System_StationsAndDesktops\":[\"Win32_System\"],\"Win32_System_SubsystemForLinux\":[\"Win32_System\"],\"Win32_System_SystemInformation\":[\"Win32_System\"],\"Win32_System_SystemServices\":[\"Win32_System\"],\"Win32_System_TaskScheduler\":[\"Win32_System\"],\"Win32_System_Threading\":[\"Win32_System\"],\"Win32_System_Time\":[\"Win32_System\"],\"Win32_System_TpmBaseServices\":[\"Win32_System\"],\"Win32_System_UpdateAgent\":[\"Win32_System\"],\"Win32_System_UpdateAssessment\":[\"Win32_System\"],\"Win32_System_UserAccessLogging\":[\"Win32_System\"],\"Win32_System_VirtualDosMachines\":[\"Win32_System\"],\"Win32_System_WindowsProgramming\":[\"Win32_System\"],\"Win32_System_WindowsSync\":[\"Win32_System\"],\"Win32_System_Wmi\":[\"Win32_System\"],\"Win32_UI\":[\"Win32\"],\"Win32_UI_Accessibility\":[\"Win32_UI\"],\"Win32_UI_Animation\":[\"Win32_UI\"],\"Win32_UI_ColorSystem\":[\"Win32_UI\"],\"Win32_UI_Controls\":[\"Win32_UI\"],\"Win32_UI_Controls_Dialogs\":[\"Win32_UI_Controls\"],\"Win32_UI_Controls_RichEdit\":[\"Win32_UI_Controls\"],\"Win32_UI_HiDpi\":[\"Win32_UI\"],\"Win32_UI_Input\":[\"Win32_UI\"],\"Win32_UI_Input_Ime\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Ink\":[\"Win32_UI_Input\"],\"Win32_UI_Input_KeyboardAndMouse\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Pointer\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Radial\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Touch\":[\"Win32_UI_Input\"],\"Win32_UI_Input_XboxController\":[\"Win32_UI_Input\"],\"Win32_UI_InteractionContext\":[\"Win32_UI\"],\"Win32_UI_LegacyWindowsEnvironmentFeatures\":[\"Win32_UI\"],\"Win32_UI_Magnification\":[\"Win32_UI\"],\"Win32_UI_Notifications\":[\"Win32_UI\"],\"Win32_UI_Ribbon\":[\"Win32_UI\"],\"Win32_UI_Shell\":[\"Win32_UI\"],\"Win32_UI_Shell_Common\":[\"Win32_UI_Shell\"],\"Win32_UI_Shell_PropertiesSystem\":[\"Win32_UI_Shell\"],\"Win32_UI_TabletPC\":[\"Win32_UI\"],\"Win32_UI_TextServices\":[\"Win32_UI\"],\"Win32_UI_WindowsAndMessaging\":[\"Win32_UI\"],\"Win32_UI_Wpf\":[\"Win32_UI\"],\"Win32_Web\":[\"Win32\"],\"Win32_Web_InternetExplorer\":[\"Win32_Web\"],\"default\":[]}}", - "windows-sys_0.52.0": "{\"dependencies\":[{\"name\":\"windows-targets\",\"req\":\"^0.52.0\"}],\"features\":{\"Wdk\":[],\"Wdk_Foundation\":[\"Wdk\"],\"Wdk_Graphics\":[\"Wdk\"],\"Wdk_Graphics_Direct3D\":[\"Wdk_Graphics\"],\"Wdk_Storage\":[\"Wdk\"],\"Wdk_Storage_FileSystem\":[\"Wdk_Storage\"],\"Wdk_Storage_FileSystem_Minifilters\":[\"Wdk_Storage_FileSystem\"],\"Wdk_System\":[\"Wdk\"],\"Wdk_System_IO\":[\"Wdk_System\"],\"Wdk_System_OfflineRegistry\":[\"Wdk_System\"],\"Wdk_System_Registry\":[\"Wdk_System\"],\"Wdk_System_SystemInformation\":[\"Wdk_System\"],\"Wdk_System_SystemServices\":[\"Wdk_System\"],\"Wdk_System_Threading\":[\"Wdk_System\"],\"Win32\":[],\"Win32_Data\":[\"Win32\"],\"Win32_Data_HtmlHelp\":[\"Win32_Data\"],\"Win32_Data_RightsManagement\":[\"Win32_Data\"],\"Win32_Devices\":[\"Win32\"],\"Win32_Devices_AllJoyn\":[\"Win32_Devices\"],\"Win32_Devices_BiometricFramework\":[\"Win32_Devices\"],\"Win32_Devices_Bluetooth\":[\"Win32_Devices\"],\"Win32_Devices_Communication\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAndDriverInstallation\":[\"Win32_Devices\"],\"Win32_Devices_DeviceQuery\":[\"Win32_Devices\"],\"Win32_Devices_Display\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration_Pnp\":[\"Win32_Devices_Enumeration\"],\"Win32_Devices_Fax\":[\"Win32_Devices\"],\"Win32_Devices_HumanInterfaceDevice\":[\"Win32_Devices\"],\"Win32_Devices_PortableDevices\":[\"Win32_Devices\"],\"Win32_Devices_Properties\":[\"Win32_Devices\"],\"Win32_Devices_Pwm\":[\"Win32_Devices\"],\"Win32_Devices_Sensors\":[\"Win32_Devices\"],\"Win32_Devices_SerialCommunication\":[\"Win32_Devices\"],\"Win32_Devices_Tapi\":[\"Win32_Devices\"],\"Win32_Devices_Usb\":[\"Win32_Devices\"],\"Win32_Devices_WebServicesOnDevices\":[\"Win32_Devices\"],\"Win32_Foundation\":[\"Win32\"],\"Win32_Gaming\":[\"Win32\"],\"Win32_Globalization\":[\"Win32\"],\"Win32_Graphics\":[\"Win32\"],\"Win32_Graphics_Dwm\":[\"Win32_Graphics\"],\"Win32_Graphics_Gdi\":[\"Win32_Graphics\"],\"Win32_Graphics_GdiPlus\":[\"Win32_Graphics\"],\"Win32_Graphics_Hlsl\":[\"Win32_Graphics\"],\"Win32_Graphics_OpenGL\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing_PrintTicket\":[\"Win32_Graphics_Printing\"],\"Win32_Management\":[\"Win32\"],\"Win32_Management_MobileDeviceManagementRegistration\":[\"Win32_Management\"],\"Win32_Media\":[\"Win32\"],\"Win32_Media_Audio\":[\"Win32_Media\"],\"Win32_Media_DxMediaObjects\":[\"Win32_Media\"],\"Win32_Media_KernelStreaming\":[\"Win32_Media\"],\"Win32_Media_Multimedia\":[\"Win32_Media\"],\"Win32_Media_Streaming\":[\"Win32_Media\"],\"Win32_Media_WindowsMediaFormat\":[\"Win32_Media\"],\"Win32_NetworkManagement\":[\"Win32\"],\"Win32_NetworkManagement_Dhcp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Dns\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_InternetConnectionWizard\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_IpHelper\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Multicast\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Ndis\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetBios\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetManagement\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetShell\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkDiagnosticsFramework\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_P2P\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_QoS\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Rras\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Snmp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WNet\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WebDav\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WiFi\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectionManager\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFilteringPlatform\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFirewall\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsNetworkVirtualization\":[\"Win32_NetworkManagement\"],\"Win32_Networking\":[\"Win32\"],\"Win32_Networking_ActiveDirectory\":[\"Win32_Networking\"],\"Win32_Networking_Clustering\":[\"Win32_Networking\"],\"Win32_Networking_HttpServer\":[\"Win32_Networking\"],\"Win32_Networking_Ldap\":[\"Win32_Networking\"],\"Win32_Networking_WebSocket\":[\"Win32_Networking\"],\"Win32_Networking_WinHttp\":[\"Win32_Networking\"],\"Win32_Networking_WinInet\":[\"Win32_Networking\"],\"Win32_Networking_WinSock\":[\"Win32_Networking\"],\"Win32_Networking_WindowsWebServices\":[\"Win32_Networking\"],\"Win32_Security\":[\"Win32\"],\"Win32_Security_AppLocker\":[\"Win32_Security\"],\"Win32_Security_Authentication\":[\"Win32_Security\"],\"Win32_Security_Authentication_Identity\":[\"Win32_Security_Authentication\"],\"Win32_Security_Authorization\":[\"Win32_Security\"],\"Win32_Security_Credentials\":[\"Win32_Security\"],\"Win32_Security_Cryptography\":[\"Win32_Security\"],\"Win32_Security_Cryptography_Catalog\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Certificates\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Sip\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_UI\":[\"Win32_Security_Cryptography\"],\"Win32_Security_DiagnosticDataQuery\":[\"Win32_Security\"],\"Win32_Security_DirectoryServices\":[\"Win32_Security\"],\"Win32_Security_EnterpriseData\":[\"Win32_Security\"],\"Win32_Security_ExtensibleAuthenticationProtocol\":[\"Win32_Security\"],\"Win32_Security_Isolation\":[\"Win32_Security\"],\"Win32_Security_LicenseProtection\":[\"Win32_Security\"],\"Win32_Security_NetworkAccessProtection\":[\"Win32_Security\"],\"Win32_Security_WinTrust\":[\"Win32_Security\"],\"Win32_Security_WinWlx\":[\"Win32_Security\"],\"Win32_Storage\":[\"Win32\"],\"Win32_Storage_Cabinets\":[\"Win32_Storage\"],\"Win32_Storage_CloudFilters\":[\"Win32_Storage\"],\"Win32_Storage_Compression\":[\"Win32_Storage\"],\"Win32_Storage_DistributedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_FileHistory\":[\"Win32_Storage\"],\"Win32_Storage_FileSystem\":[\"Win32_Storage\"],\"Win32_Storage_Imapi\":[\"Win32_Storage\"],\"Win32_Storage_IndexServer\":[\"Win32_Storage\"],\"Win32_Storage_InstallableFileSystems\":[\"Win32_Storage\"],\"Win32_Storage_IscsiDisc\":[\"Win32_Storage\"],\"Win32_Storage_Jet\":[\"Win32_Storage\"],\"Win32_Storage_Nvme\":[\"Win32_Storage\"],\"Win32_Storage_OfflineFiles\":[\"Win32_Storage\"],\"Win32_Storage_OperationRecorder\":[\"Win32_Storage\"],\"Win32_Storage_Packaging\":[\"Win32_Storage\"],\"Win32_Storage_Packaging_Appx\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_ProjectedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_StructuredStorage\":[\"Win32_Storage\"],\"Win32_Storage_Vhd\":[\"Win32_Storage\"],\"Win32_Storage_Xps\":[\"Win32_Storage\"],\"Win32_System\":[\"Win32\"],\"Win32_System_AddressBook\":[\"Win32_System\"],\"Win32_System_Antimalware\":[\"Win32_System\"],\"Win32_System_ApplicationInstallationAndServicing\":[\"Win32_System\"],\"Win32_System_ApplicationVerifier\":[\"Win32_System\"],\"Win32_System_ClrHosting\":[\"Win32_System\"],\"Win32_System_Com\":[\"Win32_System\"],\"Win32_System_Com_Marshal\":[\"Win32_System_Com\"],\"Win32_System_Com_StructuredStorage\":[\"Win32_System_Com\"],\"Win32_System_Com_Urlmon\":[\"Win32_System_Com\"],\"Win32_System_ComponentServices\":[\"Win32_System\"],\"Win32_System_Console\":[\"Win32_System\"],\"Win32_System_CorrelationVector\":[\"Win32_System\"],\"Win32_System_DataExchange\":[\"Win32_System\"],\"Win32_System_DeploymentServices\":[\"Win32_System\"],\"Win32_System_DeveloperLicensing\":[\"Win32_System\"],\"Win32_System_Diagnostics\":[\"Win32_System\"],\"Win32_System_Diagnostics_Ceip\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug_Extensions\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Etw\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ProcessSnapshotting\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ToolHelp\":[\"Win32_System_Diagnostics\"],\"Win32_System_DistributedTransactionCoordinator\":[\"Win32_System\"],\"Win32_System_Environment\":[\"Win32_System\"],\"Win32_System_ErrorReporting\":[\"Win32_System\"],\"Win32_System_EventCollector\":[\"Win32_System\"],\"Win32_System_EventLog\":[\"Win32_System\"],\"Win32_System_EventNotificationService\":[\"Win32_System\"],\"Win32_System_GroupPolicy\":[\"Win32_System\"],\"Win32_System_HostCompute\":[\"Win32_System\"],\"Win32_System_HostComputeNetwork\":[\"Win32_System\"],\"Win32_System_HostComputeSystem\":[\"Win32_System\"],\"Win32_System_Hypervisor\":[\"Win32_System\"],\"Win32_System_IO\":[\"Win32_System\"],\"Win32_System_Iis\":[\"Win32_System\"],\"Win32_System_Ioctl\":[\"Win32_System\"],\"Win32_System_JobObjects\":[\"Win32_System\"],\"Win32_System_Js\":[\"Win32_System\"],\"Win32_System_Kernel\":[\"Win32_System\"],\"Win32_System_LibraryLoader\":[\"Win32_System\"],\"Win32_System_Mailslots\":[\"Win32_System\"],\"Win32_System_Mapi\":[\"Win32_System\"],\"Win32_System_Memory\":[\"Win32_System\"],\"Win32_System_Memory_NonVolatile\":[\"Win32_System_Memory\"],\"Win32_System_MessageQueuing\":[\"Win32_System\"],\"Win32_System_MixedReality\":[\"Win32_System\"],\"Win32_System_Ole\":[\"Win32_System\"],\"Win32_System_PasswordManagement\":[\"Win32_System\"],\"Win32_System_Performance\":[\"Win32_System\"],\"Win32_System_Performance_HardwareCounterProfiling\":[\"Win32_System_Performance\"],\"Win32_System_Pipes\":[\"Win32_System\"],\"Win32_System_Power\":[\"Win32_System\"],\"Win32_System_ProcessStatus\":[\"Win32_System\"],\"Win32_System_Recovery\":[\"Win32_System\"],\"Win32_System_Registry\":[\"Win32_System\"],\"Win32_System_RemoteDesktop\":[\"Win32_System\"],\"Win32_System_RemoteManagement\":[\"Win32_System\"],\"Win32_System_RestartManager\":[\"Win32_System\"],\"Win32_System_Restore\":[\"Win32_System\"],\"Win32_System_Rpc\":[\"Win32_System\"],\"Win32_System_Search\":[\"Win32_System\"],\"Win32_System_Search_Common\":[\"Win32_System_Search\"],\"Win32_System_SecurityCenter\":[\"Win32_System\"],\"Win32_System_Services\":[\"Win32_System\"],\"Win32_System_SetupAndMigration\":[\"Win32_System\"],\"Win32_System_Shutdown\":[\"Win32_System\"],\"Win32_System_StationsAndDesktops\":[\"Win32_System\"],\"Win32_System_SubsystemForLinux\":[\"Win32_System\"],\"Win32_System_SystemInformation\":[\"Win32_System\"],\"Win32_System_SystemServices\":[\"Win32_System\"],\"Win32_System_Threading\":[\"Win32_System\"],\"Win32_System_Time\":[\"Win32_System\"],\"Win32_System_TpmBaseServices\":[\"Win32_System\"],\"Win32_System_UserAccessLogging\":[\"Win32_System\"],\"Win32_System_Variant\":[\"Win32_System\"],\"Win32_System_VirtualDosMachines\":[\"Win32_System\"],\"Win32_System_WindowsProgramming\":[\"Win32_System\"],\"Win32_System_Wmi\":[\"Win32_System\"],\"Win32_UI\":[\"Win32\"],\"Win32_UI_Accessibility\":[\"Win32_UI\"],\"Win32_UI_ColorSystem\":[\"Win32_UI\"],\"Win32_UI_Controls\":[\"Win32_UI\"],\"Win32_UI_Controls_Dialogs\":[\"Win32_UI_Controls\"],\"Win32_UI_HiDpi\":[\"Win32_UI\"],\"Win32_UI_Input\":[\"Win32_UI\"],\"Win32_UI_Input_Ime\":[\"Win32_UI_Input\"],\"Win32_UI_Input_KeyboardAndMouse\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Pointer\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Touch\":[\"Win32_UI_Input\"],\"Win32_UI_Input_XboxController\":[\"Win32_UI_Input\"],\"Win32_UI_InteractionContext\":[\"Win32_UI\"],\"Win32_UI_Magnification\":[\"Win32_UI\"],\"Win32_UI_Shell\":[\"Win32_UI\"],\"Win32_UI_Shell_PropertiesSystem\":[\"Win32_UI_Shell\"],\"Win32_UI_TabletPC\":[\"Win32_UI\"],\"Win32_UI_TextServices\":[\"Win32_UI\"],\"Win32_UI_WindowsAndMessaging\":[\"Win32_UI\"],\"Win32_Web\":[\"Win32\"],\"Win32_Web_InternetExplorer\":[\"Win32_Web\"],\"default\":[],\"docs\":[]}}", - "windows-sys_0.59.0": "{\"dependencies\":[{\"name\":\"windows-targets\",\"req\":\"^0.52.6\"}],\"features\":{\"Wdk\":[\"Win32_Foundation\"],\"Wdk_Devices\":[\"Wdk\"],\"Wdk_Devices_Bluetooth\":[\"Wdk_Devices\"],\"Wdk_Devices_HumanInterfaceDevice\":[\"Wdk_Devices\"],\"Wdk_Foundation\":[\"Wdk\"],\"Wdk_Graphics\":[\"Wdk\"],\"Wdk_Graphics_Direct3D\":[\"Wdk_Graphics\"],\"Wdk_NetworkManagement\":[\"Wdk\"],\"Wdk_NetworkManagement_Ndis\":[\"Wdk_NetworkManagement\"],\"Wdk_NetworkManagement_WindowsFilteringPlatform\":[\"Wdk_NetworkManagement\"],\"Wdk_Storage\":[\"Wdk\"],\"Wdk_Storage_FileSystem\":[\"Wdk_Storage\"],\"Wdk_Storage_FileSystem_Minifilters\":[\"Wdk_Storage_FileSystem\"],\"Wdk_System\":[\"Wdk\"],\"Wdk_System_IO\":[\"Wdk_System\"],\"Wdk_System_Memory\":[\"Wdk_System\"],\"Wdk_System_OfflineRegistry\":[\"Wdk_System\"],\"Wdk_System_Registry\":[\"Wdk_System\"],\"Wdk_System_SystemInformation\":[\"Wdk_System\"],\"Wdk_System_SystemServices\":[\"Wdk_System\"],\"Wdk_System_Threading\":[\"Wdk_System\"],\"Win32\":[\"Win32_Foundation\"],\"Win32_Data\":[\"Win32\"],\"Win32_Data_HtmlHelp\":[\"Win32_Data\"],\"Win32_Data_RightsManagement\":[\"Win32_Data\"],\"Win32_Devices\":[\"Win32\"],\"Win32_Devices_AllJoyn\":[\"Win32_Devices\"],\"Win32_Devices_BiometricFramework\":[\"Win32_Devices\"],\"Win32_Devices_Bluetooth\":[\"Win32_Devices\"],\"Win32_Devices_Communication\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAndDriverInstallation\":[\"Win32_Devices\"],\"Win32_Devices_DeviceQuery\":[\"Win32_Devices\"],\"Win32_Devices_Display\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration_Pnp\":[\"Win32_Devices_Enumeration\"],\"Win32_Devices_Fax\":[\"Win32_Devices\"],\"Win32_Devices_HumanInterfaceDevice\":[\"Win32_Devices\"],\"Win32_Devices_PortableDevices\":[\"Win32_Devices\"],\"Win32_Devices_Properties\":[\"Win32_Devices\"],\"Win32_Devices_Pwm\":[\"Win32_Devices\"],\"Win32_Devices_Sensors\":[\"Win32_Devices\"],\"Win32_Devices_SerialCommunication\":[\"Win32_Devices\"],\"Win32_Devices_Tapi\":[\"Win32_Devices\"],\"Win32_Devices_Usb\":[\"Win32_Devices\"],\"Win32_Devices_WebServicesOnDevices\":[\"Win32_Devices\"],\"Win32_Foundation\":[\"Win32\"],\"Win32_Gaming\":[\"Win32\"],\"Win32_Globalization\":[\"Win32\"],\"Win32_Graphics\":[\"Win32\"],\"Win32_Graphics_Dwm\":[\"Win32_Graphics\"],\"Win32_Graphics_Gdi\":[\"Win32_Graphics\"],\"Win32_Graphics_GdiPlus\":[\"Win32_Graphics\"],\"Win32_Graphics_Hlsl\":[\"Win32_Graphics\"],\"Win32_Graphics_OpenGL\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing_PrintTicket\":[\"Win32_Graphics_Printing\"],\"Win32_Management\":[\"Win32\"],\"Win32_Management_MobileDeviceManagementRegistration\":[\"Win32_Management\"],\"Win32_Media\":[\"Win32\"],\"Win32_Media_Audio\":[\"Win32_Media\"],\"Win32_Media_DxMediaObjects\":[\"Win32_Media\"],\"Win32_Media_KernelStreaming\":[\"Win32_Media\"],\"Win32_Media_Multimedia\":[\"Win32_Media\"],\"Win32_Media_Streaming\":[\"Win32_Media\"],\"Win32_Media_WindowsMediaFormat\":[\"Win32_Media\"],\"Win32_NetworkManagement\":[\"Win32\"],\"Win32_NetworkManagement_Dhcp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Dns\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_InternetConnectionWizard\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_IpHelper\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Multicast\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Ndis\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetBios\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetManagement\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetShell\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkDiagnosticsFramework\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_P2P\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_QoS\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Rras\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Snmp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WNet\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WebDav\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WiFi\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectionManager\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFilteringPlatform\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFirewall\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsNetworkVirtualization\":[\"Win32_NetworkManagement\"],\"Win32_Networking\":[\"Win32\"],\"Win32_Networking_ActiveDirectory\":[\"Win32_Networking\"],\"Win32_Networking_Clustering\":[\"Win32_Networking\"],\"Win32_Networking_HttpServer\":[\"Win32_Networking\"],\"Win32_Networking_Ldap\":[\"Win32_Networking\"],\"Win32_Networking_WebSocket\":[\"Win32_Networking\"],\"Win32_Networking_WinHttp\":[\"Win32_Networking\"],\"Win32_Networking_WinInet\":[\"Win32_Networking\"],\"Win32_Networking_WinSock\":[\"Win32_Networking\"],\"Win32_Networking_WindowsWebServices\":[\"Win32_Networking\"],\"Win32_Security\":[\"Win32\"],\"Win32_Security_AppLocker\":[\"Win32_Security\"],\"Win32_Security_Authentication\":[\"Win32_Security\"],\"Win32_Security_Authentication_Identity\":[\"Win32_Security_Authentication\"],\"Win32_Security_Authorization\":[\"Win32_Security\"],\"Win32_Security_Credentials\":[\"Win32_Security\"],\"Win32_Security_Cryptography\":[\"Win32_Security\"],\"Win32_Security_Cryptography_Catalog\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Certificates\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Sip\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_UI\":[\"Win32_Security_Cryptography\"],\"Win32_Security_DiagnosticDataQuery\":[\"Win32_Security\"],\"Win32_Security_DirectoryServices\":[\"Win32_Security\"],\"Win32_Security_EnterpriseData\":[\"Win32_Security\"],\"Win32_Security_ExtensibleAuthenticationProtocol\":[\"Win32_Security\"],\"Win32_Security_Isolation\":[\"Win32_Security\"],\"Win32_Security_LicenseProtection\":[\"Win32_Security\"],\"Win32_Security_NetworkAccessProtection\":[\"Win32_Security\"],\"Win32_Security_WinTrust\":[\"Win32_Security\"],\"Win32_Security_WinWlx\":[\"Win32_Security\"],\"Win32_Storage\":[\"Win32\"],\"Win32_Storage_Cabinets\":[\"Win32_Storage\"],\"Win32_Storage_CloudFilters\":[\"Win32_Storage\"],\"Win32_Storage_Compression\":[\"Win32_Storage\"],\"Win32_Storage_DistributedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_FileHistory\":[\"Win32_Storage\"],\"Win32_Storage_FileSystem\":[\"Win32_Storage\"],\"Win32_Storage_Imapi\":[\"Win32_Storage\"],\"Win32_Storage_IndexServer\":[\"Win32_Storage\"],\"Win32_Storage_InstallableFileSystems\":[\"Win32_Storage\"],\"Win32_Storage_IscsiDisc\":[\"Win32_Storage\"],\"Win32_Storage_Jet\":[\"Win32_Storage\"],\"Win32_Storage_Nvme\":[\"Win32_Storage\"],\"Win32_Storage_OfflineFiles\":[\"Win32_Storage\"],\"Win32_Storage_OperationRecorder\":[\"Win32_Storage\"],\"Win32_Storage_Packaging\":[\"Win32_Storage\"],\"Win32_Storage_Packaging_Appx\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_ProjectedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_StructuredStorage\":[\"Win32_Storage\"],\"Win32_Storage_Vhd\":[\"Win32_Storage\"],\"Win32_Storage_Xps\":[\"Win32_Storage\"],\"Win32_System\":[\"Win32\"],\"Win32_System_AddressBook\":[\"Win32_System\"],\"Win32_System_Antimalware\":[\"Win32_System\"],\"Win32_System_ApplicationInstallationAndServicing\":[\"Win32_System\"],\"Win32_System_ApplicationVerifier\":[\"Win32_System\"],\"Win32_System_ClrHosting\":[\"Win32_System\"],\"Win32_System_Com\":[\"Win32_System\"],\"Win32_System_Com_Marshal\":[\"Win32_System_Com\"],\"Win32_System_Com_StructuredStorage\":[\"Win32_System_Com\"],\"Win32_System_Com_Urlmon\":[\"Win32_System_Com\"],\"Win32_System_ComponentServices\":[\"Win32_System\"],\"Win32_System_Console\":[\"Win32_System\"],\"Win32_System_CorrelationVector\":[\"Win32_System\"],\"Win32_System_DataExchange\":[\"Win32_System\"],\"Win32_System_DeploymentServices\":[\"Win32_System\"],\"Win32_System_DeveloperLicensing\":[\"Win32_System\"],\"Win32_System_Diagnostics\":[\"Win32_System\"],\"Win32_System_Diagnostics_Ceip\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug_Extensions\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Etw\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ProcessSnapshotting\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ToolHelp\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_TraceLogging\":[\"Win32_System_Diagnostics\"],\"Win32_System_DistributedTransactionCoordinator\":[\"Win32_System\"],\"Win32_System_Environment\":[\"Win32_System\"],\"Win32_System_ErrorReporting\":[\"Win32_System\"],\"Win32_System_EventCollector\":[\"Win32_System\"],\"Win32_System_EventLog\":[\"Win32_System\"],\"Win32_System_EventNotificationService\":[\"Win32_System\"],\"Win32_System_GroupPolicy\":[\"Win32_System\"],\"Win32_System_HostCompute\":[\"Win32_System\"],\"Win32_System_HostComputeNetwork\":[\"Win32_System\"],\"Win32_System_HostComputeSystem\":[\"Win32_System\"],\"Win32_System_Hypervisor\":[\"Win32_System\"],\"Win32_System_IO\":[\"Win32_System\"],\"Win32_System_Iis\":[\"Win32_System\"],\"Win32_System_Ioctl\":[\"Win32_System\"],\"Win32_System_JobObjects\":[\"Win32_System\"],\"Win32_System_Js\":[\"Win32_System\"],\"Win32_System_Kernel\":[\"Win32_System\"],\"Win32_System_LibraryLoader\":[\"Win32_System\"],\"Win32_System_Mailslots\":[\"Win32_System\"],\"Win32_System_Mapi\":[\"Win32_System\"],\"Win32_System_Memory\":[\"Win32_System\"],\"Win32_System_Memory_NonVolatile\":[\"Win32_System_Memory\"],\"Win32_System_MessageQueuing\":[\"Win32_System\"],\"Win32_System_MixedReality\":[\"Win32_System\"],\"Win32_System_Ole\":[\"Win32_System\"],\"Win32_System_PasswordManagement\":[\"Win32_System\"],\"Win32_System_Performance\":[\"Win32_System\"],\"Win32_System_Performance_HardwareCounterProfiling\":[\"Win32_System_Performance\"],\"Win32_System_Pipes\":[\"Win32_System\"],\"Win32_System_Power\":[\"Win32_System\"],\"Win32_System_ProcessStatus\":[\"Win32_System\"],\"Win32_System_Recovery\":[\"Win32_System\"],\"Win32_System_Registry\":[\"Win32_System\"],\"Win32_System_RemoteDesktop\":[\"Win32_System\"],\"Win32_System_RemoteManagement\":[\"Win32_System\"],\"Win32_System_RestartManager\":[\"Win32_System\"],\"Win32_System_Restore\":[\"Win32_System\"],\"Win32_System_Rpc\":[\"Win32_System\"],\"Win32_System_Search\":[\"Win32_System\"],\"Win32_System_Search_Common\":[\"Win32_System_Search\"],\"Win32_System_SecurityCenter\":[\"Win32_System\"],\"Win32_System_Services\":[\"Win32_System\"],\"Win32_System_SetupAndMigration\":[\"Win32_System\"],\"Win32_System_Shutdown\":[\"Win32_System\"],\"Win32_System_StationsAndDesktops\":[\"Win32_System\"],\"Win32_System_SubsystemForLinux\":[\"Win32_System\"],\"Win32_System_SystemInformation\":[\"Win32_System\"],\"Win32_System_SystemServices\":[\"Win32_System\"],\"Win32_System_Threading\":[\"Win32_System\"],\"Win32_System_Time\":[\"Win32_System\"],\"Win32_System_TpmBaseServices\":[\"Win32_System\"],\"Win32_System_UserAccessLogging\":[\"Win32_System\"],\"Win32_System_Variant\":[\"Win32_System\"],\"Win32_System_VirtualDosMachines\":[\"Win32_System\"],\"Win32_System_WindowsProgramming\":[\"Win32_System\"],\"Win32_System_Wmi\":[\"Win32_System\"],\"Win32_UI\":[\"Win32\"],\"Win32_UI_Accessibility\":[\"Win32_UI\"],\"Win32_UI_ColorSystem\":[\"Win32_UI\"],\"Win32_UI_Controls\":[\"Win32_UI\"],\"Win32_UI_Controls_Dialogs\":[\"Win32_UI_Controls\"],\"Win32_UI_HiDpi\":[\"Win32_UI\"],\"Win32_UI_Input\":[\"Win32_UI\"],\"Win32_UI_Input_Ime\":[\"Win32_UI_Input\"],\"Win32_UI_Input_KeyboardAndMouse\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Pointer\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Touch\":[\"Win32_UI_Input\"],\"Win32_UI_Input_XboxController\":[\"Win32_UI_Input\"],\"Win32_UI_InteractionContext\":[\"Win32_UI\"],\"Win32_UI_Magnification\":[\"Win32_UI\"],\"Win32_UI_Shell\":[\"Win32_UI\"],\"Win32_UI_Shell_Common\":[\"Win32_UI_Shell\"],\"Win32_UI_Shell_PropertiesSystem\":[\"Win32_UI_Shell\"],\"Win32_UI_TabletPC\":[\"Win32_UI\"],\"Win32_UI_TextServices\":[\"Win32_UI\"],\"Win32_UI_WindowsAndMessaging\":[\"Win32_UI\"],\"Win32_Web\":[\"Win32\"],\"Win32_Web_InternetExplorer\":[\"Win32_Web\"],\"default\":[],\"docs\":[]}}", - "windows-sys_0.60.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-targets\",\"req\":\"^0.53.2\"}],\"features\":{\"Wdk\":[\"Win32_Foundation\"],\"Wdk_Devices\":[\"Wdk\"],\"Wdk_Devices_Bluetooth\":[\"Wdk_Devices\"],\"Wdk_Devices_HumanInterfaceDevice\":[\"Wdk_Devices\"],\"Wdk_Foundation\":[\"Wdk\"],\"Wdk_Graphics\":[\"Wdk\"],\"Wdk_Graphics_Direct3D\":[\"Wdk_Graphics\"],\"Wdk_NetworkManagement\":[\"Wdk\"],\"Wdk_NetworkManagement_Ndis\":[\"Wdk_NetworkManagement\"],\"Wdk_NetworkManagement_WindowsFilteringPlatform\":[\"Wdk_NetworkManagement\"],\"Wdk_Storage\":[\"Wdk\"],\"Wdk_Storage_FileSystem\":[\"Wdk_Storage\"],\"Wdk_Storage_FileSystem_Minifilters\":[\"Wdk_Storage_FileSystem\"],\"Wdk_System\":[\"Wdk\"],\"Wdk_System_IO\":[\"Wdk_System\"],\"Wdk_System_Memory\":[\"Wdk_System\"],\"Wdk_System_OfflineRegistry\":[\"Wdk_System\"],\"Wdk_System_Registry\":[\"Wdk_System\"],\"Wdk_System_SystemInformation\":[\"Wdk_System\"],\"Wdk_System_SystemServices\":[\"Wdk_System\"],\"Wdk_System_Threading\":[\"Wdk_System\"],\"Win32\":[\"Win32_Foundation\"],\"Win32_Data\":[\"Win32\"],\"Win32_Data_HtmlHelp\":[\"Win32_Data\"],\"Win32_Data_RightsManagement\":[\"Win32_Data\"],\"Win32_Devices\":[\"Win32\"],\"Win32_Devices_AllJoyn\":[\"Win32_Devices\"],\"Win32_Devices_Beep\":[\"Win32_Devices\"],\"Win32_Devices_BiometricFramework\":[\"Win32_Devices\"],\"Win32_Devices_Bluetooth\":[\"Win32_Devices\"],\"Win32_Devices_Cdrom\":[\"Win32_Devices\"],\"Win32_Devices_Communication\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAndDriverInstallation\":[\"Win32_Devices\"],\"Win32_Devices_DeviceQuery\":[\"Win32_Devices\"],\"Win32_Devices_Display\":[\"Win32_Devices\"],\"Win32_Devices_Dvd\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration_Pnp\":[\"Win32_Devices_Enumeration\"],\"Win32_Devices_Fax\":[\"Win32_Devices\"],\"Win32_Devices_HumanInterfaceDevice\":[\"Win32_Devices\"],\"Win32_Devices_Nfc\":[\"Win32_Devices\"],\"Win32_Devices_Nfp\":[\"Win32_Devices\"],\"Win32_Devices_PortableDevices\":[\"Win32_Devices\"],\"Win32_Devices_Properties\":[\"Win32_Devices\"],\"Win32_Devices_Pwm\":[\"Win32_Devices\"],\"Win32_Devices_Sensors\":[\"Win32_Devices\"],\"Win32_Devices_SerialCommunication\":[\"Win32_Devices\"],\"Win32_Devices_Tapi\":[\"Win32_Devices\"],\"Win32_Devices_Usb\":[\"Win32_Devices\"],\"Win32_Devices_WebServicesOnDevices\":[\"Win32_Devices\"],\"Win32_Foundation\":[\"Win32\"],\"Win32_Gaming\":[\"Win32\"],\"Win32_Globalization\":[\"Win32\"],\"Win32_Graphics\":[\"Win32\"],\"Win32_Graphics_Dwm\":[\"Win32_Graphics\"],\"Win32_Graphics_Gdi\":[\"Win32_Graphics\"],\"Win32_Graphics_GdiPlus\":[\"Win32_Graphics\"],\"Win32_Graphics_Hlsl\":[\"Win32_Graphics\"],\"Win32_Graphics_OpenGL\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing_PrintTicket\":[\"Win32_Graphics_Printing\"],\"Win32_Management\":[\"Win32\"],\"Win32_Management_MobileDeviceManagementRegistration\":[\"Win32_Management\"],\"Win32_Media\":[\"Win32\"],\"Win32_Media_Audio\":[\"Win32_Media\"],\"Win32_Media_DxMediaObjects\":[\"Win32_Media\"],\"Win32_Media_KernelStreaming\":[\"Win32_Media\"],\"Win32_Media_Multimedia\":[\"Win32_Media\"],\"Win32_Media_Streaming\":[\"Win32_Media\"],\"Win32_Media_WindowsMediaFormat\":[\"Win32_Media\"],\"Win32_NetworkManagement\":[\"Win32\"],\"Win32_NetworkManagement_Dhcp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Dns\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_InternetConnectionWizard\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_IpHelper\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Multicast\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Ndis\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetBios\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetManagement\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetShell\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkDiagnosticsFramework\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_P2P\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_QoS\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Rras\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Snmp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WNet\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WebDav\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WiFi\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectionManager\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFilteringPlatform\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFirewall\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsNetworkVirtualization\":[\"Win32_NetworkManagement\"],\"Win32_Networking\":[\"Win32\"],\"Win32_Networking_ActiveDirectory\":[\"Win32_Networking\"],\"Win32_Networking_Clustering\":[\"Win32_Networking\"],\"Win32_Networking_HttpServer\":[\"Win32_Networking\"],\"Win32_Networking_Ldap\":[\"Win32_Networking\"],\"Win32_Networking_WebSocket\":[\"Win32_Networking\"],\"Win32_Networking_WinHttp\":[\"Win32_Networking\"],\"Win32_Networking_WinInet\":[\"Win32_Networking\"],\"Win32_Networking_WinSock\":[\"Win32_Networking\"],\"Win32_Networking_WindowsWebServices\":[\"Win32_Networking\"],\"Win32_Security\":[\"Win32\"],\"Win32_Security_AppLocker\":[\"Win32_Security\"],\"Win32_Security_Authentication\":[\"Win32_Security\"],\"Win32_Security_Authentication_Identity\":[\"Win32_Security_Authentication\"],\"Win32_Security_Authorization\":[\"Win32_Security\"],\"Win32_Security_Credentials\":[\"Win32_Security\"],\"Win32_Security_Cryptography\":[\"Win32_Security\"],\"Win32_Security_Cryptography_Catalog\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Certificates\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Sip\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_UI\":[\"Win32_Security_Cryptography\"],\"Win32_Security_DiagnosticDataQuery\":[\"Win32_Security\"],\"Win32_Security_DirectoryServices\":[\"Win32_Security\"],\"Win32_Security_EnterpriseData\":[\"Win32_Security\"],\"Win32_Security_ExtensibleAuthenticationProtocol\":[\"Win32_Security\"],\"Win32_Security_Isolation\":[\"Win32_Security\"],\"Win32_Security_LicenseProtection\":[\"Win32_Security\"],\"Win32_Security_NetworkAccessProtection\":[\"Win32_Security\"],\"Win32_Security_WinTrust\":[\"Win32_Security\"],\"Win32_Security_WinWlx\":[\"Win32_Security\"],\"Win32_Storage\":[\"Win32\"],\"Win32_Storage_Cabinets\":[\"Win32_Storage\"],\"Win32_Storage_CloudFilters\":[\"Win32_Storage\"],\"Win32_Storage_Compression\":[\"Win32_Storage\"],\"Win32_Storage_DistributedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_FileHistory\":[\"Win32_Storage\"],\"Win32_Storage_FileSystem\":[\"Win32_Storage\"],\"Win32_Storage_Imapi\":[\"Win32_Storage\"],\"Win32_Storage_IndexServer\":[\"Win32_Storage\"],\"Win32_Storage_InstallableFileSystems\":[\"Win32_Storage\"],\"Win32_Storage_IscsiDisc\":[\"Win32_Storage\"],\"Win32_Storage_Jet\":[\"Win32_Storage\"],\"Win32_Storage_Nvme\":[\"Win32_Storage\"],\"Win32_Storage_OfflineFiles\":[\"Win32_Storage\"],\"Win32_Storage_OperationRecorder\":[\"Win32_Storage\"],\"Win32_Storage_Packaging\":[\"Win32_Storage\"],\"Win32_Storage_Packaging_Appx\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_ProjectedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_StructuredStorage\":[\"Win32_Storage\"],\"Win32_Storage_Vhd\":[\"Win32_Storage\"],\"Win32_Storage_Xps\":[\"Win32_Storage\"],\"Win32_System\":[\"Win32\"],\"Win32_System_AddressBook\":[\"Win32_System\"],\"Win32_System_Antimalware\":[\"Win32_System\"],\"Win32_System_ApplicationInstallationAndServicing\":[\"Win32_System\"],\"Win32_System_ApplicationVerifier\":[\"Win32_System\"],\"Win32_System_ClrHosting\":[\"Win32_System\"],\"Win32_System_Com\":[\"Win32_System\"],\"Win32_System_Com_Marshal\":[\"Win32_System_Com\"],\"Win32_System_Com_StructuredStorage\":[\"Win32_System_Com\"],\"Win32_System_Com_Urlmon\":[\"Win32_System_Com\"],\"Win32_System_ComponentServices\":[\"Win32_System\"],\"Win32_System_Console\":[\"Win32_System\"],\"Win32_System_CorrelationVector\":[\"Win32_System\"],\"Win32_System_DataExchange\":[\"Win32_System\"],\"Win32_System_DeploymentServices\":[\"Win32_System\"],\"Win32_System_DeveloperLicensing\":[\"Win32_System\"],\"Win32_System_Diagnostics\":[\"Win32_System\"],\"Win32_System_Diagnostics_Ceip\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug_Extensions\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Etw\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ProcessSnapshotting\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ToolHelp\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_TraceLogging\":[\"Win32_System_Diagnostics\"],\"Win32_System_DistributedTransactionCoordinator\":[\"Win32_System\"],\"Win32_System_Environment\":[\"Win32_System\"],\"Win32_System_ErrorReporting\":[\"Win32_System\"],\"Win32_System_EventCollector\":[\"Win32_System\"],\"Win32_System_EventLog\":[\"Win32_System\"],\"Win32_System_EventNotificationService\":[\"Win32_System\"],\"Win32_System_GroupPolicy\":[\"Win32_System\"],\"Win32_System_HostCompute\":[\"Win32_System\"],\"Win32_System_HostComputeNetwork\":[\"Win32_System\"],\"Win32_System_HostComputeSystem\":[\"Win32_System\"],\"Win32_System_Hypervisor\":[\"Win32_System\"],\"Win32_System_IO\":[\"Win32_System\"],\"Win32_System_Iis\":[\"Win32_System\"],\"Win32_System_Ioctl\":[\"Win32_System\"],\"Win32_System_JobObjects\":[\"Win32_System\"],\"Win32_System_Js\":[\"Win32_System\"],\"Win32_System_Kernel\":[\"Win32_System\"],\"Win32_System_LibraryLoader\":[\"Win32_System\"],\"Win32_System_Mailslots\":[\"Win32_System\"],\"Win32_System_Mapi\":[\"Win32_System\"],\"Win32_System_Memory\":[\"Win32_System\"],\"Win32_System_Memory_NonVolatile\":[\"Win32_System_Memory\"],\"Win32_System_MessageQueuing\":[\"Win32_System\"],\"Win32_System_MixedReality\":[\"Win32_System\"],\"Win32_System_Ole\":[\"Win32_System\"],\"Win32_System_PasswordManagement\":[\"Win32_System\"],\"Win32_System_Performance\":[\"Win32_System\"],\"Win32_System_Performance_HardwareCounterProfiling\":[\"Win32_System_Performance\"],\"Win32_System_Pipes\":[\"Win32_System\"],\"Win32_System_Power\":[\"Win32_System\"],\"Win32_System_ProcessStatus\":[\"Win32_System\"],\"Win32_System_Recovery\":[\"Win32_System\"],\"Win32_System_Registry\":[\"Win32_System\"],\"Win32_System_RemoteDesktop\":[\"Win32_System\"],\"Win32_System_RemoteManagement\":[\"Win32_System\"],\"Win32_System_RestartManager\":[\"Win32_System\"],\"Win32_System_Restore\":[\"Win32_System\"],\"Win32_System_Rpc\":[\"Win32_System\"],\"Win32_System_Search\":[\"Win32_System\"],\"Win32_System_Search_Common\":[\"Win32_System_Search\"],\"Win32_System_SecurityCenter\":[\"Win32_System\"],\"Win32_System_Services\":[\"Win32_System\"],\"Win32_System_SetupAndMigration\":[\"Win32_System\"],\"Win32_System_Shutdown\":[\"Win32_System\"],\"Win32_System_StationsAndDesktops\":[\"Win32_System\"],\"Win32_System_SubsystemForLinux\":[\"Win32_System\"],\"Win32_System_SystemInformation\":[\"Win32_System\"],\"Win32_System_SystemServices\":[\"Win32_System\"],\"Win32_System_Threading\":[\"Win32_System\"],\"Win32_System_Time\":[\"Win32_System\"],\"Win32_System_TpmBaseServices\":[\"Win32_System\"],\"Win32_System_UserAccessLogging\":[\"Win32_System\"],\"Win32_System_Variant\":[\"Win32_System\"],\"Win32_System_VirtualDosMachines\":[\"Win32_System\"],\"Win32_System_WindowsProgramming\":[\"Win32_System\"],\"Win32_System_Wmi\":[\"Win32_System\"],\"Win32_UI\":[\"Win32\"],\"Win32_UI_Accessibility\":[\"Win32_UI\"],\"Win32_UI_ColorSystem\":[\"Win32_UI\"],\"Win32_UI_Controls\":[\"Win32_UI\"],\"Win32_UI_Controls_Dialogs\":[\"Win32_UI_Controls\"],\"Win32_UI_HiDpi\":[\"Win32_UI\"],\"Win32_UI_Input\":[\"Win32_UI\"],\"Win32_UI_Input_Ime\":[\"Win32_UI_Input\"],\"Win32_UI_Input_KeyboardAndMouse\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Pointer\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Touch\":[\"Win32_UI_Input\"],\"Win32_UI_Input_XboxController\":[\"Win32_UI_Input\"],\"Win32_UI_InteractionContext\":[\"Win32_UI\"],\"Win32_UI_Magnification\":[\"Win32_UI\"],\"Win32_UI_Shell\":[\"Win32_UI\"],\"Win32_UI_Shell_Common\":[\"Win32_UI_Shell\"],\"Win32_UI_Shell_PropertiesSystem\":[\"Win32_UI_Shell\"],\"Win32_UI_TabletPC\":[\"Win32_UI\"],\"Win32_UI_TextServices\":[\"Win32_UI\"],\"Win32_UI_WindowsAndMessaging\":[\"Win32_UI\"],\"Win32_Web\":[\"Win32\"],\"Win32_Web_InternetExplorer\":[\"Win32_Web\"],\"default\":[],\"docs\":[]}}", - "windows-sys_0.61.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\"}],\"features\":{\"Wdk\":[\"Win32_Foundation\"],\"Wdk_Devices\":[\"Wdk\"],\"Wdk_Devices_Bluetooth\":[\"Wdk_Devices\"],\"Wdk_Devices_HumanInterfaceDevice\":[\"Wdk_Devices\"],\"Wdk_Foundation\":[\"Wdk\"],\"Wdk_Graphics\":[\"Wdk\"],\"Wdk_Graphics_Direct3D\":[\"Wdk_Graphics\"],\"Wdk_NetworkManagement\":[\"Wdk\"],\"Wdk_NetworkManagement_Ndis\":[\"Wdk_NetworkManagement\"],\"Wdk_NetworkManagement_WindowsFilteringPlatform\":[\"Wdk_NetworkManagement\"],\"Wdk_Storage\":[\"Wdk\"],\"Wdk_Storage_FileSystem\":[\"Wdk_Storage\"],\"Wdk_Storage_FileSystem_Minifilters\":[\"Wdk_Storage_FileSystem\"],\"Wdk_System\":[\"Wdk\"],\"Wdk_System_IO\":[\"Wdk_System\"],\"Wdk_System_Memory\":[\"Wdk_System\"],\"Wdk_System_OfflineRegistry\":[\"Wdk_System\"],\"Wdk_System_Registry\":[\"Wdk_System\"],\"Wdk_System_SystemInformation\":[\"Wdk_System\"],\"Wdk_System_SystemServices\":[\"Wdk_System\"],\"Wdk_System_Threading\":[\"Wdk_System\"],\"Win32\":[\"Win32_Foundation\"],\"Win32_Data\":[\"Win32\"],\"Win32_Data_HtmlHelp\":[\"Win32_Data\"],\"Win32_Data_RightsManagement\":[\"Win32_Data\"],\"Win32_Devices\":[\"Win32\"],\"Win32_Devices_AllJoyn\":[\"Win32_Devices\"],\"Win32_Devices_Beep\":[\"Win32_Devices\"],\"Win32_Devices_BiometricFramework\":[\"Win32_Devices\"],\"Win32_Devices_Bluetooth\":[\"Win32_Devices\"],\"Win32_Devices_Cdrom\":[\"Win32_Devices\"],\"Win32_Devices_Communication\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAndDriverInstallation\":[\"Win32_Devices\"],\"Win32_Devices_DeviceQuery\":[\"Win32_Devices\"],\"Win32_Devices_Display\":[\"Win32_Devices\"],\"Win32_Devices_Dvd\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration_Pnp\":[\"Win32_Devices_Enumeration\"],\"Win32_Devices_Fax\":[\"Win32_Devices\"],\"Win32_Devices_HumanInterfaceDevice\":[\"Win32_Devices\"],\"Win32_Devices_Nfc\":[\"Win32_Devices\"],\"Win32_Devices_Nfp\":[\"Win32_Devices\"],\"Win32_Devices_PortableDevices\":[\"Win32_Devices\"],\"Win32_Devices_Properties\":[\"Win32_Devices\"],\"Win32_Devices_Pwm\":[\"Win32_Devices\"],\"Win32_Devices_Sensors\":[\"Win32_Devices\"],\"Win32_Devices_SerialCommunication\":[\"Win32_Devices\"],\"Win32_Devices_Tapi\":[\"Win32_Devices\"],\"Win32_Devices_Usb\":[\"Win32_Devices\"],\"Win32_Devices_WebServicesOnDevices\":[\"Win32_Devices\"],\"Win32_Foundation\":[\"Win32\"],\"Win32_Gaming\":[\"Win32\"],\"Win32_Globalization\":[\"Win32\"],\"Win32_Graphics\":[\"Win32\"],\"Win32_Graphics_Dwm\":[\"Win32_Graphics\"],\"Win32_Graphics_Gdi\":[\"Win32_Graphics\"],\"Win32_Graphics_GdiPlus\":[\"Win32_Graphics\"],\"Win32_Graphics_Hlsl\":[\"Win32_Graphics\"],\"Win32_Graphics_OpenGL\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing_PrintTicket\":[\"Win32_Graphics_Printing\"],\"Win32_Management\":[\"Win32\"],\"Win32_Management_MobileDeviceManagementRegistration\":[\"Win32_Management\"],\"Win32_Media\":[\"Win32\"],\"Win32_Media_Audio\":[\"Win32_Media\"],\"Win32_Media_DxMediaObjects\":[\"Win32_Media\"],\"Win32_Media_KernelStreaming\":[\"Win32_Media\"],\"Win32_Media_Multimedia\":[\"Win32_Media\"],\"Win32_Media_Streaming\":[\"Win32_Media\"],\"Win32_Media_WindowsMediaFormat\":[\"Win32_Media\"],\"Win32_NetworkManagement\":[\"Win32\"],\"Win32_NetworkManagement_Dhcp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Dns\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_InternetConnectionWizard\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_IpHelper\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Multicast\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Ndis\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetBios\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetManagement\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetShell\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkDiagnosticsFramework\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_P2P\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_QoS\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Rras\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Snmp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WNet\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WebDav\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WiFi\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectionManager\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFilteringPlatform\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFirewall\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsNetworkVirtualization\":[\"Win32_NetworkManagement\"],\"Win32_Networking\":[\"Win32\"],\"Win32_Networking_ActiveDirectory\":[\"Win32_Networking\"],\"Win32_Networking_Clustering\":[\"Win32_Networking\"],\"Win32_Networking_HttpServer\":[\"Win32_Networking\"],\"Win32_Networking_Ldap\":[\"Win32_Networking\"],\"Win32_Networking_WebSocket\":[\"Win32_Networking\"],\"Win32_Networking_WinHttp\":[\"Win32_Networking\"],\"Win32_Networking_WinInet\":[\"Win32_Networking\"],\"Win32_Networking_WinSock\":[\"Win32_Networking\"],\"Win32_Networking_WindowsWebServices\":[\"Win32_Networking\"],\"Win32_Security\":[\"Win32\"],\"Win32_Security_AppLocker\":[\"Win32_Security\"],\"Win32_Security_Authentication\":[\"Win32_Security\"],\"Win32_Security_Authentication_Identity\":[\"Win32_Security_Authentication\"],\"Win32_Security_Authorization\":[\"Win32_Security\"],\"Win32_Security_Credentials\":[\"Win32_Security\"],\"Win32_Security_Cryptography\":[\"Win32_Security\"],\"Win32_Security_Cryptography_Catalog\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Certificates\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Sip\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_UI\":[\"Win32_Security_Cryptography\"],\"Win32_Security_DiagnosticDataQuery\":[\"Win32_Security\"],\"Win32_Security_DirectoryServices\":[\"Win32_Security\"],\"Win32_Security_EnterpriseData\":[\"Win32_Security\"],\"Win32_Security_ExtensibleAuthenticationProtocol\":[\"Win32_Security\"],\"Win32_Security_Isolation\":[\"Win32_Security\"],\"Win32_Security_LicenseProtection\":[\"Win32_Security\"],\"Win32_Security_NetworkAccessProtection\":[\"Win32_Security\"],\"Win32_Security_WinTrust\":[\"Win32_Security\"],\"Win32_Security_WinWlx\":[\"Win32_Security\"],\"Win32_Storage\":[\"Win32\"],\"Win32_Storage_Cabinets\":[\"Win32_Storage\"],\"Win32_Storage_CloudFilters\":[\"Win32_Storage\"],\"Win32_Storage_Compression\":[\"Win32_Storage\"],\"Win32_Storage_DistributedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_FileHistory\":[\"Win32_Storage\"],\"Win32_Storage_FileSystem\":[\"Win32_Storage\"],\"Win32_Storage_Imapi\":[\"Win32_Storage\"],\"Win32_Storage_IndexServer\":[\"Win32_Storage\"],\"Win32_Storage_InstallableFileSystems\":[\"Win32_Storage\"],\"Win32_Storage_IscsiDisc\":[\"Win32_Storage\"],\"Win32_Storage_Jet\":[\"Win32_Storage\"],\"Win32_Storage_Nvme\":[\"Win32_Storage\"],\"Win32_Storage_OfflineFiles\":[\"Win32_Storage\"],\"Win32_Storage_OperationRecorder\":[\"Win32_Storage\"],\"Win32_Storage_Packaging\":[\"Win32_Storage\"],\"Win32_Storage_Packaging_Appx\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_ProjectedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_StructuredStorage\":[\"Win32_Storage\"],\"Win32_Storage_Vhd\":[\"Win32_Storage\"],\"Win32_Storage_Xps\":[\"Win32_Storage\"],\"Win32_System\":[\"Win32\"],\"Win32_System_AddressBook\":[\"Win32_System\"],\"Win32_System_Antimalware\":[\"Win32_System\"],\"Win32_System_ApplicationInstallationAndServicing\":[\"Win32_System\"],\"Win32_System_ApplicationVerifier\":[\"Win32_System\"],\"Win32_System_ClrHosting\":[\"Win32_System\"],\"Win32_System_Com\":[\"Win32_System\"],\"Win32_System_Com_Marshal\":[\"Win32_System_Com\"],\"Win32_System_Com_StructuredStorage\":[\"Win32_System_Com\"],\"Win32_System_Com_Urlmon\":[\"Win32_System_Com\"],\"Win32_System_ComponentServices\":[\"Win32_System\"],\"Win32_System_Console\":[\"Win32_System\"],\"Win32_System_CorrelationVector\":[\"Win32_System\"],\"Win32_System_DataExchange\":[\"Win32_System\"],\"Win32_System_DeploymentServices\":[\"Win32_System\"],\"Win32_System_DeveloperLicensing\":[\"Win32_System\"],\"Win32_System_Diagnostics\":[\"Win32_System\"],\"Win32_System_Diagnostics_Ceip\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug_Extensions\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Etw\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ProcessSnapshotting\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ToolHelp\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_TraceLogging\":[\"Win32_System_Diagnostics\"],\"Win32_System_DistributedTransactionCoordinator\":[\"Win32_System\"],\"Win32_System_Environment\":[\"Win32_System\"],\"Win32_System_ErrorReporting\":[\"Win32_System\"],\"Win32_System_EventCollector\":[\"Win32_System\"],\"Win32_System_EventLog\":[\"Win32_System\"],\"Win32_System_EventNotificationService\":[\"Win32_System\"],\"Win32_System_GroupPolicy\":[\"Win32_System\"],\"Win32_System_HostCompute\":[\"Win32_System\"],\"Win32_System_HostComputeNetwork\":[\"Win32_System\"],\"Win32_System_HostComputeSystem\":[\"Win32_System\"],\"Win32_System_Hypervisor\":[\"Win32_System\"],\"Win32_System_IO\":[\"Win32_System\"],\"Win32_System_Iis\":[\"Win32_System\"],\"Win32_System_Ioctl\":[\"Win32_System\"],\"Win32_System_JobObjects\":[\"Win32_System\"],\"Win32_System_Js\":[\"Win32_System\"],\"Win32_System_Kernel\":[\"Win32_System\"],\"Win32_System_LibraryLoader\":[\"Win32_System\"],\"Win32_System_Mailslots\":[\"Win32_System\"],\"Win32_System_Mapi\":[\"Win32_System\"],\"Win32_System_Memory\":[\"Win32_System\"],\"Win32_System_Memory_NonVolatile\":[\"Win32_System_Memory\"],\"Win32_System_MessageQueuing\":[\"Win32_System\"],\"Win32_System_MixedReality\":[\"Win32_System\"],\"Win32_System_Ole\":[\"Win32_System\"],\"Win32_System_PasswordManagement\":[\"Win32_System\"],\"Win32_System_Performance\":[\"Win32_System\"],\"Win32_System_Performance_HardwareCounterProfiling\":[\"Win32_System_Performance\"],\"Win32_System_Pipes\":[\"Win32_System\"],\"Win32_System_Power\":[\"Win32_System\"],\"Win32_System_ProcessStatus\":[\"Win32_System\"],\"Win32_System_Recovery\":[\"Win32_System\"],\"Win32_System_Registry\":[\"Win32_System\"],\"Win32_System_RemoteDesktop\":[\"Win32_System\"],\"Win32_System_RemoteManagement\":[\"Win32_System\"],\"Win32_System_RestartManager\":[\"Win32_System\"],\"Win32_System_Restore\":[\"Win32_System\"],\"Win32_System_Rpc\":[\"Win32_System\"],\"Win32_System_Search\":[\"Win32_System\"],\"Win32_System_Search_Common\":[\"Win32_System_Search\"],\"Win32_System_SecurityCenter\":[\"Win32_System\"],\"Win32_System_Services\":[\"Win32_System\"],\"Win32_System_SetupAndMigration\":[\"Win32_System\"],\"Win32_System_Shutdown\":[\"Win32_System\"],\"Win32_System_StationsAndDesktops\":[\"Win32_System\"],\"Win32_System_SubsystemForLinux\":[\"Win32_System\"],\"Win32_System_SystemInformation\":[\"Win32_System\"],\"Win32_System_SystemServices\":[\"Win32_System\"],\"Win32_System_Threading\":[\"Win32_System\"],\"Win32_System_Time\":[\"Win32_System\"],\"Win32_System_TpmBaseServices\":[\"Win32_System\"],\"Win32_System_UserAccessLogging\":[\"Win32_System\"],\"Win32_System_Variant\":[\"Win32_System\"],\"Win32_System_VirtualDosMachines\":[\"Win32_System\"],\"Win32_System_WindowsProgramming\":[\"Win32_System\"],\"Win32_System_Wmi\":[\"Win32_System\"],\"Win32_UI\":[\"Win32\"],\"Win32_UI_Accessibility\":[\"Win32_UI\"],\"Win32_UI_ColorSystem\":[\"Win32_UI\"],\"Win32_UI_Controls\":[\"Win32_UI\"],\"Win32_UI_Controls_Dialogs\":[\"Win32_UI_Controls\"],\"Win32_UI_HiDpi\":[\"Win32_UI\"],\"Win32_UI_Input\":[\"Win32_UI\"],\"Win32_UI_Input_Ime\":[\"Win32_UI_Input\"],\"Win32_UI_Input_KeyboardAndMouse\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Pointer\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Touch\":[\"Win32_UI_Input\"],\"Win32_UI_Input_XboxController\":[\"Win32_UI_Input\"],\"Win32_UI_InteractionContext\":[\"Win32_UI\"],\"Win32_UI_Magnification\":[\"Win32_UI\"],\"Win32_UI_Shell\":[\"Win32_UI\"],\"Win32_UI_Shell_Common\":[\"Win32_UI_Shell\"],\"Win32_UI_Shell_PropertiesSystem\":[\"Win32_UI_Shell\"],\"Win32_UI_TabletPC\":[\"Win32_UI\"],\"Win32_UI_TextServices\":[\"Win32_UI\"],\"Win32_UI_WindowsAndMessaging\":[\"Win32_UI\"],\"Win32_Web\":[\"Win32\"],\"Win32_Web_InternetExplorer\":[\"Win32_Web\"],\"default\":[],\"docs\":[]}}", - "windows-targets_0.42.2": "{\"dependencies\":[{\"name\":\"windows_aarch64_gnullvm\",\"req\":\"^0.42.2\",\"target\":\"aarch64-pc-windows-gnullvm\"},{\"name\":\"windows_aarch64_msvc\",\"req\":\"^0.42.2\",\"target\":\"aarch64-pc-windows-msvc\"},{\"name\":\"windows_aarch64_msvc\",\"req\":\"^0.42.2\",\"target\":\"aarch64-uwp-windows-msvc\"},{\"name\":\"windows_i686_gnu\",\"req\":\"^0.42.2\",\"target\":\"i686-pc-windows-gnu\"},{\"name\":\"windows_i686_gnu\",\"req\":\"^0.42.2\",\"target\":\"i686-uwp-windows-gnu\"},{\"name\":\"windows_i686_msvc\",\"req\":\"^0.42.2\",\"target\":\"i686-pc-windows-msvc\"},{\"name\":\"windows_i686_msvc\",\"req\":\"^0.42.2\",\"target\":\"i686-uwp-windows-msvc\"},{\"name\":\"windows_x86_64_gnu\",\"req\":\"^0.42.2\",\"target\":\"x86_64-pc-windows-gnu\"},{\"name\":\"windows_x86_64_gnu\",\"req\":\"^0.42.2\",\"target\":\"x86_64-uwp-windows-gnu\"},{\"name\":\"windows_x86_64_gnullvm\",\"req\":\"^0.42.2\",\"target\":\"x86_64-pc-windows-gnullvm\"},{\"name\":\"windows_x86_64_msvc\",\"req\":\"^0.42.2\",\"target\":\"x86_64-pc-windows-msvc\"},{\"name\":\"windows_x86_64_msvc\",\"req\":\"^0.42.2\",\"target\":\"x86_64-uwp-windows-msvc\"}],\"features\":{}}", - "windows-targets_0.48.5": "{\"dependencies\":[{\"name\":\"windows_aarch64_gnullvm\",\"req\":\"^0.48.5\",\"target\":\"aarch64-pc-windows-gnullvm\"},{\"name\":\"windows_aarch64_msvc\",\"req\":\"^0.48.5\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"},{\"name\":\"windows_i686_gnu\",\"req\":\"^0.48.5\",\"target\":\"cfg(all(target_arch = \\\"x86\\\", target_env = \\\"gnu\\\", not(windows_raw_dylib)))\"},{\"name\":\"windows_i686_msvc\",\"req\":\"^0.48.5\",\"target\":\"cfg(all(target_arch = \\\"x86\\\", target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"},{\"name\":\"windows_x86_64_gnu\",\"req\":\"^0.48.5\",\"target\":\"cfg(all(target_arch = \\\"x86_64\\\", target_env = \\\"gnu\\\", not(target_abi = \\\"llvm\\\"), not(windows_raw_dylib)))\"},{\"name\":\"windows_x86_64_gnullvm\",\"req\":\"^0.48.5\",\"target\":\"x86_64-pc-windows-gnullvm\"},{\"name\":\"windows_x86_64_msvc\",\"req\":\"^0.48.5\",\"target\":\"cfg(all(target_arch = \\\"x86_64\\\", target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"}],\"features\":{}}", - "windows-targets_0.52.6": "{\"dependencies\":[{\"name\":\"windows_aarch64_gnullvm\",\"req\":\"^0.52.6\",\"target\":\"aarch64-pc-windows-gnullvm\"},{\"name\":\"windows_aarch64_msvc\",\"req\":\"^0.52.6\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"},{\"name\":\"windows_i686_gnu\",\"req\":\"^0.52.6\",\"target\":\"cfg(all(target_arch = \\\"x86\\\", target_env = \\\"gnu\\\", not(target_abi = \\\"llvm\\\"), not(windows_raw_dylib)))\"},{\"name\":\"windows_i686_gnullvm\",\"req\":\"^0.52.6\",\"target\":\"i686-pc-windows-gnullvm\"},{\"name\":\"windows_i686_msvc\",\"req\":\"^0.52.6\",\"target\":\"cfg(all(target_arch = \\\"x86\\\", target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"},{\"name\":\"windows_x86_64_gnu\",\"req\":\"^0.52.6\",\"target\":\"cfg(all(target_arch = \\\"x86_64\\\", target_env = \\\"gnu\\\", not(target_abi = \\\"llvm\\\"), not(windows_raw_dylib)))\"},{\"name\":\"windows_x86_64_gnullvm\",\"req\":\"^0.52.6\",\"target\":\"x86_64-pc-windows-gnullvm\"},{\"name\":\"windows_x86_64_msvc\",\"req\":\"^0.52.6\",\"target\":\"cfg(all(any(target_arch = \\\"x86_64\\\", target_arch = \\\"arm64ec\\\"), target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"}],\"features\":{}}", - "windows-targets_0.53.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\",\"target\":\"cfg(windows_raw_dylib)\"},{\"name\":\"windows_aarch64_gnullvm\",\"req\":\"^0.53.0\",\"target\":\"aarch64-pc-windows-gnullvm\"},{\"name\":\"windows_aarch64_msvc\",\"req\":\"^0.53.0\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"},{\"name\":\"windows_i686_gnu\",\"req\":\"^0.53.0\",\"target\":\"cfg(all(target_arch = \\\"x86\\\", target_env = \\\"gnu\\\", not(target_abi = \\\"llvm\\\"), not(windows_raw_dylib)))\"},{\"name\":\"windows_i686_gnullvm\",\"req\":\"^0.53.0\",\"target\":\"i686-pc-windows-gnullvm\"},{\"name\":\"windows_i686_msvc\",\"req\":\"^0.53.0\",\"target\":\"cfg(all(target_arch = \\\"x86\\\", target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"},{\"name\":\"windows_x86_64_gnu\",\"req\":\"^0.53.0\",\"target\":\"cfg(all(target_arch = \\\"x86_64\\\", target_env = \\\"gnu\\\", not(target_abi = \\\"llvm\\\"), not(windows_raw_dylib)))\"},{\"name\":\"windows_x86_64_gnullvm\",\"req\":\"^0.53.0\",\"target\":\"x86_64-pc-windows-gnullvm\"},{\"name\":\"windows_x86_64_msvc\",\"req\":\"^0.53.0\",\"target\":\"cfg(all(any(target_arch = \\\"x86_64\\\", target_arch = \\\"arm64ec\\\"), target_env = \\\"msvc\\\", not(windows_raw_dylib)))\"}],\"features\":{}}", - "windows-threading_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-link\",\"req\":\"^0.2.1\"}],\"features\":{}}", - "windows_0.62.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"windows-collections\",\"req\":\"^0.3.2\"},{\"default_features\":false,\"name\":\"windows-core\",\"req\":\"^0.62.2\"},{\"default_features\":false,\"name\":\"windows-future\",\"req\":\"^0.3.2\"},{\"default_features\":false,\"name\":\"windows-numerics\",\"req\":\"^0.3.1\"}],\"features\":{\"AI\":[\"Foundation\"],\"AI_Actions\":[\"AI\"],\"AI_Actions_Hosting\":[\"AI_Actions\"],\"AI_Actions_Provider\":[\"AI_Actions\"],\"AI_Agents\":[\"AI\"],\"AI_Agents_Mcp\":[\"AI_Agents\"],\"AI_MachineLearning\":[\"AI\"],\"ApplicationModel\":[\"Foundation\"],\"ApplicationModel_Activation\":[\"ApplicationModel\"],\"ApplicationModel_AppExtensions\":[\"ApplicationModel\"],\"ApplicationModel_AppService\":[\"ApplicationModel\"],\"ApplicationModel_Appointments\":[\"ApplicationModel\"],\"ApplicationModel_Appointments_AppointmentsProvider\":[\"ApplicationModel_Appointments\"],\"ApplicationModel_Appointments_DataProvider\":[\"ApplicationModel_Appointments\"],\"ApplicationModel_Background\":[\"ApplicationModel\"],\"ApplicationModel_Calls\":[\"ApplicationModel\"],\"ApplicationModel_Calls_Background\":[\"ApplicationModel_Calls\"],\"ApplicationModel_Calls_Provider\":[\"ApplicationModel_Calls\"],\"ApplicationModel_Chat\":[\"ApplicationModel\"],\"ApplicationModel_CommunicationBlocking\":[\"ApplicationModel\"],\"ApplicationModel_Contacts\":[\"ApplicationModel\"],\"ApplicationModel_Contacts_DataProvider\":[\"ApplicationModel_Contacts\"],\"ApplicationModel_Contacts_Provider\":[\"ApplicationModel_Contacts\"],\"ApplicationModel_ConversationalAgent\":[\"ApplicationModel\"],\"ApplicationModel_Core\":[\"ApplicationModel\"],\"ApplicationModel_DataTransfer\":[\"ApplicationModel\"],\"ApplicationModel_DataTransfer_DragDrop\":[\"ApplicationModel_DataTransfer\"],\"ApplicationModel_DataTransfer_DragDrop_Core\":[\"ApplicationModel_DataTransfer_DragDrop\"],\"ApplicationModel_DataTransfer_ShareTarget\":[\"ApplicationModel_DataTransfer\"],\"ApplicationModel_Email\":[\"ApplicationModel\"],\"ApplicationModel_Email_DataProvider\":[\"ApplicationModel_Email\"],\"ApplicationModel_ExtendedExecution\":[\"ApplicationModel\"],\"ApplicationModel_ExtendedExecution_Foreground\":[\"ApplicationModel_ExtendedExecution\"],\"ApplicationModel_Holographic\":[\"ApplicationModel\"],\"ApplicationModel_LockScreen\":[\"ApplicationModel\"],\"ApplicationModel_PackageExtensions\":[\"ApplicationModel\"],\"ApplicationModel_Payments\":[\"ApplicationModel\"],\"ApplicationModel_Payments_Provider\":[\"ApplicationModel_Payments\"],\"ApplicationModel_Preview\":[\"ApplicationModel\"],\"ApplicationModel_Preview_Holographic\":[\"ApplicationModel_Preview\"],\"ApplicationModel_Preview_InkWorkspace\":[\"ApplicationModel_Preview\"],\"ApplicationModel_Preview_Notes\":[\"ApplicationModel_Preview\"],\"ApplicationModel_Resources\":[\"ApplicationModel\"],\"ApplicationModel_Resources_Core\":[\"ApplicationModel_Resources\"],\"ApplicationModel_Resources_Management\":[\"ApplicationModel_Resources\"],\"ApplicationModel_Search\":[\"ApplicationModel\"],\"ApplicationModel_Search_Core\":[\"ApplicationModel_Search\"],\"ApplicationModel_UserActivities\":[\"ApplicationModel\"],\"ApplicationModel_UserActivities_Core\":[\"ApplicationModel_UserActivities\"],\"ApplicationModel_UserDataAccounts\":[\"ApplicationModel\"],\"ApplicationModel_UserDataAccounts_Provider\":[\"ApplicationModel_UserDataAccounts\"],\"ApplicationModel_UserDataAccounts_SystemAccess\":[\"ApplicationModel_UserDataAccounts\"],\"ApplicationModel_UserDataTasks\":[\"ApplicationModel\"],\"ApplicationModel_UserDataTasks_DataProvider\":[\"ApplicationModel_UserDataTasks\"],\"ApplicationModel_VoiceCommands\":[\"ApplicationModel\"],\"ApplicationModel_Wallet\":[\"ApplicationModel\"],\"ApplicationModel_Wallet_System\":[\"ApplicationModel_Wallet\"],\"Data\":[\"Foundation\"],\"Data_Html\":[\"Data\"],\"Data_Json\":[\"Data\"],\"Data_Pdf\":[\"Data\"],\"Data_Text\":[\"Data\"],\"Data_Xml\":[\"Data\"],\"Data_Xml_Dom\":[\"Data_Xml\"],\"Data_Xml_Xsl\":[\"Data_Xml\"],\"Devices\":[\"Foundation\"],\"Devices_Adc\":[\"Devices\"],\"Devices_Adc_Provider\":[\"Devices_Adc\"],\"Devices_Background\":[\"Devices\"],\"Devices_Bluetooth\":[\"Devices\"],\"Devices_Bluetooth_Advertisement\":[\"Devices_Bluetooth\"],\"Devices_Bluetooth_Background\":[\"Devices_Bluetooth\"],\"Devices_Bluetooth_GenericAttributeProfile\":[\"Devices_Bluetooth\"],\"Devices_Bluetooth_Rfcomm\":[\"Devices_Bluetooth\"],\"Devices_Custom\":[\"Devices\"],\"Devices_Display\":[\"Devices\"],\"Devices_Display_Core\":[\"Devices_Display\"],\"Devices_Enumeration\":[\"Devices\"],\"Devices_Enumeration_Pnp\":[\"Devices_Enumeration\"],\"Devices_Geolocation\":[\"Devices\"],\"Devices_Geolocation_Geofencing\":[\"Devices_Geolocation\"],\"Devices_Geolocation_Provider\":[\"Devices_Geolocation\"],\"Devices_Gpio\":[\"Devices\"],\"Devices_Gpio_Provider\":[\"Devices_Gpio\"],\"Devices_Haptics\":[\"Devices\"],\"Devices_HumanInterfaceDevice\":[\"Devices\"],\"Devices_I2c\":[\"Devices\"],\"Devices_I2c_Provider\":[\"Devices_I2c\"],\"Devices_Input\":[\"Devices\"],\"Devices_Input_Preview\":[\"Devices_Input\"],\"Devices_Lights\":[\"Devices\"],\"Devices_Lights_Effects\":[\"Devices_Lights\"],\"Devices_Midi\":[\"Devices\"],\"Devices_PointOfService\":[\"Devices\"],\"Devices_PointOfService_Provider\":[\"Devices_PointOfService\"],\"Devices_Portable\":[\"Devices\"],\"Devices_Power\":[\"Devices\"],\"Devices_Printers\":[\"Devices\"],\"Devices_Printers_Extensions\":[\"Devices_Printers\"],\"Devices_Pwm\":[\"Devices\"],\"Devices_Pwm_Provider\":[\"Devices_Pwm\"],\"Devices_Radios\":[\"Devices\"],\"Devices_Scanners\":[\"Devices\"],\"Devices_Sensors\":[\"Devices\"],\"Devices_Sensors_Custom\":[\"Devices_Sensors\"],\"Devices_SerialCommunication\":[\"Devices\"],\"Devices_SmartCards\":[\"Devices\"],\"Devices_Sms\":[\"Devices\"],\"Devices_Spi\":[\"Devices\"],\"Devices_Spi_Provider\":[\"Devices_Spi\"],\"Devices_Usb\":[\"Devices\"],\"Devices_WiFi\":[\"Devices\"],\"Devices_WiFiDirect\":[\"Devices\"],\"Devices_WiFiDirect_Services\":[\"Devices_WiFiDirect\"],\"Foundation\":[],\"Foundation_Collections\":[\"Foundation\"],\"Foundation_Diagnostics\":[\"Foundation\"],\"Foundation_Metadata\":[\"Foundation\"],\"Foundation_Numerics\":[\"Foundation\"],\"Gaming\":[\"Foundation\"],\"Gaming_Input\":[\"Gaming\"],\"Gaming_Input_Custom\":[\"Gaming_Input\"],\"Gaming_Input_ForceFeedback\":[\"Gaming_Input\"],\"Gaming_Input_Preview\":[\"Gaming_Input\"],\"Gaming_Preview\":[\"Gaming\"],\"Gaming_Preview_GamesEnumeration\":[\"Gaming_Preview\"],\"Gaming_UI\":[\"Gaming\"],\"Gaming_XboxLive\":[\"Gaming\"],\"Gaming_XboxLive_Storage\":[\"Gaming_XboxLive\"],\"Globalization\":[\"Foundation\"],\"Globalization_Collation\":[\"Globalization\"],\"Globalization_DateTimeFormatting\":[\"Globalization\"],\"Globalization_Fonts\":[\"Globalization\"],\"Globalization_NumberFormatting\":[\"Globalization\"],\"Globalization_PhoneNumberFormatting\":[\"Globalization\"],\"Graphics\":[\"Foundation\"],\"Graphics_Capture\":[\"Graphics\"],\"Graphics_DirectX\":[\"Graphics\"],\"Graphics_DirectX_Direct3D11\":[\"Graphics_DirectX\"],\"Graphics_Display\":[\"Graphics\"],\"Graphics_Display_Core\":[\"Graphics_Display\"],\"Graphics_Effects\":[\"Graphics\"],\"Graphics_Holographic\":[\"Graphics\"],\"Graphics_Imaging\":[\"Graphics\"],\"Graphics_Printing\":[\"Graphics\"],\"Graphics_Printing3D\":[\"Graphics\"],\"Graphics_Printing_OptionDetails\":[\"Graphics_Printing\"],\"Graphics_Printing_PrintSupport\":[\"Graphics_Printing\"],\"Graphics_Printing_PrintTicket\":[\"Graphics_Printing\"],\"Graphics_Printing_ProtectedPrint\":[\"Graphics_Printing\"],\"Graphics_Printing_Workflow\":[\"Graphics_Printing\"],\"Management\":[\"Foundation\"],\"Management_Core\":[\"Management\"],\"Management_Deployment\":[\"Management\"],\"Management_Deployment_Preview\":[\"Management_Deployment\"],\"Management_Policies\":[\"Management\"],\"Management_Setup\":[\"Management\"],\"Management_Update\":[\"Management\"],\"Management_Workplace\":[\"Management\"],\"Media\":[\"Foundation\"],\"Media_AppBroadcasting\":[\"Media\"],\"Media_AppRecording\":[\"Media\"],\"Media_Audio\":[\"Media\"],\"Media_Capture\":[\"Media\"],\"Media_Capture_Core\":[\"Media_Capture\"],\"Media_Capture_Frames\":[\"Media_Capture\"],\"Media_Casting\":[\"Media\"],\"Media_ClosedCaptioning\":[\"Media\"],\"Media_ContentRestrictions\":[\"Media\"],\"Media_Control\":[\"Media\"],\"Media_Core\":[\"Media\"],\"Media_Core_Preview\":[\"Media_Core\"],\"Media_Devices\":[\"Media\"],\"Media_Devices_Core\":[\"Media_Devices\"],\"Media_DialProtocol\":[\"Media\"],\"Media_Editing\":[\"Media\"],\"Media_Effects\":[\"Media\"],\"Media_FaceAnalysis\":[\"Media\"],\"Media_Import\":[\"Media\"],\"Media_MediaProperties\":[\"Media\"],\"Media_Miracast\":[\"Media\"],\"Media_Ocr\":[\"Media\"],\"Media_PlayTo\":[\"Media\"],\"Media_Playback\":[\"Media\"],\"Media_Playlists\":[\"Media\"],\"Media_Protection\":[\"Media\"],\"Media_Protection_PlayReady\":[\"Media_Protection\"],\"Media_Render\":[\"Media\"],\"Media_SpeechRecognition\":[\"Media\"],\"Media_SpeechSynthesis\":[\"Media\"],\"Media_Streaming\":[\"Media\"],\"Media_Streaming_Adaptive\":[\"Media_Streaming\"],\"Media_Transcoding\":[\"Media\"],\"Networking\":[\"Foundation\"],\"Networking_BackgroundTransfer\":[\"Networking\"],\"Networking_Connectivity\":[\"Networking\"],\"Networking_NetworkOperators\":[\"Networking\"],\"Networking_Proximity\":[\"Networking\"],\"Networking_PushNotifications\":[\"Networking\"],\"Networking_ServiceDiscovery\":[\"Networking\"],\"Networking_ServiceDiscovery_Dnssd\":[\"Networking_ServiceDiscovery\"],\"Networking_Sockets\":[\"Networking\"],\"Networking_Vpn\":[\"Networking\"],\"Networking_XboxLive\":[\"Networking\"],\"Perception\":[\"Foundation\"],\"Perception_Automation\":[\"Perception\"],\"Perception_Automation_Core\":[\"Perception_Automation\"],\"Perception_People\":[\"Perception\"],\"Perception_Spatial\":[\"Perception\"],\"Perception_Spatial_Preview\":[\"Perception_Spatial\"],\"Perception_Spatial_Surfaces\":[\"Perception_Spatial\"],\"Security\":[\"Foundation\"],\"Security_Authentication\":[\"Security\"],\"Security_Authentication_Identity\":[\"Security_Authentication\"],\"Security_Authentication_Identity_Core\":[\"Security_Authentication_Identity\"],\"Security_Authentication_OnlineId\":[\"Security_Authentication\"],\"Security_Authentication_Web\":[\"Security_Authentication\"],\"Security_Authentication_Web_Core\":[\"Security_Authentication_Web\"],\"Security_Authentication_Web_Provider\":[\"Security_Authentication_Web\"],\"Security_Authorization\":[\"Security\"],\"Security_Authorization_AppCapabilityAccess\":[\"Security_Authorization\"],\"Security_Credentials\":[\"Security\"],\"Security_Credentials_UI\":[\"Security_Credentials\"],\"Security_Cryptography\":[\"Security\"],\"Security_Cryptography_Certificates\":[\"Security_Cryptography\"],\"Security_Cryptography_Core\":[\"Security_Cryptography\"],\"Security_Cryptography_DataProtection\":[\"Security_Cryptography\"],\"Security_DataProtection\":[\"Security\"],\"Security_EnterpriseData\":[\"Security\"],\"Security_ExchangeActiveSyncProvisioning\":[\"Security\"],\"Security_Isolation\":[\"Security\"],\"Services\":[\"Foundation\"],\"Services_Maps\":[\"Services\"],\"Services_Maps_Guidance\":[\"Services_Maps\"],\"Services_Maps_LocalSearch\":[\"Services_Maps\"],\"Services_Maps_OfflineMaps\":[\"Services_Maps\"],\"Services_Store\":[\"Services\"],\"Services_TargetedContent\":[\"Services\"],\"Storage\":[\"Foundation\"],\"Storage_AccessCache\":[\"Storage\"],\"Storage_BulkAccess\":[\"Storage\"],\"Storage_Compression\":[\"Storage\"],\"Storage_FileProperties\":[\"Storage\"],\"Storage_Pickers\":[\"Storage\"],\"Storage_Pickers_Provider\":[\"Storage_Pickers\"],\"Storage_Provider\":[\"Storage\"],\"Storage_Search\":[\"Storage\"],\"Storage_Streams\":[\"Storage\"],\"System\":[\"Foundation\"],\"System_Diagnostics\":[\"System\"],\"System_Diagnostics_DevicePortal\":[\"System_Diagnostics\"],\"System_Diagnostics_Telemetry\":[\"System_Diagnostics\"],\"System_Diagnostics_TraceReporting\":[\"System_Diagnostics\"],\"System_Display\":[\"System\"],\"System_Implementation\":[\"System\"],\"System_Implementation_FileExplorer\":[\"System_Implementation\"],\"System_Inventory\":[\"System\"],\"System_Power\":[\"System\"],\"System_Profile\":[\"System\"],\"System_Profile_SystemManufacturers\":[\"System_Profile\"],\"System_RemoteDesktop\":[\"System\"],\"System_RemoteDesktop_Input\":[\"System_RemoteDesktop\"],\"System_RemoteDesktop_Provider\":[\"System_RemoteDesktop\"],\"System_RemoteSystems\":[\"System\"],\"System_Threading\":[\"System\"],\"System_Threading_Core\":[\"System_Threading\"],\"System_Update\":[\"System\"],\"System_UserProfile\":[\"System\"],\"UI\":[\"Foundation\"],\"UI_Accessibility\":[\"UI\"],\"UI_ApplicationSettings\":[\"UI\"],\"UI_Composition\":[\"UI\"],\"UI_Composition_Core\":[\"UI_Composition\"],\"UI_Composition_Desktop\":[\"UI_Composition\"],\"UI_Composition_Diagnostics\":[\"UI_Composition\"],\"UI_Composition_Effects\":[\"UI_Composition\"],\"UI_Composition_Interactions\":[\"UI_Composition\"],\"UI_Composition_Scenes\":[\"UI_Composition\"],\"UI_Core\":[\"UI\"],\"UI_Core_AnimationMetrics\":[\"UI_Core\"],\"UI_Core_Preview\":[\"UI_Core\"],\"UI_Input\":[\"UI\"],\"UI_Input_Core\":[\"UI_Input\"],\"UI_Input_Inking\":[\"UI_Input\"],\"UI_Input_Inking_Analysis\":[\"UI_Input_Inking\"],\"UI_Input_Inking_Core\":[\"UI_Input_Inking\"],\"UI_Input_Inking_Preview\":[\"UI_Input_Inking\"],\"UI_Input_Preview\":[\"UI_Input\"],\"UI_Input_Preview_Injection\":[\"UI_Input_Preview\"],\"UI_Input_Preview_Text\":[\"UI_Input_Preview\"],\"UI_Input_Spatial\":[\"UI_Input\"],\"UI_Notifications\":[\"UI\"],\"UI_Notifications_Management\":[\"UI_Notifications\"],\"UI_Notifications_Preview\":[\"UI_Notifications\"],\"UI_Popups\":[\"UI\"],\"UI_Shell\":[\"UI\"],\"UI_StartScreen\":[\"UI\"],\"UI_Text\":[\"UI\"],\"UI_Text_Core\":[\"UI_Text\"],\"UI_UIAutomation\":[\"UI\"],\"UI_UIAutomation_Core\":[\"UI_UIAutomation\"],\"UI_ViewManagement\":[\"UI\"],\"UI_ViewManagement_Core\":[\"UI_ViewManagement\"],\"UI_WebUI\":[\"UI\"],\"UI_WindowManagement\":[\"UI\"],\"UI_WindowManagement_Preview\":[\"UI_WindowManagement\"],\"Wdk\":[\"Win32_Foundation\"],\"Wdk_Devices\":[\"Wdk\"],\"Wdk_Devices_Bluetooth\":[\"Wdk_Devices\"],\"Wdk_Devices_HumanInterfaceDevice\":[\"Wdk_Devices\"],\"Wdk_Foundation\":[\"Wdk\"],\"Wdk_Graphics\":[\"Wdk\"],\"Wdk_Graphics_Direct3D\":[\"Wdk_Graphics\"],\"Wdk_NetworkManagement\":[\"Wdk\"],\"Wdk_NetworkManagement_Ndis\":[\"Wdk_NetworkManagement\"],\"Wdk_NetworkManagement_WindowsFilteringPlatform\":[\"Wdk_NetworkManagement\"],\"Wdk_Storage\":[\"Wdk\"],\"Wdk_Storage_FileSystem\":[\"Wdk_Storage\"],\"Wdk_Storage_FileSystem_Minifilters\":[\"Wdk_Storage_FileSystem\"],\"Wdk_System\":[\"Wdk\"],\"Wdk_System_IO\":[\"Wdk_System\"],\"Wdk_System_Memory\":[\"Wdk_System\"],\"Wdk_System_OfflineRegistry\":[\"Wdk_System\"],\"Wdk_System_Registry\":[\"Wdk_System\"],\"Wdk_System_SystemInformation\":[\"Wdk_System\"],\"Wdk_System_SystemServices\":[\"Wdk_System\"],\"Wdk_System_Threading\":[\"Wdk_System\"],\"Web\":[\"Foundation\"],\"Web_AtomPub\":[\"Web\"],\"Web_Http\":[\"Web\"],\"Web_Http_Diagnostics\":[\"Web_Http\"],\"Web_Http_Filters\":[\"Web_Http\"],\"Web_Http_Headers\":[\"Web_Http\"],\"Web_Syndication\":[\"Web\"],\"Web_UI\":[\"Web\"],\"Web_UI_Interop\":[\"Web_UI\"],\"Win32\":[\"Win32_Foundation\"],\"Win32_AI\":[\"Win32\"],\"Win32_AI_MachineLearning\":[\"Win32_AI\"],\"Win32_AI_MachineLearning_DirectML\":[\"Win32_AI_MachineLearning\"],\"Win32_AI_MachineLearning_WinML\":[\"Win32_AI_MachineLearning\"],\"Win32_Data\":[\"Win32\"],\"Win32_Data_HtmlHelp\":[\"Win32_Data\"],\"Win32_Data_RightsManagement\":[\"Win32_Data\"],\"Win32_Data_Xml\":[\"Win32_Data\"],\"Win32_Data_Xml_MsXml\":[\"Win32_Data_Xml\"],\"Win32_Data_Xml_XmlLite\":[\"Win32_Data_Xml\"],\"Win32_Devices\":[\"Win32\"],\"Win32_Devices_AllJoyn\":[\"Win32_Devices\"],\"Win32_Devices_Beep\":[\"Win32_Devices\"],\"Win32_Devices_BiometricFramework\":[\"Win32_Devices\"],\"Win32_Devices_Bluetooth\":[\"Win32_Devices\"],\"Win32_Devices_Cdrom\":[\"Win32_Devices\"],\"Win32_Devices_Communication\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAccess\":[\"Win32_Devices\"],\"Win32_Devices_DeviceAndDriverInstallation\":[\"Win32_Devices\"],\"Win32_Devices_DeviceQuery\":[\"Win32_Devices\"],\"Win32_Devices_Display\":[\"Win32_Devices\"],\"Win32_Devices_Dvd\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration\":[\"Win32_Devices\"],\"Win32_Devices_Enumeration_Pnp\":[\"Win32_Devices_Enumeration\"],\"Win32_Devices_Fax\":[\"Win32_Devices\"],\"Win32_Devices_FunctionDiscovery\":[\"Win32_Devices\"],\"Win32_Devices_Geolocation\":[\"Win32_Devices\"],\"Win32_Devices_HumanInterfaceDevice\":[\"Win32_Devices\"],\"Win32_Devices_ImageAcquisition\":[\"Win32_Devices\"],\"Win32_Devices_Nfc\":[\"Win32_Devices\"],\"Win32_Devices_Nfp\":[\"Win32_Devices\"],\"Win32_Devices_PortableDevices\":[\"Win32_Devices\"],\"Win32_Devices_Properties\":[\"Win32_Devices\"],\"Win32_Devices_Pwm\":[\"Win32_Devices\"],\"Win32_Devices_Sensors\":[\"Win32_Devices\"],\"Win32_Devices_SerialCommunication\":[\"Win32_Devices\"],\"Win32_Devices_Tapi\":[\"Win32_Devices\"],\"Win32_Devices_Usb\":[\"Win32_Devices\"],\"Win32_Devices_WebServicesOnDevices\":[\"Win32_Devices\"],\"Win32_Foundation\":[\"Win32\"],\"Win32_Gaming\":[\"Win32\"],\"Win32_Globalization\":[\"Win32\"],\"Win32_Graphics\":[\"Win32\"],\"Win32_Graphics_CompositionSwapchain\":[\"Win32_Graphics\"],\"Win32_Graphics_DXCore\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct2D\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct2D_Common\":[\"Win32_Graphics_Direct2D\"],\"Win32_Graphics_Direct3D\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct3D10\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct3D11\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct3D11on12\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct3D12\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct3D9\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct3D9on12\":[\"Win32_Graphics\"],\"Win32_Graphics_Direct3D_Dxc\":[\"Win32_Graphics_Direct3D\"],\"Win32_Graphics_Direct3D_Fxc\":[\"Win32_Graphics_Direct3D\"],\"Win32_Graphics_DirectComposition\":[\"Win32_Graphics\"],\"Win32_Graphics_DirectDraw\":[\"Win32_Graphics\"],\"Win32_Graphics_DirectManipulation\":[\"Win32_Graphics\"],\"Win32_Graphics_DirectWrite\":[\"Win32_Graphics\"],\"Win32_Graphics_Dwm\":[\"Win32_Graphics\"],\"Win32_Graphics_Dxgi\":[\"Win32_Graphics\"],\"Win32_Graphics_Dxgi_Common\":[\"Win32_Graphics_Dxgi\"],\"Win32_Graphics_Gdi\":[\"Win32_Graphics\"],\"Win32_Graphics_GdiPlus\":[\"Win32_Graphics\"],\"Win32_Graphics_Hlsl\":[\"Win32_Graphics\"],\"Win32_Graphics_Imaging\":[\"Win32_Graphics\"],\"Win32_Graphics_Imaging_D2D\":[\"Win32_Graphics_Imaging\"],\"Win32_Graphics_OpenGL\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing\":[\"Win32_Graphics\"],\"Win32_Graphics_Printing_PrintTicket\":[\"Win32_Graphics_Printing\"],\"Win32_Management\":[\"Win32\"],\"Win32_Management_MobileDeviceManagementRegistration\":[\"Win32_Management\"],\"Win32_Media\":[\"Win32\"],\"Win32_Media_Audio\":[\"Win32_Media\"],\"Win32_Media_Audio_Apo\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_DirectMusic\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_DirectSound\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_Endpoints\":[\"Win32_Media_Audio\"],\"Win32_Media_Audio_XAudio2\":[\"Win32_Media_Audio\"],\"Win32_Media_DeviceManager\":[\"Win32_Media\"],\"Win32_Media_DirectShow\":[\"Win32_Media\"],\"Win32_Media_DirectShow_Tv\":[\"Win32_Media_DirectShow\"],\"Win32_Media_DirectShow_Xml\":[\"Win32_Media_DirectShow\"],\"Win32_Media_DxMediaObjects\":[\"Win32_Media\"],\"Win32_Media_KernelStreaming\":[\"Win32_Media\"],\"Win32_Media_LibrarySharingServices\":[\"Win32_Media\"],\"Win32_Media_MediaFoundation\":[\"Win32_Media\"],\"Win32_Media_MediaPlayer\":[\"Win32_Media\"],\"Win32_Media_Multimedia\":[\"Win32_Media\"],\"Win32_Media_PictureAcquisition\":[\"Win32_Media\"],\"Win32_Media_Speech\":[\"Win32_Media\"],\"Win32_Media_Streaming\":[\"Win32_Media\"],\"Win32_Media_WindowsMediaFormat\":[\"Win32_Media\"],\"Win32_NetworkManagement\":[\"Win32\"],\"Win32_NetworkManagement_Dhcp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Dns\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_InternetConnectionWizard\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_IpHelper\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_MobileBroadband\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Multicast\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Ndis\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetBios\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetManagement\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetShell\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkDiagnosticsFramework\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_NetworkPolicyServer\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_P2P\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_QoS\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Rras\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_Snmp\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WNet\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WebDav\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WiFi\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectNow\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsConnectionManager\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFilteringPlatform\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsFirewall\":[\"Win32_NetworkManagement\"],\"Win32_NetworkManagement_WindowsNetworkVirtualization\":[\"Win32_NetworkManagement\"],\"Win32_Networking\":[\"Win32\"],\"Win32_Networking_ActiveDirectory\":[\"Win32_Networking\"],\"Win32_Networking_BackgroundIntelligentTransferService\":[\"Win32_Networking\"],\"Win32_Networking_Clustering\":[\"Win32_Networking\"],\"Win32_Networking_HttpServer\":[\"Win32_Networking\"],\"Win32_Networking_Ldap\":[\"Win32_Networking\"],\"Win32_Networking_NetworkListManager\":[\"Win32_Networking\"],\"Win32_Networking_RemoteDifferentialCompression\":[\"Win32_Networking\"],\"Win32_Networking_WebSocket\":[\"Win32_Networking\"],\"Win32_Networking_WinHttp\":[\"Win32_Networking\"],\"Win32_Networking_WinInet\":[\"Win32_Networking\"],\"Win32_Networking_WinSock\":[\"Win32_Networking\"],\"Win32_Networking_WindowsWebServices\":[\"Win32_Networking\"],\"Win32_Security\":[\"Win32\"],\"Win32_Security_AppLocker\":[\"Win32_Security\"],\"Win32_Security_Authentication\":[\"Win32_Security\"],\"Win32_Security_Authentication_Identity\":[\"Win32_Security_Authentication\"],\"Win32_Security_Authentication_Identity_Provider\":[\"Win32_Security_Authentication_Identity\"],\"Win32_Security_Authorization\":[\"Win32_Security\"],\"Win32_Security_Authorization_UI\":[\"Win32_Security_Authorization\"],\"Win32_Security_ConfigurationSnapin\":[\"Win32_Security\"],\"Win32_Security_Credentials\":[\"Win32_Security\"],\"Win32_Security_Cryptography\":[\"Win32_Security\"],\"Win32_Security_Cryptography_Catalog\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Certificates\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_Sip\":[\"Win32_Security_Cryptography\"],\"Win32_Security_Cryptography_UI\":[\"Win32_Security_Cryptography\"],\"Win32_Security_DiagnosticDataQuery\":[\"Win32_Security\"],\"Win32_Security_DirectoryServices\":[\"Win32_Security\"],\"Win32_Security_EnterpriseData\":[\"Win32_Security\"],\"Win32_Security_ExtensibleAuthenticationProtocol\":[\"Win32_Security\"],\"Win32_Security_Isolation\":[\"Win32_Security\"],\"Win32_Security_LicenseProtection\":[\"Win32_Security\"],\"Win32_Security_NetworkAccessProtection\":[\"Win32_Security\"],\"Win32_Security_Tpm\":[\"Win32_Security\"],\"Win32_Security_WinTrust\":[\"Win32_Security\"],\"Win32_Security_WinWlx\":[\"Win32_Security\"],\"Win32_Storage\":[\"Win32\"],\"Win32_Storage_Cabinets\":[\"Win32_Storage\"],\"Win32_Storage_CloudFilters\":[\"Win32_Storage\"],\"Win32_Storage_Compression\":[\"Win32_Storage\"],\"Win32_Storage_DataDeduplication\":[\"Win32_Storage\"],\"Win32_Storage_DistributedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_EnhancedStorage\":[\"Win32_Storage\"],\"Win32_Storage_FileHistory\":[\"Win32_Storage\"],\"Win32_Storage_FileServerResourceManager\":[\"Win32_Storage\"],\"Win32_Storage_FileSystem\":[\"Win32_Storage\"],\"Win32_Storage_Imapi\":[\"Win32_Storage\"],\"Win32_Storage_IndexServer\":[\"Win32_Storage\"],\"Win32_Storage_InstallableFileSystems\":[\"Win32_Storage\"],\"Win32_Storage_IscsiDisc\":[\"Win32_Storage\"],\"Win32_Storage_Jet\":[\"Win32_Storage\"],\"Win32_Storage_Nvme\":[\"Win32_Storage\"],\"Win32_Storage_OfflineFiles\":[\"Win32_Storage\"],\"Win32_Storage_OperationRecorder\":[\"Win32_Storage\"],\"Win32_Storage_Packaging\":[\"Win32_Storage\"],\"Win32_Storage_Packaging_Appx\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_Packaging_Opc\":[\"Win32_Storage_Packaging\"],\"Win32_Storage_ProjectedFileSystem\":[\"Win32_Storage\"],\"Win32_Storage_StructuredStorage\":[\"Win32_Storage\"],\"Win32_Storage_Vhd\":[\"Win32_Storage\"],\"Win32_Storage_VirtualDiskService\":[\"Win32_Storage\"],\"Win32_Storage_Vss\":[\"Win32_Storage\"],\"Win32_Storage_Xps\":[\"Win32_Storage\"],\"Win32_Storage_Xps_Printing\":[\"Win32_Storage_Xps\"],\"Win32_System\":[\"Win32\"],\"Win32_System_AddressBook\":[\"Win32_System\"],\"Win32_System_Antimalware\":[\"Win32_System\"],\"Win32_System_ApplicationInstallationAndServicing\":[\"Win32_System\"],\"Win32_System_ApplicationVerifier\":[\"Win32_System\"],\"Win32_System_AssessmentTool\":[\"Win32_System\"],\"Win32_System_ClrHosting\":[\"Win32_System\"],\"Win32_System_Com\":[\"Win32_System\"],\"Win32_System_Com_CallObj\":[\"Win32_System_Com\"],\"Win32_System_Com_ChannelCredentials\":[\"Win32_System_Com\"],\"Win32_System_Com_Events\":[\"Win32_System_Com\"],\"Win32_System_Com_Marshal\":[\"Win32_System_Com\"],\"Win32_System_Com_StructuredStorage\":[\"Win32_System_Com\"],\"Win32_System_Com_UI\":[\"Win32_System_Com\"],\"Win32_System_Com_Urlmon\":[\"Win32_System_Com\"],\"Win32_System_ComponentServices\":[\"Win32_System\"],\"Win32_System_Console\":[\"Win32_System\"],\"Win32_System_Contacts\":[\"Win32_System\"],\"Win32_System_CorrelationVector\":[\"Win32_System\"],\"Win32_System_DataExchange\":[\"Win32_System\"],\"Win32_System_DeploymentServices\":[\"Win32_System\"],\"Win32_System_DesktopSharing\":[\"Win32_System\"],\"Win32_System_DeveloperLicensing\":[\"Win32_System\"],\"Win32_System_Diagnostics\":[\"Win32_System\"],\"Win32_System_Diagnostics_Ceip\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ClrProfiling\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_Debug_ActiveScript\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Debug_Extensions\":[\"Win32_System_Diagnostics_Debug\"],\"Win32_System_Diagnostics_Etw\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ProcessSnapshotting\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_ToolHelp\":[\"Win32_System_Diagnostics\"],\"Win32_System_Diagnostics_TraceLogging\":[\"Win32_System_Diagnostics\"],\"Win32_System_DistributedTransactionCoordinator\":[\"Win32_System\"],\"Win32_System_Environment\":[\"Win32_System\"],\"Win32_System_ErrorReporting\":[\"Win32_System\"],\"Win32_System_EventCollector\":[\"Win32_System\"],\"Win32_System_EventLog\":[\"Win32_System\"],\"Win32_System_EventNotificationService\":[\"Win32_System\"],\"Win32_System_GroupPolicy\":[\"Win32_System\"],\"Win32_System_HostCompute\":[\"Win32_System\"],\"Win32_System_HostComputeNetwork\":[\"Win32_System\"],\"Win32_System_HostComputeSystem\":[\"Win32_System\"],\"Win32_System_Hypervisor\":[\"Win32_System\"],\"Win32_System_IO\":[\"Win32_System\"],\"Win32_System_Iis\":[\"Win32_System\"],\"Win32_System_Ioctl\":[\"Win32_System\"],\"Win32_System_JobObjects\":[\"Win32_System\"],\"Win32_System_Js\":[\"Win32_System\"],\"Win32_System_Kernel\":[\"Win32_System\"],\"Win32_System_LibraryLoader\":[\"Win32_System\"],\"Win32_System_Mailslots\":[\"Win32_System\"],\"Win32_System_Mapi\":[\"Win32_System\"],\"Win32_System_Memory\":[\"Win32_System\"],\"Win32_System_Memory_NonVolatile\":[\"Win32_System_Memory\"],\"Win32_System_MessageQueuing\":[\"Win32_System\"],\"Win32_System_MixedReality\":[\"Win32_System\"],\"Win32_System_Mmc\":[\"Win32_System\"],\"Win32_System_Ole\":[\"Win32_System\"],\"Win32_System_ParentalControls\":[\"Win32_System\"],\"Win32_System_PasswordManagement\":[\"Win32_System\"],\"Win32_System_Performance\":[\"Win32_System\"],\"Win32_System_Performance_HardwareCounterProfiling\":[\"Win32_System_Performance\"],\"Win32_System_Pipes\":[\"Win32_System\"],\"Win32_System_Power\":[\"Win32_System\"],\"Win32_System_ProcessStatus\":[\"Win32_System\"],\"Win32_System_RealTimeCommunications\":[\"Win32_System\"],\"Win32_System_Recovery\":[\"Win32_System\"],\"Win32_System_Registry\":[\"Win32_System\"],\"Win32_System_RemoteAssistance\":[\"Win32_System\"],\"Win32_System_RemoteDesktop\":[\"Win32_System\"],\"Win32_System_RemoteManagement\":[\"Win32_System\"],\"Win32_System_RestartManager\":[\"Win32_System\"],\"Win32_System_Restore\":[\"Win32_System\"],\"Win32_System_Rpc\":[\"Win32_System\"],\"Win32_System_Search\":[\"Win32_System\"],\"Win32_System_Search_Common\":[\"Win32_System_Search\"],\"Win32_System_SecurityCenter\":[\"Win32_System\"],\"Win32_System_ServerBackup\":[\"Win32_System\"],\"Win32_System_Services\":[\"Win32_System\"],\"Win32_System_SettingsManagementInfrastructure\":[\"Win32_System\"],\"Win32_System_SetupAndMigration\":[\"Win32_System\"],\"Win32_System_Shutdown\":[\"Win32_System\"],\"Win32_System_SideShow\":[\"Win32_System\"],\"Win32_System_StationsAndDesktops\":[\"Win32_System\"],\"Win32_System_SubsystemForLinux\":[\"Win32_System\"],\"Win32_System_SystemInformation\":[\"Win32_System\"],\"Win32_System_SystemServices\":[\"Win32_System\"],\"Win32_System_TaskScheduler\":[\"Win32_System\"],\"Win32_System_Threading\":[\"Win32_System\"],\"Win32_System_Time\":[\"Win32_System\"],\"Win32_System_TpmBaseServices\":[\"Win32_System\"],\"Win32_System_TransactionServer\":[\"Win32_System\"],\"Win32_System_UpdateAgent\":[\"Win32_System\"],\"Win32_System_UpdateAssessment\":[\"Win32_System\"],\"Win32_System_UserAccessLogging\":[\"Win32_System\"],\"Win32_System_Variant\":[\"Win32_System\"],\"Win32_System_VirtualDosMachines\":[\"Win32_System\"],\"Win32_System_WinRT\":[\"Win32_System\"],\"Win32_System_WinRT_AllJoyn\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Composition\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_CoreInputView\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Direct3D11\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Display\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Graphics\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Graphics_Capture\":[\"Win32_System_WinRT_Graphics\"],\"Win32_System_WinRT_Graphics_Direct2D\":[\"Win32_System_WinRT_Graphics\"],\"Win32_System_WinRT_Graphics_Imaging\":[\"Win32_System_WinRT_Graphics\"],\"Win32_System_WinRT_Holographic\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Isolation\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_ML\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Media\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Metadata\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Pdf\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Printing\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Shell\":[\"Win32_System_WinRT\"],\"Win32_System_WinRT_Storage\":[\"Win32_System_WinRT\"],\"Win32_System_WindowsProgramming\":[\"Win32_System\"],\"Win32_System_WindowsSync\":[\"Win32_System\"],\"Win32_System_Wmi\":[\"Win32_System\"],\"Win32_UI\":[\"Win32\"],\"Win32_UI_Accessibility\":[\"Win32_UI\"],\"Win32_UI_Animation\":[\"Win32_UI\"],\"Win32_UI_ColorSystem\":[\"Win32_UI\"],\"Win32_UI_Controls\":[\"Win32_UI\"],\"Win32_UI_Controls_Dialogs\":[\"Win32_UI_Controls\"],\"Win32_UI_Controls_RichEdit\":[\"Win32_UI_Controls\"],\"Win32_UI_HiDpi\":[\"Win32_UI\"],\"Win32_UI_Input\":[\"Win32_UI\"],\"Win32_UI_Input_Ime\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Ink\":[\"Win32_UI_Input\"],\"Win32_UI_Input_KeyboardAndMouse\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Pointer\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Radial\":[\"Win32_UI_Input\"],\"Win32_UI_Input_Touch\":[\"Win32_UI_Input\"],\"Win32_UI_Input_XboxController\":[\"Win32_UI_Input\"],\"Win32_UI_InteractionContext\":[\"Win32_UI\"],\"Win32_UI_LegacyWindowsEnvironmentFeatures\":[\"Win32_UI\"],\"Win32_UI_Magnification\":[\"Win32_UI\"],\"Win32_UI_Notifications\":[\"Win32_UI\"],\"Win32_UI_Ribbon\":[\"Win32_UI\"],\"Win32_UI_Shell\":[\"Win32_UI\"],\"Win32_UI_Shell_Common\":[\"Win32_UI_Shell\"],\"Win32_UI_Shell_PropertiesSystem\":[\"Win32_UI_Shell\"],\"Win32_UI_TabletPC\":[\"Win32_UI\"],\"Win32_UI_TextServices\":[\"Win32_UI\"],\"Win32_UI_WindowsAndMessaging\":[\"Win32_UI\"],\"Win32_UI_Wpf\":[\"Win32_UI\"],\"Win32_Web\":[\"Win32\"],\"Win32_Web_InternetExplorer\":[\"Win32_Web\"],\"default\":[\"std\"],\"docs\":[],\"std\":[\"windows-collections/std\",\"windows-core/std\",\"windows-future/std\",\"windows-numerics/std\"]}}", - "windows_aarch64_gnullvm_0.42.2": "{\"dependencies\":[],\"features\":{}}", - "windows_aarch64_gnullvm_0.48.5": "{\"dependencies\":[],\"features\":{}}", - "windows_aarch64_gnullvm_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_aarch64_gnullvm_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "windows_aarch64_msvc_0.42.2": "{\"dependencies\":[],\"features\":{}}", - "windows_aarch64_msvc_0.48.5": "{\"dependencies\":[],\"features\":{}}", - "windows_aarch64_msvc_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_aarch64_msvc_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_gnu_0.42.2": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_gnu_0.48.5": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_gnu_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_gnu_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_gnullvm_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_gnullvm_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_msvc_0.42.2": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_msvc_0.48.5": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_msvc_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_i686_msvc_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnu_0.42.2": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnu_0.48.5": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnu_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnu_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnullvm_0.42.2": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnullvm_0.48.5": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnullvm_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_gnullvm_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_msvc_0.42.2": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_msvc_0.48.5": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_msvc_0.52.6": "{\"dependencies\":[],\"features\":{}}", - "windows_x86_64_msvc_0.53.1": "{\"dependencies\":[],\"features\":{}}", - "winnow_0.7.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"annotate-snippets\",\"req\":\"^0.11.4\"},{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.100\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"circular\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"is_terminal_polyfill\",\"optional\":true,\"req\":\"^1.48.1\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2.1.1\"},{\"features\":[\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"term-transcript\",\"req\":\"^0.2.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\",\"dep:is_terminal_polyfill\",\"dep:terminal_size\"],\"default\":[\"std\"],\"simd\":[\"dep:memchr\"],\"std\":[\"alloc\",\"memchr?/std\"],\"unstable-doc\":[\"alloc\",\"std\",\"simd\",\"unstable-recover\"],\"unstable-recover\":[]}}", - "wiremock_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-rt\",\"req\":\"^2.10.0\"},{\"name\":\"assert-json-diff\",\"req\":\"^2.0.2\"},{\"features\":[\"attributes\",\"tokio1\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.2\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"deadpool\",\"req\":\"^0.12.2\"},{\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"http\",\"req\":\"^1.3\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"full\"],\"name\":\"hyper\",\"req\":\"^1.7\"},{\"features\":[\"tokio\",\"server\",\"http1\",\"http2\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"regex\",\"req\":\"^1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.23\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"rt\",\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"name\":\"url\",\"req\":\"^2.5\"}],\"features\":{}}", - "wit-bindgen-core_0.51.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.72\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.3.19\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.218\"},{\"name\":\"wit-parser\",\"req\":\"^0.244.0\"}],\"features\":{\"clap\":[\"dep:clap\"],\"serde\":[\"dep:serde\"]}}", - "wit-bindgen-rust-macro_0.51.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.72\"},{\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.89\"},{\"name\":\"wit-bindgen-core\",\"req\":\"^0.51.0\"},{\"name\":\"wit-bindgen-rust\",\"req\":\"^0.51.0\"}],\"features\":{\"async\":[]}}", - "wit-bindgen-rust_0.51.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.72\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.3.19\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.218\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.89\"},{\"default_features\":false,\"name\":\"wasm-metadata\",\"req\":\"^0.244.0\"},{\"name\":\"wit-bindgen-core\",\"req\":\"^0.51.0\"},{\"name\":\"wit-component\",\"req\":\"^0.244.0\"}],\"features\":{\"clap\":[\"dep:clap\",\"wit-bindgen-core/clap\"],\"serde\":[\"dep:serde\",\"wit-bindgen-core/serde\"]}}", - "wit-bindgen_0.51.0": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.3.3\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"wit-bindgen-rust-macro\",\"optional\":true,\"req\":\"^0.51.0\"}],\"features\":{\"async\":[\"std\",\"wit-bindgen-rust-macro?/async\"],\"async-spawn\":[\"async\",\"dep:futures\"],\"bitflags\":[\"dep:bitflags\"],\"default\":[\"macros\",\"realloc\",\"async\",\"std\",\"bitflags\"],\"inter-task-wakeup\":[\"async\"],\"macros\":[\"dep:wit-bindgen-rust-macro\"],\"realloc\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\"],\"std\":[]}}", - "wit-bindgen_0.57.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.11.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"default_features\":false,\"name\":\"wit-bindgen-rust-macro\",\"optional\":true,\"req\":\"^0.57.1\"}],\"features\":{\"async\":[],\"async-spawn\":[\"async\",\"dep:futures\",\"std\"],\"bitflags\":[\"dep:bitflags\"],\"default\":[\"macros\",\"realloc\",\"async\",\"std\",\"bitflags\",\"macro-string\"],\"futures-stream\":[\"async\",\"dep:futures\"],\"inter-task-wakeup\":[\"async\"],\"macro-string\":[\"wit-bindgen-rust-macro?/macro-string\"],\"macros\":[\"dep:wit-bindgen-rust-macro\"],\"realloc\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\"],\"std\":[]}}", - "wit-component_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.3.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"libtest-mimic\",\"req\":\"^0.8.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"wasmparser\"],\"name\":\"wasm-encoder\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"name\":\"wasm-metadata\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"oci\"],\"kind\":\"dev\",\"name\":\"wasm-metadata\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"simd\"],\"name\":\"wasmparser\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"features\"],\"kind\":\"dev\",\"name\":\"wasmparser\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"component-model\"],\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"cranelift\",\"component-model\",\"runtime\",\"gc-drc\"],\"kind\":\"dev\",\"name\":\"wasmtime\",\"req\":\"^34.0.1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"default_features\":false,\"name\":\"wast\",\"optional\":true,\"req\":\"^244.0.0\"},{\"default_features\":false,\"name\":\"wat\",\"optional\":true,\"req\":\"^1.244.0\"},{\"default_features\":false,\"features\":[\"component-model\"],\"kind\":\"dev\",\"name\":\"wat\",\"req\":\"^1.244.0\"},{\"features\":[\"decoding\",\"serde\"],\"name\":\"wit-parser\",\"req\":\"^0.244.0\"}],\"features\":{\"dummy-module\":[\"dep:wat\"],\"semver-check\":[\"dummy-module\"],\"wat\":[\"dep:wast\",\"dep:wat\"]}}", - "wit-parser_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"id-arena\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"libtest-mimic\",\"req\":\"^0.8.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"name\":\"semver\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"unicode-xid\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"validate\",\"component-model\",\"features\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"component-model\"],\"name\":\"wat\",\"optional\":true,\"req\":\"^1.244.0\"}],\"features\":{\"decoding\":[\"dep:wasmparser\"],\"default\":[\"serde\",\"decoding\"],\"serde\":[\"dep:serde\",\"dep:serde_derive\",\"indexmap/serde\",\"serde_json\"],\"wat\":[\"decoding\",\"dep:wat\"]}}", - "writeable_0.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"either\":[\"dep:either\"]}}", - "x509-parser_0.16.0": "{\"dependencies\":[{\"features\":[\"datetime\"],\"name\":\"asn1-rs\",\"req\":\"^0.6.1\"},{\"name\":\"data-encoding\",\"req\":\"^2.2.1\"},{\"features\":[\"bigint\"],\"name\":\"der-parser\",\"req\":\"^9.0\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"features\":[\"crypto\",\"x509\",\"x962\"],\"name\":\"oid-registry\",\"req\":\"^0.7\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.7\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.2\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.20\"}],\"features\":{\"default\":[],\"validate\":[],\"verify\":[\"ring\"]}}", - "xattr_1.6.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.150\",\"target\":\"cfg(any(target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^1.0.0\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\", target_os = \\\"macos\\\", target_os = \\\"hurd\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"default\":[\"unsupported\"],\"unsupported\":[]}}", - "xmlparser_0.13.6": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "xterm-color_1.0.2": "{\"dependencies\":[],\"features\":{}}", - "yasna_0.5.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.1\"}],\"features\":{\"default\":[],\"std\":[]}}", - "yoke-derive_0.8.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"fold\"],\"name\":\"syn\",\"req\":\"^2.0.21\"},{\"name\":\"synstructure\",\"req\":\"^0.13.0\"}],\"features\":{}}", - "yoke_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"yoke-derive\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"}],\"features\":{\"alloc\":[\"stable_deref_trait/alloc\",\"zerofrom/alloc\"],\"default\":[\"alloc\",\"zerofrom\"],\"derive\":[\"dep:yoke-derive\",\"zerofrom/derive\"],\"serde\":[],\"zerofrom\":[\"dep:zerofrom\"]}}", - "z3-sys_0.10.9": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"blocking\"],\"kind\":\"build\",\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.22\"},{\"kind\":\"build\",\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.140\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"},{\"kind\":\"build\",\"name\":\"zip\",\"optional\":true,\"req\":\"^8.2\"}],\"features\":{\"bundled\":[\"dep:reqwest\",\"dep:serde_json\",\"dep:zip\",\"dep:cmake\"],\"default\":[\"reqwest-rustls\"],\"deprecated-static-link-z3\":[],\"gh-release\":[\"dep:reqwest\",\"dep:serde_json\",\"dep:zip\"],\"reqwest-native-tls-vendored\":[\"reqwest/native-tls-vendored\"],\"reqwest-rustls\":[\"reqwest/rustls-tls\"],\"static-link-z3\":[\"bundled\",\"deprecated-static-link-z3\"],\"vcpkg\":[\"dep:vcpkg\"]}}", - "z3_0.19.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"num\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.10.0\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1\"},{\"name\":\"z3-sys\",\"req\":\"^0.10.9\"}],\"features\":{\"bundled\":[\"z3-sys/bundled\"],\"default\":[\"z3_4_8_15\"],\"gh-release\":[\"z3-sys/gh-release\"],\"static-link-z3\":[\"z3-sys/bundled\",\"z3-sys/deprecated-static-link-z3\"],\"vcpkg\":[\"z3-sys/vcpkg\"],\"z3_4_8_13\":[],\"z3_4_8_14\":[\"z3_4_8_13\"],\"z3_4_8_15\":[\"z3_4_8_14\"]}}", - "zerocopy-derive_0.8.48": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"dissimilar\",\"req\":\"^1.0.9\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"=0.2.17\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.1\"},{\"name\":\"quote\",\"req\":\"^1.0.40\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"visit\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", - "zerocopy_0.8.48": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"elain\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"zerocopy-derive\",\"req\":\"=0.8.48\",\"target\":\"cfg(any())\"},{\"name\":\"zerocopy-derive\",\"optional\":true,\"req\":\"=0.8.48\"},{\"kind\":\"dev\",\"name\":\"zerocopy-derive\",\"req\":\"=0.8.48\"}],\"features\":{\"__internal_use_only_features_that_work_on_stable\":[\"alloc\",\"derive\",\"simd\",\"std\"],\"alloc\":[],\"derive\":[\"zerocopy-derive\"],\"float-nightly\":[],\"simd\":[],\"simd-nightly\":[\"simd\"],\"std\":[\"alloc\"]}}", - "zerofrom-derive_0.1.7": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"fold\"],\"name\":\"syn\",\"req\":\"^2.0.21\"},{\"name\":\"synstructure\",\"req\":\"^0.13.0\"}],\"features\":{}}", - "zerofrom_0.1.7": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zerofrom-derive\",\"optional\":true,\"req\":\"^0.1.6\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"dep:zerofrom-derive\"]}}", - "zerofrom_0.1.8": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zerofrom-derive\",\"optional\":true,\"req\":\"^0.1.6\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"dep:zerofrom-derive\"]}}", - "zeroize_1.8.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"zeroize_derive\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"aarch64\":[],\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"zeroize_derive\"],\"simd\":[],\"std\":[\"alloc\"]}}", - "zeroize_1.9.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"zeroize_derive\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"aarch64\":[],\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"zeroize_derive\"],\"simd\":[],\"std\":[\"alloc\"]}}", - "zeroize_derive_1.5.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\",\"visit\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "zerotrie_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"litemap\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"zerovec?/alloc\"],\"databake\":[\"dep:databake\",\"zerovec?/databake\"],\"default\":[],\"dense\":[\"dep:zerovec\"],\"litemap\":[\"dep:litemap\",\"alloc\"],\"serde\":[\"dep:serde_core\",\"dep:litemap\",\"alloc\",\"litemap/serde\",\"zerovec?/serde\"],\"yoke\":[\"dep:yoke\"],\"zerofrom\":[\"dep:zerofrom\"],\"zerovec\":[\"dep:zerovec\"]}}", - "zerovec-derive_0.11.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.21\"}],\"features\":{}}", - "zerovec_0.11.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.43.2\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"schemars\":[\"dep:schemars\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", - "zip_8.5.1": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"bitstream-io\",\"optional\":true,\"req\":\"^4.9\"},{\"default_features\":false,\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.27\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"crc32fast\",\"req\":\"^1.5\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.10\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"std\",\"encoder\",\"optimization\",\"xz\"],\"name\":\"lzma-rust2\",\"optional\":true,\"req\":\"^0.16.1\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"ppmd-rust\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.11\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"wasm-bindgen\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.47\"},{\"name\":\"typed-path\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.56\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8.3\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13.3\"}],\"features\":{\"_arbitrary\":[\"dep:arbitrary\"],\"_bzip2_any\":[],\"_deflate-any\":[],\"aes-crypto\":[\"dep:aes\",\"dep:constant_time_eq\",\"getrandom/std\",\"dep:hmac\",\"dep:pbkdf2\",\"dep:sha1\",\"dep:zeroize\"],\"bzip2\":[\"dep:bzip2\",\"bzip2/default\",\"_bzip2_any\"],\"bzip2-rs\":[\"dep:bzip2\",\"bzip2/bzip2-sys\",\"_bzip2_any\"],\"chrono\":[\"dep:chrono\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"ppmd\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"deflate-zopfli\",\"deflate-flate2-zlib-rs\"],\"deflate-flate2\":[\"_deflate-any\",\"dep:flate2\"],\"deflate-flate2-zlib\":[\"deflate-flate2\",\"flate2/zlib\"],\"deflate-flate2-zlib-ng\":[\"deflate-flate2\",\"flate2/zlib-ng\"],\"deflate-flate2-zlib-ng-compat\":[\"deflate-flate2\",\"flate2/zlib-ng-compat\"],\"deflate-flate2-zlib-rs\":[\"deflate-flate2\",\"flate2/zlib-rs\"],\"deflate-zopfli\":[\"dep:zopfli\",\"_deflate-any\"],\"deprecated-time\":[],\"jiff-02\":[\"dep:jiff\"],\"legacy-zip\":[\"bitstream-io\"],\"lzma\":[\"dep:lzma-rust2\"],\"nt-time\":[\"dep:nt-time\"],\"ppmd\":[\"dep:ppmd-rust\"],\"time\":[\"dep:time\"],\"unreserved\":[],\"xz\":[\"dep:lzma-rust2\"]}}", - "zlib-rs_0.6.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-api\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", - "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", - "zopfli_0.8.3": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.19.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.5.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"default\":[\"gzip\",\"std\",\"zlib\"],\"gzip\":[\"dep:crc32fast\"],\"nightly\":[\"crc32fast?/nightly\"],\"std\":[\"crc32fast?/std\",\"dep:log\",\"simd-adler32?/std\"],\"zlib\":[\"dep:simd-adler32\"]}}", - "zstd-safe_7.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.15\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"fat-lto\":[\"zstd-sys/fat-lto\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"seekable\":[\"zstd-sys/seekable\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"thin-lto\":[\"zstd-sys/thin-lto\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", - "zstd-sys_2.0.16+zstd.1.5.7": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.45\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.28\"}],\"features\":{\"debug\":[],\"default\":[\"legacy\",\"zdict_builder\",\"bindgen\"],\"experimental\":[],\"fat-lto\":[],\"legacy\":[],\"no_asm\":[],\"no_wasm_shim\":[],\"non-cargo\":[],\"pkg-config\":[],\"seekable\":[],\"std\":[],\"thin\":[],\"thin-lto\":[],\"zdict_builder\":[],\"zstdmt\":[]}}", - "zstd_0.13.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"humansize\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"zstd-safe\",\"req\":\"^7.1.0\"}],\"features\":{\"arrays\":[\"zstd-safe/arrays\"],\"bindgen\":[\"zstd-safe/bindgen\"],\"debug\":[\"zstd-safe/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-safe/experimental\"],\"fat-lto\":[\"zstd-safe/fat-lto\"],\"legacy\":[\"zstd-safe/legacy\"],\"no_asm\":[\"zstd-safe/no_asm\"],\"pkg-config\":[\"zstd-safe/pkg-config\"],\"thin\":[\"zstd-safe/thin\"],\"thin-lto\":[\"zstd-safe/thin-lto\"],\"wasm\":[],\"zdict_builder\":[\"zstd-safe/zdict_builder\"],\"zstdmt\":[\"zstd-safe/zstdmt\"]}}" - }, - "@@rules_rs+//rs/toolchains:module_extension.bzl%toolchains": { - "cargo-1.95.0-aarch64-apple-darwin.tar.xz": "6c2ffed8e1ac9cf4dc9e80f282a869a6b237a153e7c55cca039d33de29d80aaf", - "cargo-1.95.0-aarch64-pc-windows-msvc.tar.xz": "e645b30fa035a18aa12d28b699052014c7efa9dd4a33dabd223f0d16b5fa28e8", - "cargo-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "7c070aeba9bbf12073646995a03f36c346bb5f541d0078ba6d9dc2a7adaaf6af", - "cargo-1.95.0-x86_64-apple-darwin.tar.xz": "e2e1131ade2dddc0d779e0ab3a6a990085c7a654951235742823c3a1ce0f190f", - "cargo-1.95.0-x86_64-pc-windows-msvc.tar.xz": "cab2606cb2d0aa31c55d50512fe07a9f15e893227566fbeb448306760cd0d2bf", - "cargo-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "e74edd2cf7d0f1f1383b4f00eb90c843750bc489e2ccf7214e6476678a907425", - "clippy-1.95.0-aarch64-apple-darwin.tar.xz": "fd183baa023d0c4e0c5b8184226e2d4c85126adf156cb1f3a726ec593bba8d62", - "clippy-1.95.0-aarch64-pc-windows-msvc.tar.xz": "44c1b7ada72aa8f3fcaceb37a3899665bc9b160c2fea77879c8ecb65a9e97eba", - "clippy-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "fb021e0c0fc2238be9266d7614f4a26bc372544c4cba3528d729ab24ad229fc9", - "clippy-1.95.0-x86_64-apple-darwin.tar.xz": "e47367f6b1489d74cbba93b387310adcb82e27a51e44b2c6ff543eb4f199fe32", - "clippy-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ddc151d6f58c6658b7380292ecaef36e62d063bbdbf7f5802669810575bb5b75", - "clippy-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "ac779bc9839dd47180806b133e4e2563c4a34716284cd5b8fede8ef289f452ca", - "rust-analyzer-1.95.0-aarch64-apple-darwin.tar.xz": "11231fc6574301b94bd379af4ef409caef7c65b877bcecf2b227dc0d74aa0ec7", - "rust-analyzer-1.95.0-aarch64-pc-windows-msvc.tar.xz": "92958624f23d4b0980748ac9e6d67f6f67a868f8224e8c6240e3f84145e2d805", - "rust-analyzer-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "b37e5b9aad624e54228254f98a710ee19ad464fe7ada93ef12e20c87886a0047", - "rust-analyzer-1.95.0-x86_64-apple-darwin.tar.xz": "6cd111900e13fd19b188c5d8844b34136af3967066c0ea2914ce5c3508296c85", - "rust-analyzer-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ba58e349f5e8b0ef13735c48d4ad8d8c7664472f8403f3c9d97b291bd54a7638", - "rust-analyzer-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "a9d71c6e7427c45afcd846a8b34a3e3301ae7a0e91a2bcf929326af77a7dc68e", - "rust-src-1.95.0.tar.xz": "67b09138c8db96afc4bbfc69ea771ac9a091fd777698acb43f6dfd9fb7dea363", - "rust-std-1.95.0-aarch64-apple-darwin.tar.xz": "9b30089b0f767cb91b2190ffec55a9beeb2a21a1405d8da0f664d7e09d08e6d8", - "rust-std-1.95.0-aarch64-apple-ios-macabi.tar.xz": "0e1760828f4e0fa1cde0061ba5680619dcc1cdcafec9242cc18dc4547c73b1cd", - "rust-std-1.95.0-aarch64-apple-ios-sim.tar.xz": "4bfe5b0c74c10d121a8ac60f1833c7714b963f9130f6256ca313d94405267deb", - "rust-std-1.95.0-aarch64-apple-ios.tar.xz": "6fcc42d8dbba4910a128ffa32d62a730339a7e3882a90341a881f2edf66ff55a", - "rust-std-1.95.0-aarch64-linux-android.tar.xz": "de5e8fa5d955809891eea77682811fc90be705f78883bd94071e98f5a738d05b", - "rust-std-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "93d810b8872771afe04f66aa30a4eee48736aca693186e4c7cc2766e1a82e340", - "rust-std-1.95.0-aarch64-pc-windows-msvc.tar.xz": "be21b5a8a71c49b4dcbc19956233b0de7bfda3ee3c8a199148299f867e95cb42", - "rust-std-1.95.0-aarch64-unknown-fuchsia.tar.xz": "b311a0523f75e031d683d983515edb9baaf22a843dbd44ce2a30a3204752f592", - "rust-std-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "3a21b271b1ff973b94d69b25e7a39992f9fbcae1ab6d9475844a23e6ad3908ac", - "rust-std-1.95.0-aarch64-unknown-linux-musl.tar.xz": "f6710416ed9a7d5cf2a15efa761eb79a1deeb43f9961bbe05cc97bec4ef9064a", - "rust-std-1.95.0-aarch64-unknown-none-softfloat.tar.xz": "54d691468e25e7989b022a171337beadf78b5202877b312b75182b7f93efbb8b", - "rust-std-1.95.0-aarch64-unknown-none.tar.xz": "2b0c986dc9902866311f1fe2d44bc2bd84479d2ac84ed7ada76a5eb7ba37080f", - "rust-std-1.95.0-aarch64-unknown-uefi.tar.xz": "ca657564103024d345ca32e8e4ade7ebb395a51163d2897d9e5f8373c025e49e", - "rust-std-1.95.0-arm-linux-androideabi.tar.xz": "12a9c5fa24608159c2b2bd50abc0c6d0add407c0258cee894c2f61c07051a9c4", - "rust-std-1.95.0-arm-unknown-linux-gnueabi.tar.xz": "f6cd592dacdf41f724ee90a2f34db028e37ca2a7fb26fa86e93e8fd68e24d066", - "rust-std-1.95.0-arm-unknown-linux-gnueabihf.tar.xz": "fda8408ea17881c6529e27e58672d6c628f786cad557fac92856077e7a610239", - "rust-std-1.95.0-arm-unknown-linux-musleabi.tar.xz": "62f21fabc209fd0de53156764fc426da74c59525bb60cb4c1c3ffd1be0bbe00b", - "rust-std-1.95.0-arm-unknown-linux-musleabihf.tar.xz": "2842ce67f7a4c68c6e8b30ad3bb36484fb745edcc2694b2a36bf0609cf758044", - "rust-std-1.95.0-armv7-linux-androideabi.tar.xz": "61e95e144986c52ff9fa2fe3c249a68b2bf268adb2a2eeb81d80c180e43027f1", - "rust-std-1.95.0-armv7-unknown-linux-gnueabi.tar.xz": "e23454d6ca7fc3f5eb7cf9241572765176d86e9f45d4f394de31a5fd794e523f", - "rust-std-1.95.0-armv7-unknown-linux-gnueabihf.tar.xz": "bd319e18ca2dad0450f76277874d56356330da536be8cf271509f8e6f28ac6de", - "rust-std-1.95.0-armv7-unknown-linux-musleabi.tar.xz": "a49bc987e0531800f92825ce61402b5734ea747caa8970d3373506742ed7daa6", - "rust-std-1.95.0-armv7-unknown-linux-musleabihf.tar.xz": "77f9aaff4669c076edc96cdb99dc21749d2c692c862232b52176572c289fe671", - "rust-std-1.95.0-i686-linux-android.tar.xz": "e7b5e18d1d4119c7c1454ee8427933f27aeff9e81a22248c2829f5f299d3a937", - "rust-std-1.95.0-i686-pc-windows-gnu.tar.xz": "2e98fb94fc500690d7e72e071ce29dd98790ff518163963f5c82c32afba9231d", - "rust-std-1.95.0-i686-pc-windows-gnullvm.tar.xz": "b429a8c456d6a1815ece064102bd6a25f67d46db4f6d7b79dbe7d1b9d1f78e0f", - "rust-std-1.95.0-i686-pc-windows-msvc.tar.xz": "6206fd7e8bd119e9d1ba1c425ad25c282512eb0d5659f2dcb4be224b84715706", - "rust-std-1.95.0-i686-unknown-freebsd.tar.xz": "3b13b3ccecf482c0da3e94f19e0aa5e8e375622f4e09b30e6dae2b7638bee63f", - "rust-std-1.95.0-i686-unknown-linux-gnu.tar.xz": "527c5d5249a7f77b48d3c9da3ac512d27b47f43d08dbe3c6f82a3d5b35d8aa27", - "rust-std-1.95.0-i686-unknown-linux-musl.tar.xz": "af4d3e7aabb63d39a7a2ff5435cc993b65ff38a2d2e23f1967e519037a1b0455", - "rust-std-1.95.0-i686-unknown-uefi.tar.xz": "3233985273616ec36861f2d50b4a025c903b2bb8c45b171c0ae9e2de8342125b", - "rust-std-1.95.0-loongarch64-unknown-linux-gnu.tar.xz": "eaf2c37c3293eea742e7ab20f25718ab19c93bd381df8823113fce70460c19c3", - "rust-std-1.95.0-loongarch64-unknown-linux-musl.tar.xz": "959b1bf99bc724c87bc4f2c0d184eb0554c134885c05c90d060eb572838924fd", - "rust-std-1.95.0-loongarch64-unknown-none.tar.xz": "139e8bdc86cbc21e149a2229c092f74741c907ef6424fa8ce8d435db47895cb6", - "rust-std-1.95.0-powerpc-unknown-linux-gnu.tar.xz": "59e0abbaa246502521e37c55b8d6cf88d5b8a697b0c70c61ec189937308f7246", - "rust-std-1.95.0-powerpc64-unknown-linux-gnu.tar.xz": "cc7fb9aa289ff1756502ae16a05e2885289165f01ed94a7c2db6576b3dae74a6", - "rust-std-1.95.0-powerpc64le-unknown-linux-gnu.tar.xz": "2370d9266051a0b23346d42e43a00f91b2daff22a963fb03e28ae50cb0b76c50", - "rust-std-1.95.0-powerpc64le-unknown-linux-musl.tar.xz": "0362ba4ecfe0bb508f0d2b064c6fbbfe72604d5ae1989d6a8a7b4fe5ff1889f1", - "rust-std-1.95.0-riscv32imc-unknown-none-elf.tar.xz": "04befebf3b15372dacb7e6c0fcdab842c17a63ea58bf2f4cdccd7182ae9195c6", - "rust-std-1.95.0-riscv64gc-unknown-linux-gnu.tar.xz": "50fe7869e166bb4c990a0e1664366b1ffdbe669664b7663cd03c079bd0efdcac", - "rust-std-1.95.0-riscv64gc-unknown-linux-musl.tar.xz": "e01bdbf5d6fa3e529671d49e87ba81dc9612101144f3ee5a0e1de3c48f27b47c", - "rust-std-1.95.0-riscv64gc-unknown-none-elf.tar.xz": "a4cb7a1527f3b56a39464e5ca2b174a27b708b26d78e604735eb5ffd9ee4d20b", - "rust-std-1.95.0-s390x-unknown-linux-gnu.tar.xz": "31978c1286afff9a0bb7f01c2ae4a39f40727b6100a82b6d934f146b06cde510", - "rust-std-1.95.0-sparc64-unknown-linux-gnu.tar.xz": "88619b2413d218c119a2060e583a9e835fa5f9cf6ac038070eec10b02c191056", - "rust-std-1.95.0-thumbv6m-none-eabi.tar.xz": "602ec023c4615fc1c2d78b688554d42fa525e07e861c052f406fd7a607e5d5ee", - "rust-std-1.95.0-thumbv7em-none-eabi.tar.xz": "fb671966ba9aede333956ed43fcfe114ec890ca6e70369c9f3219871ee3ae8ae", - "rust-std-1.95.0-thumbv7em-none-eabihf.tar.xz": "fa3d189c09b64d818ad65a3fec1ce1c7d7b3908aea6fc4607a3fcc05067cad81", - "rust-std-1.95.0-thumbv7m-none-eabi.tar.xz": "e9e39f483ad4c1ce55fa4f508009e8d7d7ef25efe6592f7bfbabc159a24658ff", - "rust-std-1.95.0-thumbv8m.main-none-eabi.tar.xz": "3b64bffb193b37e83dc7b169add55f0ef3298220f8475468be4bb87276a68105", - "rust-std-1.95.0-thumbv8m.main-none-eabihf.tar.xz": "25ec92187d3a45fb1e397b0ba6000341d872523925a99a48b1978bdf1e6038cb", - "rust-std-1.95.0-wasm32-unknown-emscripten.tar.xz": "967b92a8682e8b8a1a459d776b36e41c906cdcac1008fef70e4800fec40d6864", - "rust-std-1.95.0-wasm32-unknown-unknown.tar.xz": "5587b89ff69623d09e476439d44a24453b4e4ea3d5e0b53a5c0a935151ff3fd1", - "rust-std-1.95.0-wasm32-wasip1-threads.tar.xz": "9079935a00a3c3aaf284957bbe82972983ce2708019687cec1f4988c30c1e0f3", - "rust-std-1.95.0-wasm32-wasip1.tar.xz": "86e5b6d98c7520bb9c3ad4f8cbbbf14beaf230b0f06b437db398e3c4f7dae43e", - "rust-std-1.95.0-wasm32-wasip2.tar.xz": "68146eb4c887431379966efa21d75dc957f18bd1166239c1deaa53fe38cb9ab4", - "rust-std-1.95.0-x86_64-apple-darwin.tar.xz": "2be13c14122b8d4d09b7f7c434fca9ae7215ec72049944189c88c4d9128ce504", - "rust-std-1.95.0-x86_64-apple-ios-macabi.tar.xz": "60b92e51e87f84046e0b19ccedc88c45b4b62c3ab10351f8473453f341c894f1", - "rust-std-1.95.0-x86_64-apple-ios.tar.xz": "11abb7b1c92b5a88b8c3ba21ee596a1be7dee5e817b336f26b4968d8ed5513ad", - "rust-std-1.95.0-x86_64-linux-android.tar.xz": "77b8e2be4a6e784a63cd77de944864c8044ddf4d5c7d56f663ada8a38a8319c4", - "rust-std-1.95.0-x86_64-pc-windows-gnu.tar.xz": "f57e045016a04130125fb43295d95f9ad2bebc296150eadb031dbf5167ad12bd", - "rust-std-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "3c52a0e34e0b4abe4439cabd77383408f5f9c80b6e249fbc0855df424b45008d", - "rust-std-1.95.0-x86_64-pc-windows-msvc.tar.xz": "7c659bdc88646e7e1befa370881bd311be87b26f006933a28b40dcab2f7cc832", - "rust-std-1.95.0-x86_64-unknown-freebsd.tar.xz": "dfe913c2f477172db10d3723d9f5c536d8b6f42776c979cc855820d8249adec3", - "rust-std-1.95.0-x86_64-unknown-fuchsia.tar.xz": "faa2bc09a3992f1d81b538121d64a3a396f4ec666e665c79d2ab47461c2d3206", - "rust-std-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "047ea7098803d3500fa1072e9cee5392697e21525559e4458128a2bf874aa382", - "rust-std-1.95.0-x86_64-unknown-linux-musl.tar.xz": "aee540abf132920f791ef781489851a078d69dff493fb628d49c1d573f92bb3a", - "rust-std-1.95.0-x86_64-unknown-netbsd.tar.xz": "7a82b71c53f20cb147a340819fcab645da220b312a96194531e631ee99783a7d", - "rust-std-1.95.0-x86_64-unknown-none.tar.xz": "7c151c0e7bf3b0b4d7136774cd3686e5f691b761b648b17e83af58e7669d3e01", - "rust-std-1.95.0-x86_64-unknown-uefi.tar.xz": "4cc55629480aa8ab5b39eb6b7458433b48461d6626fdea0330fb88e23af818ea", - "rustc-1.95.0-aarch64-apple-darwin.tar.xz": "149e85a285b6eba58eb6c8bdf7deb1b93763890598e62cb635a712e3a8454f04", - "rustc-1.95.0-aarch64-pc-windows-msvc.tar.xz": "0dbec9739b93427ccdd3948c3b1f83cec42e4c9545d930a8d1e1464ff4092c5f", - "rustc-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "0fe3689eeaed603e5ef24572d11597d3edadaefd2cb181674ad621260f2501d2", - "rustc-1.95.0-src.tar.xz": "62b67230754da642a264ca0cb9fc08820c54e2ed7b3baba0289876d4cdb48c08", - "rustc-1.95.0-x86_64-apple-darwin.tar.xz": "33db457715446a69ed6f69f78f5fbb9ca8e17a16585d1d7a0060479bfe4c7afc", - "rustc-1.95.0-x86_64-pc-windows-msvc.tar.xz": "4cb1f3b578adc6541cbe13a6f85f1fd8c0ce643d90b506a36dee24c680864c67", - "rustc-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "8426a3d170a5879f5682f5fbdd024a1779b3951e7baba685af2d6dc32a6dfc15", - "rustfmt-1.95.0-aarch64-apple-darwin.tar.xz": "c54af79adfdc790d27fc56e24407e370c80be5a89ad537eef9fbd45d3c3e28e8", - "rustfmt-1.95.0-aarch64-pc-windows-msvc.tar.xz": "a873c048743e6da29e09a8b55c774ee113f8f6ae4fd57d988d304a6453801b34", - "rustfmt-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "64cce868f0f3d29f1524e11e9bab01ac9d538a31665fea1cd6b78af46a1c0a41", - "rustfmt-1.95.0-x86_64-apple-darwin.tar.xz": "5f7228f40a160e80d260e74e068d6fec8627aa02f1f5ae29d2019b9347076401", - "rustfmt-1.95.0-x86_64-pc-windows-msvc.tar.xz": "8bcf91606e36b8a0164efafde50709cd7a3c02143a2edaa81dbf3dccd6ed8f4c", - "rustfmt-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "f1b2a7301513ffdd95ebf22ebbdd932e4d17fc806f748d93924740d0297b1396" - } - } -} diff --git a/README.md b/README.md index 3d5aeb6fb4..a7ecf4179e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,12 @@ -# ![OpenShell](docs/brand/assets/openshell-lockup-horizontal.svg) + + + + + + OpenShell + + + [![License](https://img.shields.io/badge/License-Apache_2.0-blue)](https://github.com/NVIDIA/OpenShell/blob/main/LICENSE) [![PyPI](https://img.shields.io/badge/PyPI-openshell-orange?logo=pypi)](https://pypi.org/project/openshell/) @@ -8,9 +16,7 @@ OpenShell is the safe, private runtime for autonomous AI agents. It provides sandboxed execution environments that protect your data, credentials, and infrastructure — governed by declarative YAML policies that prevent unauthorized file access, data exfiltration, and uncontrolled network activity. -OpenShell is built agent-first. The project ships with agent skills for everything from gateway troubleshooting to policy generation, and we expect contributors to use them. - -> **Alpha software — single-player mode.** OpenShell is proof-of-life: one developer, one environment, one gateway. We are building toward multi-tenant enterprise deployments, but the starting point is getting your own environment up and running. Expect rough edges. Bring your agent. +OpenShell is built agent-first. It ships public agent skills for using and operating OpenShell, plus separate repository-aware workflows for contributors and maintainers. ## Quickstart @@ -27,14 +33,14 @@ OpenShell is built agent-first. The project ships with agent skills for everythi curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh ``` -**From PyPI (requires [uv](https://docs.astral.sh/uv/)):** +The installer installs the latest stable release by default. To install a specific version, set `OPENSHELL_VERSION`. A [`dev` release](https://github.com/NVIDIA/OpenShell/releases/tag/dev) is also available that tracks the latest commit on `main`. + +The `openshell` package on PyPI provides the Python SDK only. It does not install the `openshell` CLI. Add the SDK to a Python project with [uv](https://docs.astral.sh/uv/): ```bash -uv tool install -U openshell +uv add openshell ``` -Both methods install the latest stable release by default. To install a specific version, set `OPENSHELL_VERSION` (binary) or pin the version with `uv tool install openshell==`. A [`dev` release](https://github.com/NVIDIA/OpenShell/releases/tag/dev) is also available that tracks the latest commit on `main`. - **Helm chart:** > **Experimental** — the Kubernetes deployment path is under active development. Expect rough edges and breaking changes. @@ -200,35 +206,33 @@ openshell sandbox create --from registry.io/img:v1 # container image See the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog and the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for details. -## Explore with Your Agent +## Use OpenShell with Your Agent -Clone the repo and point your coding agent at it. The project includes agent skills that can answer questions, walk you through workflows, and diagnose problems — no issue filing required. +OpenShell provides four portable skills for users and operators: CLI workflows (`openshell-cli`), gateway troubleshooting (`debug-openshell-cluster`), inference troubleshooting (`debug-inference`), and policy generation (`generate-sandbox-policy`). Install them with the Agent Skills CLI: ```bash -git clone https://github.com/NVIDIA/OpenShell.git # or git@github.com:NVIDIA/OpenShell.git -cd OpenShell -# Point your agent here — it will discover the skills in .agents/skills/ automatically +npx skills add NVIDIA/OpenShell ``` -Your agent can load skills for CLI usage (`openshell-cli`), gateway troubleshooting (`debug-openshell-cluster`), inference troubleshooting (`debug-inference`), policy generation (`generate-sandbox-policy`), and more. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full skills table. +These public, installable skills live in [`skills/`](skills/) and use the installed CLI help and [published documentation](https://docs.nvidia.com/openshell/latest/index.html) as their sources of truth. They do not require an OpenShell source checkout. ## Built With Agents -OpenShell is developed using the same agent-driven workflows it enables. The `.agents/skills/` directory contains workflow automation that powers the project's development cycle: +OpenShell is developed using the same agent-driven workflows it enables. Contributor and maintainer skills live separately in [`.agents/skills/`](.agents/skills/); they automate work on the OpenShell repository and are not included when users install the public skills: -- **Spike and build:** Investigate a problem with `create-spike`; a human accepts or declines it and separately places it on the [roadmap](https://github.com/orgs/NVIDIA/projects/233). Accepted work can remain human-owned or enter the optional, human-gated `agent:*` planning and implementation workflow. +- **Spike and build:** Investigate a problem with `create-spike`; a human accepts it with `state:accepted` or [roadmap](https://github.com/orgs/NVIDIA/projects/233) placement, or declines it. Accepted work can remain human-owned or enter the optional, human-gated `agent:*` planning and implementation workflow. - **Triage and route:** Community issues are assessed with `triage-issue`. Agents establish technical validity and impact; humans decide whether the project should act and where the work sits on the roadmap. - **Security review:** `review-security-issue` produces a severity assessment and remediation plan. `fix-security-issue` implements it. -- **Policy authoring:** `generate-sandbox-policy` creates YAML policies from plain-language requirements or API documentation. +- **Repository maintenance:** `sync-agent-infra`, `update-docs-from-commits`, and other internal workflows keep code, documentation, and agent infrastructure consistent. -All agent implementation work is human-gated: maintainers explicitly request a plan, agents propose it, maintainers approve it, and agents build. See [AGENTS.md](AGENTS.md) for the full workflow chain documentation. +Agent implementation is human-directed: a user may request a phase directly, or maintainers may use the optional `agent:*` workflow to queue and approve planning and implementation. See [AGENTS.md](AGENTS.md) for the full workflow chain documentation. ## Getting Help - **Questions and discussion:** [GitHub Discussions](https://github.com/NVIDIA/OpenShell/discussions) - **Bug reports:** [GitHub Issues](https://github.com/NVIDIA/OpenShell/issues) — use the bug report template - **Security vulnerabilities:** See [SECURITY.md](SECURITY.md) — do not use GitHub Issues -- **Agent-assisted help:** Clone the repo and use the agent skills in `.agents/skills/` for self-service diagnostics +- **Agent-assisted help:** Install the public OpenShell skills with `npx skills add NVIDIA/OpenShell` ## Learn More @@ -244,15 +248,23 @@ All agent implementation work is human-gated: maintainers explicitly request a p ## Contributing -OpenShell is built agent-first — your agent is your first collaborator. Before opening issues or submitting code, point your agent at the repo and let it use the skills in `.agents/skills/` to investigate, diagnose, and prototype. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full agent skills table, contribution workflow, and development setup. +OpenShell is built agent-first. Issues should include a user story, problem statement, impact, and acceptance criteria. The impact should explain the consequences of the current behavior and why existing workarounds are insufficient. Feature requests also require a workflow-level proposed design and alternatives; bug reports add reproduction steps, environment details, and relevant logs. Once work is authorized through the project workflow or a direct request, contributors should use the skills in `.agents/skills/` to investigate the current code and behavior, implement the change, and verify it. If an issue contains earlier diagnostics, verify them rather than relying on them. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full agent skills table, contribution workflow, and development setup. ## Telemetry OpenShell collects anonymous telemetry to help improve the project for developers. This data is not used to track individual user behavior. It helps us understand aggregate usage of sandbox, provider, and policy workflows so we can prioritize product improvements and share usage trends with the community. -Disable telemetry at runtime by setting `OPENSHELL_TELEMETRY_ENABLED=false` on the gateway deployment. OpenShell propagates this deployment setting into sandbox supervisor environments so sandbox-side telemetry collection is disabled as well. +Disable telemetry at runtime by setting `OPENSHELL_TELEMETRY_ENABLED=false` on the gateway deployment. For Helm installs, set `server.telemetryEnabled=false`. OpenShell propagates this deployment setting into sandbox supervisor environments so sandbox-side telemetry collection is disabled as well. + +You can also compile telemetry out entirely. Telemetry support is a default-on `telemetry` Cargo feature, and each crate that carries it also defines a `defaults-without-telemetry` alias covering every other default feature. Build telemetry-free artifacts with `--no-default-features --features defaults-without-telemetry`: + +```shell +cargo build --release -p openshell-gateway --no-default-features --features defaults-without-telemetry +cargo build --release -p openshell-sandbox --no-default-features --features defaults-without-telemetry +cargo build --release -p openshell-driver-vm --no-default-features --features defaults-without-telemetry +``` -You can also compile telemetry out entirely. Telemetry support is a default-on `telemetry` Cargo feature; building with `--no-default-features` produces binaries that contain no telemetry endpoint, no telemetry HTTP client, and no emission code. Build telemetry-free artifacts with, for example, `cargo build --release -p openshell-server --no-default-features` (gateway) and the equivalent for `openshell-sandbox` and `openshell-driver-vm`. With telemetry compiled out, the gateway emits nothing and reports telemetry disabled to the sandboxes it launches. +The resulting binaries contain no telemetry endpoint, no telemetry HTTP client, and no emission code. With telemetry compiled out, the gateway emits nothing and reports telemetry disabled to the sandboxes it launches. Cargo has no way to subtract a single default feature, so `defaults-without-telemetry` must be paired with `--no-default-features`; passing it on its own leaves the defaults in place and fails the build rather than producing a binary that still emits. Telemetry events are limited to anonymous operational categories and counts, such as sandbox lifecycle outcomes, provider profile buckets, policy decision counts, and aggregate network activity denial categories. OpenShell telemetry does not collect sandbox names or IDs, hostnames, file paths, binary paths, prompts, credentials, provider names, model names, or user content. diff --git a/TESTING.md b/TESTING.md index a032baa5ea..199fa35416 100644 --- a/TESTING.md +++ b/TESTING.md @@ -148,9 +148,15 @@ lifecycle management, output parsing, and cleanup. Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. -- Docker suite (`--features e2e-docker`) - common suite plus Docker-only coverage such as Dockerfile image builds, Docker preflight checks, and managed Docker gateway resume. +- CLI conformance (`openshell-conformance`) - the portable deployment smoke + scenario plus focused tests for its reusable command runner. +- 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. +- Docker suite (`--features e2e-docker`) - includes Docker-only coverage such as Dockerfile image builds, Docker preflight checks, and managed Docker gateway start. - Docker GPU suite (`--features e2e-docker-gpu`) - Docker suite plus GPU sandbox smoke coverage. - VM suite (`--features e2e-vm`) - runs e2e tests on a VM. +- Kubernetes credential-driver suite (`--features e2e-kubernetes-credential-drivers`) - targeted Kubernetes Secrets and Vault provider credential storage coverage. GPU device-selection tests compare OpenShell sandboxes against a plain Docker or Podman container that requests `--device nvidia.com/gpu=all`. The probe image @@ -165,10 +171,48 @@ key. Run the Docker-backed Rust CLI e2e suite: ```shell -mise run e2e:rust +mise run e2e:docker ``` -Run the Podman-backed Rust CLI e2e suite: +Run the minimal portable CLI conformance profile against the gateway selected +in your OpenShell CLI configuration: + +```shell +mise run e2e:cli-conformance +``` + +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 phase-1 scenario verifies the complete CLI-to-gateway-to-driver path without +depending on how the gateway was installed or which driver is configured. It +requires machine-readable gRPC status, creates a uniquely named detached +sandbox with `--from base`, verifies the sandbox is `Ready` by finding its +unique name in paginated JSON list output, executes `echo` with a run-specific +marker, deletes the sandbox, and verifies that its name no longer appears. +Driver suites enable the same profile +instead of maintaining a separate smoke implementation. Sandbox lifecycle, +label matrices, VM overlay, and TLS-key permission assertions remain regular +E2E coverage. + +Each invocation prints a ten-character run ID before creating resources. +Conformance sandboxes use names such as `ct--01`. The runner tracks the +exact name and uses it for cleanup; phase 1 does not add ownership labels. + +The runner deletes owned resources after both success and failure. If the test +process is interrupted before cleanup, locate leftovers without touching +unrelated gateway state: + +```shell +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: ```shell mise run e2e:podman @@ -180,6 +224,14 @@ Run the VM-backed Rust CLI e2e suite: mise run e2e:vm ``` +Run the targeted Kubernetes credential-driver e2e suite. This deploys an +OpenBao fixture for the Vault-compatible driver path and validates Kubernetes +Secrets and Vault storage backends one at a time: + +```shell +mise run e2e:kubernetes:credential-drivers +``` + Run a single test directly with cargo: ```shell @@ -210,3 +262,4 @@ The harness (`e2e/rust/src/harness/`) provides: | `OPENSHELL_GATEWAY` | Override active gateway name for E2E tests | | `OPENSHELL_GATEWAY_ENDPOINT` | Run E2E tests against an existing plaintext HTTP gateway endpoint | | `OPENSHELL_E2E_DRIVER` | Driver name exported by the e2e gateway wrapper (`docker`, `podman`, or `vm`) | +| `OPENSHELL_E2E_CREDENTIAL_DRIVERS` | Enables the Kubernetes credential-driver fixture path in `e2e/with-kube-gateway.sh` | diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 41eda0e0a8..8125e5692f 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -14813,13 +14813,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -================================================================================ -maturin 1.11.5 -License: MIT OR Apache-2.0 --------------------------------------------------------------------------------- - -UNKNOWN - ================================================================================ packaging 26.0 License: Apache-2.0 OR BSD-2-Clause diff --git a/architecture/README.md b/architecture/README.md index 3fc72afd25..d48a0d53f9 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -141,6 +141,14 @@ bridge networks, port mappings, NAT traversal, or bespoke tunnels. The common runtime requirement is narrower: the supervisor must be able to reach the gateway. +The compute-driver capability contract identifies whether a driver reports +runtime readiness. Most drivers use the supervisor session model above. A +driver that sets `driver_reports_runtime_readiness` may self-report readiness +without a supervisor session. Every driver receives the canonical create-time +policy in `DriverSandboxSpec`; drivers without a supervisor use the existing +sandbox configuration API for later revisions. The Windows MXC driver reports +its own readiness and does not expose interactive connect or governed egress. + The gateway delivers desired state; the sandbox applies it locally. Policy, settings, credentials, and inference routes flow from the gateway to the supervisor. The supervisor validates and applies what can change at runtime, @@ -163,10 +171,12 @@ that crate's `README.md`. |---|---| | [Gateway](gateway.md) | Gateway control plane, auth, APIs, persistence, settings, and relay coordination. | | [Sandbox](sandbox.md) | Sandbox supervisor, child process isolation, proxy, credentials, inference, connect, and logs. | +| [Sandbox Limits](sandbox-limits.md) | Sandbox supervisor and egress safety ceilings, ownership rules, current enforcement, and known gaps. | | [Security Policy](security-policy.md) | Policy model, enforcement layers, policy updates, policy advisor, and security logging. | | [Compute Runtimes](compute-runtimes.md) | Docker, Podman, Kubernetes, VM, sandbox images, and runtime-specific responsibilities. | | [Build](build.md) | Build artifacts, CI/E2E, docs site validation, and release packaging. | | [Google Vertex AI Provider](google-vertex-ai-provider.md) | Implementation reference for the `google-vertex-ai` provider, from CLI through gateway to sandbox. | +| [Windows MSVC Build](windows-msvc-build.md) | Build-only native Windows MSVC lane (x64/ARM64) and unsupported-runtime behavior on Windows. | ## `rfc/` vs `architecture/` diff --git a/architecture/build.md b/architecture/build.md index d4a3769b92..972f847bb9 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -10,8 +10,11 @@ OpenShell builds these main artifacts: | Artifact | Source | |---|---| -| Gateway binary | `crates/openshell-server` | -| CLI package and Python SDK | `python/openshell` plus Rust binaries where packaged | +| Gateway binary | `crates/openshell-gateway` | +| CLI binaries and system packages | `crates/openshell-cli` plus release packaging | +| E2E conformance CLI | `crates/openshell-conformance-cli` | +| Python SDK wheel | `python/openshell` | +| TypeScript SDK package | `sdk/typescript` | | Gateway container image | `deploy/docker/Dockerfile.gateway` | | Supervisor container image | `deploy/docker/Dockerfile.supervisor` | | Helm chart | `deploy/helm/openshell` | @@ -25,19 +28,52 @@ Sandbox community images are built outside this repository. Anonymous telemetry emission is gated behind a default-on `telemetry` Cargo feature. It is defined in `openshell-core` (where the emission code, HTTP client, and endpoint live) and forwarded by the binary crates that emit or -collect telemetry: `openshell-server` (gateway), `openshell-sandbox` +collect telemetry: `openshell-gateway`, `openshell-sandbox` (supervisor), and `openshell-driver-vm`. Every crate depends on `openshell-core` with `default-features = false`, so the binary crate's feature is the single switch that enables `openshell-core/telemetry` for its build graph. In-process drivers (`docker`, `kubernetes`, `podman`) inherit the gateway's setting through feature unification and carry no passthrough. -Building a binary with `--no-default-features` compiles out telemetry entirely: -no endpoint, no telemetry HTTP client, and no emission code. With telemetry -compiled out, `telemetry::enabled()` is always `false` and the `emit_*` helpers -are no-ops, so the data-model types stay available and dependent crates compile -unchanged. The runtime `OPENSHELL_TELEMETRY_ENABLED` switch remains the way to -disable telemetry in a default (telemetry-enabled) build. +Building a binary without the `telemetry` feature compiles out telemetry +entirely: no endpoint, no telemetry HTTP client, and no emission code. With +telemetry compiled out, `telemetry::enabled()` is always `false` and the +`emit_*` helpers are no-ops, so the data-model types stay available and +dependent crates compile unchanged. The runtime `OPENSHELL_TELEMETRY_ENABLED` +switch remains the way to disable telemetry in a default (telemetry-enabled) +build. + +Cargo cannot subtract a single default feature, so each of the three binary +crates also defines a `defaults-without-telemetry` alias listing every default +except `telemetry`. Telemetry-free builds use +`--no-default-features --features defaults-without-telemetry` and stay correct +as the default set grows, instead of dropping unrelated defaults the way a bare +`--no-default-features` does on `openshell-sandbox`. The alias is a keep-list, +not a switch: enabling it on top of the defaults would otherwise yield a +telemetry-on binary that reads as telemetry-free, so each crate root carries a +`compile_error!` for the `telemetry` + `defaults-without-telemetry` combination. +`rust:verify:defaults-without-telemetry` guards both properties — that each +alias still equals its crate's defaults minus `telemetry`, and that the +mutual-exclusion error is wired up — and `rust:verify:telemetry-off` builds +through the alias and inspects the resulting binaries for telemetry markers. + +Supervisor upstream TLS root-store selection is controlled by the +`bundled-ca-roots` Cargo feature (on by default). Default builds use Mozilla +roots through `webpki-roots` plus locally-installed CAs from the system bundle. +Building without `bundled-ca-roots` switches to the platform trust store via +`rustls-native-certs` and excludes bundled Mozilla root crates such as +`webpki-roots` and `webpki-root-certs` from the dependency graph. The +`system-ca-roots` feature alias on `openshell-sandbox` includes all other +defaults (currently `telemetry`) except `bundled-ca-roots`, so Linux +distribution builds (e.g. RPM) can use +`--no-default-features --features system-ca-roots` without manually re-adding +unrelated defaults. Other Rustls clients use native roots directly because that +already satisfies Linux distribution trust-store policy. + +The workspace uses `z3` versions whose `z3-sys` dependency keeps downloader +HTTP/TLS support behind explicit build features, so default system-Z3 builds do +not reintroduce bundled Mozilla roots. Release builds that need bundled Z3 +continue to opt in with `bundled-z3`. ## Linux Runtime Environments @@ -50,6 +86,31 @@ The gateway bundles z3 into the release binary so Linux packages, standalone tarballs, and gateway images do not depend on distro-specific z3 shared-library SONAMEs. +The supervisor is the one binary whose libc is selectable, because it is the one +binary executed inside a userland OpenShell does not control. `SUPERVISOR_LIBC` +chooses between `musl` (default) and `glibc-static`. Both produce a fully static +binary; the choice does not change the runtime layout or the supervisor image base. +Static linkage is a hard requirement rather than a preference, so both variants +are verified by `tasks/scripts/verify-static-binary.sh`, which fails the build on +any `PT_INTERP` or `DT_NEEDED` entry. + +The two variants differ only in build-time constraints: + +| | `musl` (default) | `glibc-static` | +|---|---|---| +| Cross-compiles | yes, via `cargo zigbuild` | no — must build natively per architecture | +| Host requirement | zig + cargo-zigbuild | glibc static libraries (`glibc-static` on Fedora/RHEL, `libc6-dev` on Debian/Ubuntu) | +| libc license | MIT | LGPL-2.1-or-later, statically linked | + +`cargo zigbuild` cannot produce the `glibc-static` variant: `zig cc` accepts +`-static` for `*-linux-gnu` targets and emits a dynamically linked binary +anyway. The staging script therefore refuses to cross-compile that variant +instead of silently degrading linkage. + +Selecting `glibc-static` statically links LGPL glibc into a redistributed +binary, which carries relinking obligations that musl (MIT) does not. Treat the +default as the shipping configuration unless that has been reviewed. + ## Container Builds The Docker image pipeline is a two-step flow: build the Rust binary natively @@ -59,7 +120,7 @@ and the supervisor image from `deploy/docker/Dockerfile.supervisor`. Neither Dockerfile compiles Rust — both copy a staged binary out of `deploy/docker/.build/prebuilt-binaries//` into the final image. -Binary staging is driven by `tasks/scripts/stage-prebuilt-binaries.sh`. Because +Local binary staging is driven by `tasks/scripts/stage-prebuilt-binaries.sh`. Because staging cross-compiles on the host, it sources `tasks/scripts/build-env.sh` and raises the per-process open-file limit before invoking `cargo zigbuild` on macOS — the static musl link opens hundreds of `.rlib` files at once and would @@ -73,17 +134,51 @@ package-managed VM support does not raise the package runtime requirement. Gateway staging and release workflows set up the Zig C/C++ wrapper before bundled Z3 builds and verify the maximum referenced `GLIBC_*` symbol version before publishing or copying artifacts. -Supervisor binaries remain static musl and use `cargo zigbuild` when available, -including native CPU architectures, so C dependencies are compiled for the musl -target instead of the host GNU libc target. Local Docker image tasks infer the +Supervisor binaries are static in every configuration. The default `musl` +variant uses `cargo zigbuild` when available, including native CPU +architectures, so C dependencies are compiled for the musl target instead of the +host GNU libc target. The `glibc-static` variant uses plain `cargo build` with +`+crt-static` and requires a native per-architecture build. Local Docker image tasks infer the target architecture from `DOCKER_PLATFORM` when set. Otherwise, they require valid container engine host metadata and fail when the engine query is unavailable or reports an unsupported architecture, avoiding host-kernel -fallbacks that can target the wrong architecture. CI invokes the same staging -step via the `rust-native-build.yml` workflow (per-architecture, per-component) -and uploads the result as an artifact that the image build job downloads back +fallbacks that can target the wrong architecture. CI instead compiles binaries +in platform-specific Nix development shells through reusable workflows and the +shared `build-rust-binary` action. The image build downloads each binary artifact into the staging directory before running Buildx. +Gateway and supervisor binaries staged into branch E2E, Release Dev, and Release +Tag images are compiled through `cargo auditable` (pinned in `mise.toml`), which +embeds a `.dep-v0` section describing the Rust dependencies actually compiled +into the binary. That section holds data rather than symbols, so it survives the +workspace's `strip = true` release profile, and Syft can catalog the crates +present in image binaries instead of inferring them from the source tree. This +is a different artifact from the source SBOM produced by `syft dir:.` in +`tasks/sbom.toml`, which describes the checkout, and from the image SBOM +attestation below, which describes a published image. + +The shared binary build action compiles release artifacts with `cargo auditable`. +Branch E2E, Release Dev, and Release Tag image jobs stage those same artifacts +instead of rebuilding binaries in Docker. Each binary build scans its output with +Syft and requires at least one decoded Cargo package before uploading the +artifact. Darwin builds replace Nix's `libiconv` load command with the macOS +system install name, ad-hoc sign the modified binary, and fail if `otool -L` +reports any remaining `/nix/store` dependency. Runtime and Syft verification +run after that normalization. The CI image gains the pinned `cargo-auditable` +tool through `mise install --locked` but ships no auditable OpenShell binary of +its own. + +Pushed Docker images carry minimal SLSA provenance and a per-platform SPDX SBOM +generated by BuildKit's default Syft scanner. The registry exporter uses OCI +media types and `oci-artifact=true`, so each attestation identifies its subject. +GHCR exposes these through the image index because it has no referrers API. + +Attestations require a registry-backed image index. Local builds therefore keep +`--provenance=false`, and Podman builds carry neither attestation. +`tasks/scripts/verify-image-sbom.sh` verifies the merged multi-arch tag and runs +with `--require-cargo` for auditable builds, so those attestations must also +contain Cargo packages. + Runtime layout: - **Gateway**: `gcr.io/distroless/cc-debian13:nonroot` base, GNU-linked binary at @@ -95,12 +190,21 @@ Runtime layout: `/usr/libexec/openshell/openshell-driver-vm` in Linux packages and published as a release artifact. Linux GNU VM driver binaries must not reference `GLIBC_*` symbols newer than `GLIBC_2.28`; release workflows verify this - before publishing artifacts. -- **Supervisor**: Alpine base with `nftables`, static musl binary at - `/openshell-sandbox`. Static linkage keeps the binary usable when the image - is mounted/extracted into sandbox environments (Docker extraction, Podman - image volumes, Kubernetes init-container copy-self), while `nftables` supports - Kubernetes supervisor sidecar egress enforcement. + before publishing artifacts. Nix produces the platform-specific compressed + runtime inputs. CI combines them with the matching supervisor artifact in a + runner-temporary directory outside Cargo's `target/` before the shared Rust + cache action runs. An explicitly configured VM runtime bundle is required to + contain every non-empty embedding input; the driver build fails before + packaging when an input is absent or empty. +- **Supervisor**: Alpine base with `nftables`, static binary at + `/openshell-sandbox` (musl by default; see `SUPERVISOR_LIBC` above). Static + linkage keeps the binary usable when the image is mounted/extracted into + sandbox environments (Docker extraction, Podman image volumes, Kubernetes + init-container copy-self), whose libc and glibc version are not known at build + time, while `nftables` supports Kubernetes supervisor sidecar egress + enforcement. The VM driver bundles its own supervisor build + (`tasks/scripts/vm/build-supervisor-bundle.sh`) and does not read + `SUPERVISOR_LIBC`. Gateway image builds bake the corresponding supervisor image tag into the gateway binary so Docker sandboxes do not depend on `:latest` by default. @@ -152,21 +256,46 @@ pulls and explicit publication. OCI pulls require a trusted manifest digest and retain that provenance with the local entry; mutable tags are used only for explicit publication. +CLI conformance runs after target provisioning. Action-free scenarios operate +only through the configured OpenShell CLI. A versioned conformance plan may add +an ordered sequence of target-supplied host-side actions, such as a gateway +restart, while the scenario remains responsible for black-box sandbox +continuity checks. The plan exposes opaque executable paths and timeouts rather +than driver or package-manager configuration; target setup owns those details. + ## Python Wheel Packaging The generated protobuf/gRPC stubs under `python/openshell/_proto/` are gitignored -build outputs of `mise run python:proto`. The task uses `uv run --frozen` to -synchronize the current worktree's `.venv` from `uv.lock` before generation. -maturin honors `.gitignore` when collecting `python-source` files, so native -builds (Linux CI, local `pip install .`) would drop them and ship an unimportable -wheel. `pyproject.toml` -pins them back in with `[tool.maturin].include` globs. The release workflows -install each Linux wheel in a clean image and import `openshell.sandbox` as a -smoke check. +build outputs of `mise run python:proto`. Setuptools includes them through the +package-data configuration in `pyproject.toml`. Release workflows build the +wheel directly and do not produce a source distribution. Setuptools SCM derives +local versions from Git and accepts the release workflow's computed version +through its distribution-specific override. + +The build produces one platform-independent `py3-none-any` wheel. A verifier +checks its tag, metadata, version, required package files, and the absence of +native files or an `openshell` executable entry point. Release workflows build +the wheel once, install it in a clean virtual environment, import the public +package modules, and confirm that installation did not create an `openshell` +command. + +## TypeScript SDK Packaging + +The native TypeScript SDK in `sdk/typescript` uses Connect over the generated +OpenShell protobuf surface. `sdk/typescript/buf.gen.yaml` selects the client +proto closure, and `mise run sdk:ts:proto` generates gitignored sources under +`src/gen`. TypeScript compilation includes those sources in `dist`, so package +consumers do not run code generation. + +Branch checks run `mise run sdk:ts:ci`, enforce an 80% line-coverage floor, and +exercise version stamping plus `npm publish --dry-run`. Tagged releases publish +`@nvidia/openshell-sdk` to GitHub Packages. The repository keeps package version +`0.0.0`; the release task derives and temporarily stamps the npm version from +the release tag. ## CI and E2E -Required checks run on GitHub Actions. Workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges. +Required checks run on GitHub Actions. Pull-request workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges. The high-level CI model: @@ -178,6 +307,145 @@ The high-level CI model: 5. Gate jobs verify that the mirror branch matches the PR head, or that the merge-group workflow ran for the queued SHA, and that the expected non-gate workflow actually ran. 6. Release workflows rebuild and publish binaries, wheels, images, and docs. +Repository CI keeps telemetry compiled into release-parity artifacts but +disables emission for Rust tests, E2E runs, and release canaries. This prevents +synthetic activity from contributing to product usage metrics. + +Static security checks are deliberately outside the mirror-branch path. They run +directly on GitHub-hosted runners and none of them consume NVIDIA self-hosted +capacity. The change-oriented ones receive no secrets, so they also cover fork +pull requests. Codex Security release qualification is the exception: it needs a +scoped API key, which routes its model calls to NVIDIA-hosted inference while +the job itself stays GitHub-hosted. That placement is load-bearing rather than +incidental: on the repository self-hosted runner the scan agent executes no +shell commands at all, so its preflight never scopes the diff and it seals no +draft. Scanner jobs request `security-events: write` and upload SARIF to Code +Scanning directly on every event they run on, including fork and Dependabot +pull requests, which Code Scanning permits for +`pull_request` runs despite their read-only `GITHUB_TOKEN`. No privileged +intermediate workflow relays those uploads. Manually dispatched Codex Security +runs are the one opt-in exception, described below. Report retention differs by +scanner: Actionlint, Zizmor, and CodeQL keep their reports as workflow artifacts, +and Codex Security keeps no raw report. +Triggers differ by workflow: `.github/workflows/workflow-security.yml` runs on +`pull_request`, `merge_group`, `main`, and a weekly schedule; +`.github/workflows/dependency-review.yml` runs on `pull_request` and +`merge_group` only, because it needs a base and head commit to compare; +`.github/workflows/codeql.yml` runs nightly on the default branch (`main`) via +`schedule`, with `workflow_dispatch` kept for manual diagnostics; and +`.github/workflows/codex-security.yml` runs on pushed `v*.*.*-pre.*` tags, and is +also callable through `workflow_call` and `workflow_dispatch`. CodeQL does +not run on `pull_request`, `merge_group`, or pushes to `main`, so it reports +repository-level Code Scanning state on the default branch instead of per-PR +results, and its four-language matrix stays off the per-change critical path. +Codex Security is release-scoped rather than change-scoped, so it never runs on +a pull request or merge group. + +- **Actionlint and Zizmor** analyze the workflow definitions themselves. + Repository configuration lives in `.github/actionlint.yml` (self-hosted runner + labels, scoped per-file ignores) and `.github/zizmor.yml` (scoped rule + suppressions). Zizmor runs offline and reports only High severity, which is + its maximum level. Both publish SARIF to Code Scanning and retain report + artifacts. The Nix flake provides both scanners, so local runs use + `nix develop --command actionlint -shellcheck= -pyflakes=` and + `nix develop --command zizmor --offline --persona=regular --min-severity=high --no-exit-codes .`. +- **Dependency Review** compares the base and head dependency graphs. It + preflights the GitHub Dependency Graph compare API and neutralizes itself with + a warning while that repository feature is unavailable, so the check begins + reporting on its own once the feature is enabled. Reviews run in warn-only + mode. +- **CodeQL** analyzes product Rust code, examples, and the Go, Python, and + TypeScript SDKs, scoped by `.github/codeql/codeql-config.yml`. Rust test code + is excluded in two layers: the analyze job sets + `CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES=-test` so the extractor skips + `#[cfg(test)]` blocks, and `paths-ignore` drops `crates/*/tests`, whose + integration targets the cfg override does not reach. Examples remain in scope, + and E2E test code stays excluded because `e2e/` is not an analyzed path. Only + Go requires a build; the other languages use build mode `none`. Analysis runs + on the nightly schedule or by manual dispatch. Results are uploaded to Code + Scanning and always retained as workflow artifacts. +- **Codex Security** qualifies release candidates rather than individual + changes. The job installs a pinned `@openai/codex-security` release into the + runner temp directory before the repository is checked out and invokes it by + absolute path, so repository-controlled files cannot shadow the scanner. Model + calls go to NVIDIA-hosted inference at `https://inference-api.nvidia.com/v1`, + declared as a custom Codex provider named `nvidia` that uses the Responses + wire API with WebSockets disabled. The scan runs `openai/openai/gpt-5.6-sol` + at `medium` reasoning effort, with the multi-agent runtime capped at eight + concurrent threads through + `features.multi_agent_v2.max_concurrent_threads_per_session`. The + `CODEX_SECURITY_API_KEY` secret holds the + NVIDIA key and is exposed to the scan step alone, as `OPENAI_API_KEY` so the + CLI selects API-key auth and as `NVIDIA_INFERENCE_API_KEY`, the provider + `env_key` read by the Codex child process. `CODEX_SECURITY_STATE_DIR` and + `SCAN_DIR` are suffixed with `github.run_id` and `github.run_attempt` and + created mode `700`, so no scanner state or result set from a previous run or + retry attempt is reused even on a runner with a reusable temp directory. + `tasks/scripts/codex_security_range.py` resolves the scan range, reusing the + tag parsers in `tasks/scripts/release.py` so both stay on one definition of a + release tag while requiring the `v` prefix that a release workflow needs. The + job stages both files out of the workspace from the workflow's own revision + and runs the resolver by absolute path, because a scanned candidate predates + them and a revision under scan must not choose its own scan range. The range + itself is resolved against the checked-out candidate: the + candidate must be a `vX.Y.Z-pre.N` tag that is an ancestor of `origin/main`, + and the base is the newest stable `vX.Y.Z` tag merged into the candidate that + is strictly older than the release train `vX.Y.Z` the candidate targets. A + full-repository scan is only possible when no such stable tag exists and the + caller passes `allow_full_bootstrap`. Each candidate scans the cumulative + stable-to-candidate diff, so later candidates re-cover earlier ones. SARIF is + uploaded against `refs/heads/main` at the candidate commit under the + train-scoped category `codex-security/vX.Y.Z`, which makes each candidate's + analysis replace the previous one for that train. Automatic pre-release tag + pushes and `workflow_call` runs always upload. `workflow_dispatch` runs still + perform the scan and the SARIF export, but skip the Code Scanning upload + unless the caller sets the `upload_sarif` input, so manual diagnostics do not + overwrite a train's published analysis by default. Codex Security 0.1.24 + cannot apply `--max-cost` to a slash-qualified model identifier, so the run + has no CLI-enforced cost ceiling. Spend is bounded instead by the 120-minute + job timeout, a single repository-wide concurrency group that serializes + qualification so starting a newer candidate cancels an in-flight one, and + NVIDIA account-side controls. No raw report is retained. +- The job clears `kernel.apparmor_restrict_unprivileged_userns` before + installing the scanner. Codex confines model-run commands with bubblewrap, + which needs unprivileged user namespaces; Ubuntu 24.04 restricts those through + AppArmor, so bubblewrap fails to configure the sandbox network namespace + (`bwrap: loopback: Failed RTM_NEWADDR`) and the agent executes no commands at + all. The failure is silent: the agent retries its shell tool, gives up, and + seals no draft, while the scanner only reports a missing or incomplete draft. + Lifting a kernel restriction on the runner is what allows the sandbox that + confines the agent to start, and the runner is ephemeral and GitHub-hosted. +- The scan sets `approval_policy="never"`. Codex Security keeps + `approvals_reviewer="auto_review"` unconditionally, and that reviewer runs on + its own model rather than the configured one. Because the workflow declares a + single provider that serves only `openai/openai/gpt-5.6-sol`, any approval + request reaches a model the endpoint does not serve, so the agent never gets a + shell command approved and seals no draft. The scan stays confined by its + `workspace-write` sandbox with network access disabled and by the scanner's + own permission profile, which grants read access to the filesystem root and + write access only to the workspace roots. +- A scan that cannot execute commands reports only a missing or incomplete + draft, so diagnosing one means reading the scanner's session rollouts under + `CODEX_SECURITY_STATE_DIR`, where every shell command the agent ran is + recorded. No command at all is the signal that the sandbox failed to start. + +Findings never fail these checks; scanner and build failures do. A scanner that +cannot run, a CodeQL analyzer that does not complete, an unexpected Dependency +Graph API error, and a Codex Security range, scan, or export failure are all +errors, which keeps an informational check from silently degrading into a no-op. +Codex Security also rejects any scan scope other than the resolved +cumulative diff or an approved full bootstrap, so a qualification run either +covers the whole stable-to-candidate range or fails; a separate no-permission +job republishes the analysis job's outcome as the +`OpenShell / Codex Security (informational)` status. None of these checks are +required statuses, so they do not gate merges. + +Codex Security findings are informational during the observation phase, and the +workflow only reports on candidates that already exist. Creating pre-release +tags and gating stable promotion on qualification results are part of +[RFC 0014](../rfc/0014-release-stability/release-qualification.md) and are not +implemented yet. + See `CI.md` for the contributor workflow, labels, and maintainer merge-queue workflow. ## Docs Site diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 55d7e7a29d..e1e731a0ce 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -1,27 +1,50 @@ # Compute Runtimes -Compute runtimes create, stop, delete, and watch sandbox workloads for the -gateway. They do not replace sandbox policy enforcement. Every runtime starts a -workload that runs the `openshell-sandbox` supervisor, and the supervisor -enforces the sandbox contract locally. +Compute runtimes create, stop, start, delete, and watch sandbox workloads for the +gateway. Supervisor-controlled runtimes start a workload that runs the +`openshell-sandbox` supervisor, which enforces the sandbox contract locally. +Driver-controlled runtimes apply the canonical sandbox policy while +provisioning and report workload readiness directly. ## Driver Contract -Each runtime receives a sandbox spec from the gateway and is responsible for: +Each runtime receives a sandbox spec and canonical policy from the gateway and +is responsible for: - Selecting the sandbox image. -- Injecting sandbox identity and gateway callback configuration. -- Supplying TLS or secret material for supervisor callbacks. -- Providing the supervisor binary or image in the workload. +- For supervisor-controlled runtimes, injecting sandbox identity and gateway + callback configuration, supplying callback credentials, and providing the + supervisor binary or image. +- For runtimes without the standard supervisor, validating and applying the + canonical policy before launching the workload. +- Forwarding the exact canonical main-process argv and TTY mode without shell + reconstruction. The sandbox-level environment and policy workspace apply to + the main process. - Reporting lifecycle and platform events back to the gateway. - Cleaning up runtime-owned resources. -Drivers report **backend state only**. A driver snapshot with `Ready=True` means -the underlying compute resource (container, pod, VM) is healthy and running — -nothing more. Drivers must not gate on supervisor session state or hold -references to gateway-internal types. The gateway owns the public -`SandboxPhase::Ready` decision. This applies equally to extension drivers -implementing `ComputeDriver` out of tree. +Drivers report **runtime-observed state only** and must not hold references to +gateway-internal types. For supervisor-controlled runtimes, `Ready=True` means +only that the compute resource is healthy; the gateway also requires a +supervisor session before publishing `SandboxPhase::Ready`. For +drivers that report runtime readiness, `Ready=True` is authoritative because the driver +launches and monitors the policy-constrained workload itself. + +`compute_driver.proto` is the supported gateway/driver extension boundary. +At initialization the gateway snapshots the driver's identity, version, +default image, gateway-lifecycle preference, and +`driver_reports_runtime_readiness` from `GetCapabilities`. The gateway includes +the canonical `SandboxPolicy` in `DriverSandboxSpec.policy` for validation and +creation. Drivers that enforce policy outside the standard supervisor fetch +later revisions through `GetSandboxConfig` and acknowledge them through +`ReportPolicyStatus`. +Process-identity omissions are preserved across this boundary so every driver +can apply its native image or runtime defaults. Driver-requested listeners are +structurally validated and remain restricted to sandbox callback RPCs. + +Canonical main-process support is part of the `ComputeDriver` contract. Every +in-tree and extension driver must forward the exact specification; it is not an +optional capability that drivers can omit or negotiate. Drivers own runtime-specific platform event interpretation. When an event should drive client provisioning UI, the driver attaches the shared @@ -31,28 +54,29 @@ reason strings. ## Sandbox Readiness Composition -The gateway composes driver backend state with supervisor session presence to -produce the public `SandboxPhase`. This composition is gateway-owned and applied -uniformly across all drivers: +The gateway composes driver state with the advertised readiness behavior to +produce the public `SandboxPhase`: ``` backend_phase = derive_phase(driver_status) public_phase = if backend_phase in {Error, Deleting}: → pass through (terminal precedence) + if driver_reports_runtime_readiness && backend_phase == Ready: → Ready if backend_phase == Ready && session connected: → Ready if backend_phase == Ready && no session: → Provisioning if backend_phase in {Provisioning, Unknown} && session: → Ready if backend_phase in {Provisioning, Unknown} && no session: → Provisioning ``` -When `public_phase == Ready` the sandbox is usable through the gateway — both the +For a supervisor-controlled runtime, `public_phase == Ready` means both the backend resource is healthy and a supervisor session is registered. A sandbox whose backend reports ready but has no supervisor session yet holds `Provisioning` with a `Ready=False`, `SupervisorNotConnected` condition and the message `Backend ready; waiting for supervisor session`. This distinguishes it from a sandbox whose compute resource is still provisioning without exposing contradictory public -readiness signals. +readiness signals. When the driver reports runtime readiness, its ready condition +is published without waiting for a supervisor session. **Session precedence over lagging driver snapshots:** A supervisor session can only be established by a running workload. When `set_supervisor_session_state` promotes the @@ -71,10 +95,12 @@ session-owning replica. That work is deferred to GitHub issue #1868. Until then, deployments that require reliable readiness composition must run a single gateway replica. -**Extension point:** The readiness decision is a safety invariant, not an -operator-configurable hook. The driver contract is the correct extension point for -custom backend readiness semantics. RFC-0010 lifecycle hooks may observe readiness -transitions via `post_commit`; they do not override the composition rule. +**Extension point:** Driver-reported readiness is a capability, not an +operator-configurable hook. A driver may enable it only when it owns workload +readiness. Policy delivery remains independent: create-time policy is embedded +in the sandbox specification, and later revisions use the existing sandbox +configuration API. RFC-0010 lifecycle hooks may observe readiness transitions via +`post_commit`; they do not override the composition rule. The capability RPC reports driver identity, version, and the default sandbox image used by the gateway. GPU availability stays driver-local and is validated @@ -84,19 +110,108 @@ The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead of re-querying drivers on each request. +## Compiled Driver Selection + +The gateway binary explicitly installs the compute drivers compiled into that +binary before entering server startup. The server selects a configured driver +by normalized registry name. When no driver is configured, it evaluates only +the installed drivers' probes and chooses the lowest registered priority. +Drivers without a probe, including VM, remain opt-in. + +Startup computes this selection once after merging configuration. The same +selection drives authentication defaults and runtime construction, so a probe +result cannot change which driver is constructed later in startup. + +This follows the same composition model as SQLx's `Any` drivers: the binary +defines the available implementation set, while the runtime consumes a generic +registry. Adding or removing a compiled driver therefore changes registration +rather than the server's selection flow. Alternate gateway binaries can install +their own `ComputeDriverFactory` registrations and hand the completed registry +to `run_cli_with_compute_drivers`; factories receive merged driver config and +return either an in-process driver or a gateway-managed remote endpoint. The +server constructs the common runtime adapter and snapshots `GetCapabilities` +for either result. A configured UDS endpoint still takes precedence over a +compiled registration with the same name. + +The `openshell-gateway` composition crate groups first-party registrations +behind the `in-tree-compute-drivers` feature. `openshell-server` has no compute +driver dependencies or backend-name dispatch. Protocol-only gateway builds +disable the composition feature and link no compute-driver crates. E2E lanes +compose that gateway with Docker, Podman, Kubernetes, and VM driver executables +over the public UDS gRPC contract so an in-tree driver cannot silently depend +on a server-only API. + +## Stop and Start Lifecycle + +The gateway persists lifecycle intent before mutating compute: + +```text +Ready -> Stopping -> Stopped -> Starting -> Ready +``` + +A canonical main process that exits successfully follows `Ready -> Completed`. +A nonzero or signal-normalized result follows `Ready -> Error` with a +`MainProcessFailed` condition. Both retained results may be started explicitly, +which creates a fresh main-process instance. Drivers must not automatically +restart a completed or failed canonical process. Before an explicit restart, +the gateway disconnects the prior supervisor session and deletes its SSH +sessions so credentials cannot cross runtime generations. + +`StopSandbox` and `StartSandbox` are idempotent driver operations. Stop +retains the driver resource and its persistent workspace boundary while making +exec, SSH, forwarding, and exposed services unavailable. Start reactivates the +same resource. The gateway requires a fresh supervisor session before a +starting sandbox returns to `Ready`; stale driver snapshots and supervisor +sessions cannot promote a `Stopped` row. + +A driver stop operation does not complete while its backend still reports an +in-progress stop. This prevents an immediate start from racing the previous +run's delayed exit event and regressing the new run to `Error`. + +Persisted `Stopping` and `Starting` rows are retried at startup. Stable +`Stopped` rows remain stopped. Docker and Podman retain the stopped container +and attached storage, Kubernetes retains the Sandbox CR and PVC while scaling +compute to zero, and VM retains its launch request and writable overlay beside +a stop marker. Delete remains a separate operation that removes these +resources. + +On graceful gateway shutdown, persisted running intent for Docker, Podman, and +VM is stopped through the shared `StopSandbox` RPC before any gateway-managed +driver process exits. The gateway does not persist `Stopped` for this +infrastructure event. On startup, it reconciles the retained intent through the +shared idempotent `StartSandbox` RPC before watch processing begins. Explicitly +`Stopped` sandboxes are excluded from both sweeps. Kubernetes workloads are +cluster-owned and continue running without gateway shutdown or startup +lifecycle calls. + +The driver reports this behavior through +`GetCapabilities.gateway_manages_lifecycle`. The same declaration works for +in-process and external drivers. Older drivers omit the field and retain the +conservative operator-managed behavior. + +Drivers that can verify a platform-native sandbox credential advertise +`GetCapabilities.supports_sandbox_authentication`. On the path-scoped +`IssueSandboxToken` exchange, the gateway forwards the opaque bearer credential +to that selected driver through `AuthenticateSandbox`. The driver returns only +the authenticated sandbox ID. The gateway then verifies that its durable +sandbox record exists and mints the gateway JWT. The driver socket is therefore +a sandbox-identity trust boundary, but it does not grant user or administrator +authority. + ## Deletion Lifecycle -Delete requests use per-sandbox gates to serialize delete attempts. A request +Lifecycle requests use per-sandbox gates to serialize stop, start, and +delete attempts. A delete request resolves the name once and remains bound to that stable ID. The only -combined lock order is delete gate, then the gateway-wide state guard; external +combined lock order is lifecycle gate, then the gateway-wide state guard; external driver calls run without the global guard. -Delete gates are process-local and do not coordinate gateway replicas. They +Lifecycle gates are process-local and do not coordinate gateway replicas. They serialize attempts rather than share results: if one attempt fails and recovery restores a deletable state, a request waiting on the gate may retry the driver. Persisted resource-version checks remain the cross-replica safety boundary. -Watcher events do not acquire delete gates. Exact resource-version checks allow +Watcher events do not acquire lifecycle gates. Exact resource-version checks allow them to interleave safely: status snapshots are no-ops for `Deleting` rows, deleted events are idempotent, and snapshots for absent rows are ignored. @@ -105,28 +220,54 @@ backend is already absent (`deleted = false`), the request removes gateway state synchronously. Sandbox row removal remains bound to the stable ID and resource version. Settings retain their existing best-effort name-based cleanup; SSH sessions, indexes, and watch/log buses are cleaned after confirmed removal. +Owned-record cleanup discovers records before mutating them and uses bounded +set-based deletes so teardown cannot amplify one sandbox into an unbounded +sequence of individual persistence writes. + +When a sandbox is instead discovered gone out-of-band — a watcher deletion +event, or the periodic prune sweep finding no matching driver resource, with +no explicit `DeleteSandbox` request involved at all — the gateway also +releases driver-owned resources (for example Podman's per-sandbox secrets and +workspace volume) by calling the driver's idempotent `DeleteSandbox`, not just +gateway state. Both paths skip that call when a request-side lifecycle +operation already holds the sandbox's gate, since that operation already owns +driver-side cleanup. The watch path defers the call itself to a background +task after a non-blocking gate check, so a slow driver call cannot stall the +sequential watch loop; the prune sweep calls the driver inline, since it +already makes a blocking `GetSandbox` call per sandbox as part of its normal +operation. The request acquires both locks before starting owned work, so cancellation while queued does not leave a delete armed. After that commitment point, the owned task prevents cancellation from stranding a mutation. A gateway restart -does not resume a persisted `Deleting` operation. If the backend completed the +does not start a persisted `Deleting` operation. If the backend completed the delete, reconciliation removes the row; otherwise it can remain `Deleting`. ## Runtime Summary | Runtime | Best fit | Sandbox boundary | Notes | |---|---|---|---| -| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. | -| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API, OCI image volumes, and CDI GPU devices when available. | +| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. Advertises the combined-supervisor policy-DNS and transparent-TCP substrate. | +| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API and CDI GPU devices when available. Delivers the supervisor via OCI image volume by default; falls back to extracting the binary to a host-side cache and bind-mounting it when `userns` is configured (overlay does not support idmapped mounts). Advertises the combined-supervisor policy-DNS and transparent-TCP substrate. | | Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. | | VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Managed endpoint-backed driver. The gateway spawns `openshell-driver-vm`, waits for its Unix socket, and then consumes it through the same remote `compute_driver.proto` path used by unmanaged endpoint drivers. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. | -| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a non-reserved custom `compute_drivers = [""]` entry with `[openshell.drivers.].socket_path`, or at launch time by pairing `--drivers ` with `--compute-driver-socket=`. Reserved built-in names such as `vm`, `docker`, `podman`, and `kubernetes` cannot be used as unmanaged socket endpoints. The gateway connects to a UDS the operator already provisioned, runs `GetCapabilities`, logs the advertised `driver_name`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. | +| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a custom `compute_drivers = [""]` entry with `[openshell.drivers.].socket_path`, or at launch time by pairing `--drivers ` with `--compute-driver-socket=`. A launch-time endpoint may use a canonical built-in name to preserve its driver-config key while replacing in-process construction. The gateway connects to an operator-provisioned UDS, snapshots `GetCapabilities`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. | Per-sandbox CPU and memory values currently enter the driver layer through template resource limits. Docker and Podman apply them as runtime limits. Kubernetes mirrors each limit into the matching request. VM accepts the fields but currently ignores them. +Reusable sandbox workload templates are resolved before the compute-driver +boundary. Drivers do not receive a separate template resource; the gateway +lowers the selected `SandboxWorkloadTemplate` into the existing sandbox spec +and validates that spec before calling `ValidateSandboxCreate` or +`CreateSandbox`. Template CPU and memory become the same typed resource limits +described above. Template GPU settings become `ResourceRequirements`, preserving +the driver's default GPU assignment when the count is omitted. Template +`driver_config` remains a driver-keyed envelope until the compute layer selects +the active driver block and forwards only that block to the driver. + Docker and Podman also accept per-sandbox driver-config mounts for existing runtime-managed named volumes and tmpfs mounts. Podman additionally accepts image mounts through its image-volume API. User-supplied bind and volume mounts @@ -137,11 +278,32 @@ operator override because they place gateway-host filesystem state inside the sandbox and can negate OpenShell workspace isolation and filesystem-policy controls. Driver-owned supervisor, token, and TLS bind mounts stay reserved. +Network features follow the existing driver/substrate split. Compute drivers +advertise only the runtime mechanics they can guarantee: namespace and +capability ownership, DNS/TCP capture installation, and coupled +restart ordering. The shared supervisor remains the sole owner of DNS +eligibility, synthetic mappings, process authorization, destination filtering, +pinned dialing, relay behavior, and OCSF decisions. Docker and Podman advertise +`policy-dns-transparent-tcp`; other runtimes reject explicit TCP policy until +they implement and validate the same complete contract. The capability marker +is driver-owned supervisor input and is removed from workload environments. + Kubernetes deployments may set an AppArmor profile on sandbox agent containers through the driver configuration. The Helm chart defaults sandbox agents to `Unconfined` so runtime/default AppArmor profiles do not block supervisor network namespace setup on AppArmor-enabled nodes. +The Kubernetes deployment packaging has two ownership boundaries. The gateway +chart owns the gateway workload, configuration, Services, PKI, and +cluster-scoped gateway resources. It can retain the legacy combined behavior, +or omit workspace resources. The workspace chart is installed into a +pre-provisioned sandbox namespace and owns only the sandbox ServiceAccount, +namespaced RBAC, and sandbox ingress NetworkPolicy. Its RoleBinding names the +gateway ServiceAccount and namespace explicitly, so the two releases have +disjoint lifecycle ownership. A shared-mode gateway can target one external +namespace, while operator mode maps workspace names to multiple +platform-provisioned namespaces. + Resource requirements enter the driver layer through `SandboxSpec.resource_requirements`. This includes a set of GPU requirements, where a user can request a specific number of GPUs or the driver-specific default behaviour. For all in-tree drivers, this is equivalent to selecting a single GPU. @@ -170,7 +332,7 @@ The supervisor must be available inside each sandbox workload: | Runtime | Delivery model | |---|---| | Docker | Bind-mounted local supervisor binary, or a binary extracted from the configured supervisor image. | -| Podman | Read-only OCI image volume containing the supervisor binary. | +| Podman | Read-only OCI image volume by default; host-cached bind mount when `userns` is configured. | | Kubernetes | Supervisor image side-loaded into the sandbox pod by image volume or init container. | | VM | Embedded in the guest rootfs bundle. | | Extension | Defined by the out-of-tree driver. | @@ -185,11 +347,19 @@ The gateway preserves whether each policy process field was omitted. The active driver then supplies one authoritative identity input to the supervisor: - Docker and Podman inspect the final sandbox image, pin container creation to - its immutable image ID, and pass its raw OCI `Config.User`. + its immutable image ID, and pass its raw OCI `Config.User`. Docker also + resolves the workspace from OCI `Config.WorkingDir` during that inspection. - Kubernetes passes its platform-resolved numeric UID/GID, including OpenShift SCC-derived values. - VM keeps its existing guest identity behavior. +Explicit numeric workload identities may use any Linux UID/GID from `1` +through `u32::MAX - 1`. UID/GID `0` remains prohibited as root, and +`u32::MAX` remains prohibited because Linux APIs and POSIX ACLs use it as an +invalid identity sentinel. Infrastructure identities use separate validation: +the Kubernetes network proxy UID remains at least `1000` and must not match the +workload UID because its traffic bypasses the pod egress fence. + For Docker and Podman, policy values take precedence independently. An omitted `run_as_user` or `run_as_group` falls back to the corresponding identity from the image. The supervisor resolves names from the image's `/etc/passwd` and @@ -198,6 +368,24 @@ and uses the same privilege-drop path for direct and SSH children. When a declaration omits the group, the supervisor fills it with the user's numeric primary GID. It does not rewrite the account files. +Docker uses an absolute OCI working directory as the workspace. An +empty, root (`/`), or explicit `/sandbox` declaration uses `/sandbox`, which +OpenShell creates and owns as a compatibility workspace. Any other workdir must already +exist in the immutable image without symlink components. The completed +identity, including supplementary groups, must already be able to traverse +every parent and write and enter the workdir; OpenShell does not change that +directory's ownership or mode. A one-shot validator drops to that identity and +uses kernel effective-access checks so POSIX ACL and LSM decisions are honored. +Path checks reserve the standard OCI runtime namespaces under `/proc`, `/sys`, +and `/dev`, while separate collision checks are derived from actual OpenShell +control paths. +Docker performs the check in the final container before workload launch and +rejects image `VOLUME` declarations that would mask the workdir ancestry. The +resolved workspace is the child cwd and `HOME`; when +`filesystem.include_workdir` is enabled, it becomes the automatic writable +policy path. Podman, Kubernetes/OpenShift, and VM retain their existing +`/sandbox` workspace behavior. + Sandbox creation fails before the workload becomes ready when a required image identity is absent, malformed, unknown, ambiguous, or resolves to UID/GID 0. The supervisor itself remains root so it can establish isolation before @@ -262,10 +450,109 @@ chart deploys the gateway and sandbox runtime integration. The default gateway workload is a StatefulSet for SQLite-backed single-replica installs. External database-backed installs can render a Deployment with `workload.kind=deployment`; HA deployments must point `server.externalDbSecret` at an operator-managed -PostgreSQL database. +PostgreSQL database. Agent Sandbox CRDs and controller lifecycle remain +operator-owned; the chart can optionally preflight for a served supported API +but does not install the cluster-scoped dependency. Standalone local deployments start the gateway with a selected runtime such as Docker, Podman, or VM. The CLI can register multiple gateways and switch between them without changing the sandbox architecture. +## Workspace Namespace Modes (Kubernetes) + +The Kubernetes driver maps workspaces to namespaces through the `workspace_mode` +configuration field (`WorkspaceMode` in `crates/openshell-driver-kubernetes/src/config.rs`). +The mode controls namespace resolution, resource naming, sandbox CR watching, SA +token authentication, and RBAC requirements. + +| Mode | Namespace resolution | Resource name | Namespace lifecycle | +|---|---|---|---| +| **Shared** (default) | Single static namespace from config | `{workspace}--{name}` | None | +| **Managed** | `openshell-{gateway_id}-{workspace}` | bare sandbox name | Driver creates and deletes | +| **Operator** | Workspace name maps 1:1 to a pre-provisioned namespace | bare sandbox name | External (platform team) | + +**Shared** renders all sandboxes into one configured namespace. Resource names +embed the workspace prefix for collision avoidance. No namespace lifecycle +management. RBAC uses a namespace-scoped Role. + +**Managed** auto-creates a K8s namespace per workspace on first sandbox create. +Each new namespace receives a ServiceAccount and the configured gateway-only +SSH ingress NetworkPolicy. Configured image-pull Secrets are copied from the +driver's source namespace on every sandbox create so registry credential +rotations propagate. The namespace also copies OpenShift SCC UID-range and +supplemental-group annotations from the gateway namespace when present. The +driver deletes the namespace during workspace deletion. The workspace remains +durably `Terminating` until the Kubernetes API accepts namespace cleanup, so a +transient failure can be retried. Namespace deletion uses the fetched UID as a +precondition to avoid deleting a replacement namespace. Requires a non-empty +`gateway_id` (validated as a +DNS-1123 label at startup) so the namespace prefix fits within the K8s 63-character +limit. RBAC promotes sandbox CRD permissions to a ClusterRole and adds namespace +`create`/`delete` and ServiceAccount `create`/`get` permissions. + +Secret copies use server-side apply. Kubernetes authorizes an apply to an +existing Secret as `patch`, but also requires `create` authorization when the +target does not exist. RBAC cannot constrain `create` by `resourceNames`, so +managed mode grants cluster-wide Secret `create` while keeping source reads and +subsequent patches restricted to the explicitly configured TLS and image-pull +Secret names. The driver exercises `create` only in gateway-owned managed +namespaces. This depends on the managed-mode ownership invariant described +below; the gateway ServiceAccount must not be shared with unrelated workloads. + +Operator mode does not create NetworkPolicies or copy image-pull Secrets. +Platform teams must apply the gateway ingress boundary and provision configured +image-pull Secrets in every operator-managed namespace. + +**Operator** uses pre-provisioned namespaces discovered through two optional +sources: a K8s label selector (`operator_namespace_label`) and a drop-in +allowlist file (`operator_namespace_file`). Exactly one must be configured. +The compute driver and the gateway's ServiceAccount authenticator independently +watch that public config source; no in-process driver state crosses into the +server. Sandbox creation and token bootstrap fail closed if the workspace is +not in the current allowlist. Platform teams manage namespace lifecycle +externally. RBAC uses the same ClusterRole as managed mode but without namespace +`create`/`delete` or ServiceAccount permissions. + +### Watching and Querying + +Managed and operator modes set `is_multi_namespace() == true`, which switches +sandbox CR watchers from namespace-scoped `Api::namespaced` to cluster-wide +`Api::all_with`. In managed mode the driver scopes cluster-wide queries with a +`LABEL_GATEWAY_ID` label selector to support multiple gateways on the same +cluster. K8s Events are not watched in cluster-wide mode — the cluster-wide +watcher emits only sandbox CR changes, not platform events. + +### SA Token Authentication + +The Kubernetes driver's `AuthenticateSandbox` implementation applies its named +`[openshell.drivers.kubernetes]` configuration per mode: + +- **Shared:** `Exact` — accepts only the single configured namespace. +- **Managed:** `Prefix` — accepts any namespace starting with `openshell-{gateway_id}-`. +- **Operator:** `Allowlist` — accepts namespaces present in the dynamic + `BTreeSet` populated by the label/file watchers. Starts empty (fail-closed) + until the first watcher update. + +It validates the projected token with Kubernetes `TokenReview`, checks the live +pod UID, and verifies the pod's controlling Sandbox CR UID and sandbox ID before +returning the identity to the gateway. These checks rely on an ownership +invariant. In shared and managed modes, the Kubernetes driver and its trusted +Agent Sandbox controller exclusively administer the sandbox namespace, Sandbox +CRs, sandbox pods, and configured sandbox ServiceAccount. Other principals must +not create or mutate those resources or use that ServiceAccount. In operator +mode, the platform operator retains +namespace lifecycle ownership, but must preserve the same exclusive control of +Sandbox CRs and the pods and ServiceAccount used for sandbox token bootstrap. +An allowlisted namespace is therefore a trust grant, not a tenant isolation +boundary. Kubernetes owner references alone do not prove which controller +created a pod, so admitting principals that can fabricate that resource chain +would allow them to claim an existing sandbox identity. + +### Credential Driver Integration + +The Kubernetes Secrets credential driver (`openshell-driver-kubernetes-secrets`) +stores secrets in workspace-specific namespaces when `workspace_mode` is managed +or operator. In shared mode, all secrets render into the single configured +namespace. + When runtime infrastructure changes, validate the relevant sandbox e2e path and update the matching driver README if a maintainer-facing constraint changes. diff --git a/architecture/gateway.md b/architecture/gateway.md index d57096f734..ba325ccc2c 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -15,11 +15,29 @@ workloads. - Resolve provider credentials and inference bundles for sandbox supervisors. - Coordinate supervisor relay sessions for connect, exec, file sync, and service forwarding. +- Persist the canonical main-process instance ID and normalized exit code on + sandbox status. Exit code zero transitions the sandbox to `Completed`; + nonzero results transition it to `Error/MainProcessFailed`. Infrastructure + failures also use `Error`, with a distinct reason and no fabricated command + result. The gateway does not enforce agent network policy at request time. That happens inside each sandbox, where the supervisor and proxy can observe local process identity. +The live supervisor session is the readiness authority for its main-process +instance. The supervisor reports its normalized result through the +sandbox-authenticated `ReportMainProcessExit` RPC, and the gateway rejects +results from stale instance IDs. Foreground creation carries a one-shot +attachment intent to the process supervisor. The supervisor durably reports the +result immediately, accepts that declared SSH attachment even when the process +has already exited, sends the retained output and exit status, and waits for the +peer's channel close before finalizing the result for ephemeral cleanup. +Detached commands carry no attachment intent, so they finalize and exit +immediately without a grace period. Finalization is persisted separately from +the exit result; the gateway deletes an ephemeral sandbox only after the +finalized supervisor session disconnects. + ## Protocol and Auth The gateway listens on one service port and multiplexes gRPC and HTTP traffic. @@ -37,19 +55,17 @@ health, metrics, or tunnel routes. The plaintext service router also rejects browser requests whose Fetch Metadata, Origin, or Referer headers indicate a cross-origin or sibling-subdomain request. -Docker and Podman may negotiate additional listeners that make the gateway -reachable from their local sandbox network topology. Those listeners accept +Docker and Podman report the local address through which their sandboxes can +reach the gateway. When the primary listener covers that address, the gateway +reuses it; sandbox JWT authentication and its RPC allowlist remain the callback +authorization boundary. When the primary listener does not cover the address, +the gateway adds a callback-only listener. Additional callback listeners accept only gRPC methods classified as sandbox-callable by the gateway's generated authorization metadata. They reject user and administrator APIs, health, reflection, non-callback inference APIs, and HTTP routes before normal request authentication. The operator-configured primary listener retains the full multiplexed API surface. -The gateway rejects a callback requirement that resolves to the exact primary -listener address because one socket cannot preserve two authorization scopes. -A wildcard primary listener may cover a callback address because the accepted -connection's concrete local address still selects the callback-only scope. - The `rpc_auth` classification is also the source of truth for negotiated listener exposure: marking an RPC as `sandbox` or `dual` makes it callable on these listeners. Review such changes as both authorization and network-surface @@ -74,6 +90,26 @@ until deliberately added to this allowlist. Interception remains centralized: allowlisting a unary RPC does not require method-specific gateway instrumentation. +Remote extension clients share `openshell-extension-core` transport and bearer +primitives. When gateway JWT signing is configured, the gateway mints +short-lived, exact-audience EdDSA credentials for middleware and interceptors, +rotates their in-memory slots without rebuilding clients, and publishes the +public verification key at `/.well-known/jwks.json` alongside OIDC-shaped +discovery metadata at `/.well-known/openid-configuration`. HTTPS extensions can +pin an operator-provided CA while retaining endpoint-hostname verification. + +Extension credentials reuse the sandbox signing key and are separated from +sandbox-to-gateway admission tokens by exact audience and by an explicit +`typ` of `openshell-ext+jwt`, so a verifier that checks either one alone +cannot confuse the two. After authenticated `Describe` succeeds, a service may +advertise `expected_audience` as a post-authentication consistency assertion; +a mismatch against operator configuration fails gateway startup. A strict +verifier may reject an incorrect audience before returning the manifest. A +registration may opt out of extension authentication entirely with +`allow_insecure_transport`, which permits a plaintext endpoint, attaches no +credential, and warns at every startup. Credential minting is bounded per +sandbox because it resolves the caller's effective policy. + Each configured interceptor selects a binding policy. `dynamic` accepts valid manifest declarations and preserves the compatibility behavior. `allowlist` enables only operator-configured RPCs and phases, while `exact` requires the @@ -156,13 +192,21 @@ Supported auth modes: | Plaintext | Local development or a trusted reverse proxy boundary. | | Unauthenticated local users | Trusted Kubernetes dev or fully trusted proxy deployments only. | | Cloudflare JWT | Edge-authenticated deployments where Cloudflare Access supplies identity. | -| OIDC | Bearer-token auth for users, with browser PKCE or client credentials login. | +| OIDC | Bearer-token auth for users, with browser or device-code PKCE and client credentials login. JWKS validation accepts RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, and EdDSA (Ed25519) signing keys. | The CLI persists the scopes requested during OIDC login in gateway metadata and reuses them when refreshing an access token. This preserves the intended API resource selection for identity providers that bind access-token audiences to OAuth scopes. +Python and Go SDK client-credentials providers can use the same registered +issuer, client ID, audience, and scope metadata; the TypeScript provider accepts +those fields explicitly. All three own a separate in-memory lifecycle, repeat +the grant before expiry, and never persist the client secret or acquired access +token into the CLI token cache. They require TLS when sending renewable bearer +credentials to non-loopback gateways. This keeps non-interactive SDK +authentication independent from refresh-token rotation and shared disk state. + Gateway health and user authentication are separate probes. `OpenShell.Health` remains unauthenticated so deployment and load-balancer health checks do not depend on user credentials. The CLI uses the existing, side-effect-free @@ -191,11 +235,12 @@ identity inspection without client-side token decoding. Sandbox secrets are gateway-signed JWTs bound to a single sandbox ID. Docker, Podman, and VM drivers deliver the initial token through supervisor-only runtime material; Kubernetes supervisors exchange a projected ServiceAccount -token through `IssueSandboxToken`. The gateway validates that projected token -with Kubernetes `TokenReview`, requires the configured sandbox service account, -checks the returned pod binding against the live pod UID, and verifies the pod's -controlling `Sandbox` ownerReference against the live Sandbox CR UID and -sandbox-id label before minting the gateway JWT. The bootstrap path accepts +token through `IssueSandboxToken`. The gateway delegates that opaque credential +to the selected compute driver's `AuthenticateSandbox` RPC. A capable driver is +trusted to return the authenticated sandbox ID, while the gateway still requires +a matching durable sandbox record before minting a JWT. The Kubernetes driver +uses its own named configuration to run TokenReview and verify the live pod and +controlling Sandbox CR. The bootstrap path accepts both `agents.x-k8s.io/v1beta1` ownerReferences from newer Agent Sandbox controllers and `agents.x-k8s.io/v1alpha1` ownerReferences from existing deployments. Supervisors renew gateway JWTs in memory before expiry only while @@ -299,13 +344,47 @@ default WAL journal mode), which mirror the same sensitive contents. Persisted state includes sandboxes, providers, provider credential refresh state, SSH sessions, policy revisions, settings, inference configuration, and -deployment records. Provider refresh material is stored as a separate object -scoped to the provider instance through `objects.scope`; the provider record -keeps only the current injectable credential values and optional per-credential -expiry timestamps. A refresh normally mints one credential, but a strategy may -co-mint several (AWS STS mints the access key, secret key, and session token in -one call); the refresh state pins the resolved set of env keys it owns so -collision checks reserve all of them before the first mint. +deployment records, and reusable sandbox workload templates. Provider refresh +state is stored as a separate object scoped to the provider instance through +`objects.scope`. Its non-secret configuration remains inline, while refresh +tokens, client secrets, private keys, and other secret source material are +stored through the active credential driver and represented by opaque handles. +The provider record keeps only the current injectable credential handles and +optional per-credential expiry timestamps. A refresh normally mints one +credential, but a strategy may co-mint several (AWS STS mints the access key, +secret key, and session token in one call); the refresh state pins the resolved +set of env keys it owns so collision checks reserve all of them before the +first mint. Provider records keep inline credential values only for legacy +records created before credential driver storage. New provider and +refresh-material writes keep driver-owned credential handles. When no external +credential driver is configured, gateways use server-owned encrypted database +credential storage for defense in depth. Multi-replica deployments can use that +default with a shared database and shared key-encryption key, or opt into an +external backend such as Vault or Kubernetes Secrets. + +Sandbox workload templates are workspace-scoped gateway resources. Workspace +admins create and delete them; workspace users can read and list them. A +template owns reusable workload intent: image, environment, CPU and memory +limits, GPU request, driver-specific config, and service-level hints. A sandbox +created from a template resolves that resource once and persists an ordinary +`SandboxSpec` snapshot. The create request still owns per-sandbox governance: +name, labels, annotations, provider attachments, and policy. The sandbox stores +template provenance as the template name and resource version used for the +snapshot, so later template edits or deletes do not mutate existing sandboxes. + +OAuth refresh failures retain a gateway-owned recovery classification alongside +the refresh state. The gateway reads only a bounded error response and maps +recognized OAuth codes to retry, reauthorization, configuration repair, or +investigation without persisting issuer-controlled descriptions. Terminal +reauthorization failures remain parked until a manual retry or explicit refresh +reconfiguration. Configuration failures retry hourly so an externally repaired +clock, policy, or stored credential can recover without rapid endpoint traffic; +short-lived credentials still fail closed at their recorded expiry. + +Credential handles remain bound to the driver that created them. Before the +0.1.0 compatibility boundary, gateways do not migrate inline refresh material +or move handles between credential drivers; operators reconfigure affected +grants when upgrading or changing backends. ### Optimistic Concurrency (CAS) @@ -418,6 +497,9 @@ each service and validates its described bindings and operator body limit. Policies attach a complete external middleware by its operator-owned registration name. Manifest bindings are identified by operation and phase, and each manifest may declare at most one binding for an operation and phase pair. +Attaching a registration does not require it to advertise every supported +operation. Supervisors select only the manifest bindings that match the current +operation; policy-local config identity remains internal audit metadata. Before persisting a policy, the gateway asks each selected implementation to validate its config. The effective sandbox config contains only the registered services required by that policy; supervisors invoke those services directly on @@ -428,6 +510,18 @@ resolution and again by the sandbox placeholder resolver. This keeps expired credentials from resolving even when a running sandbox still has retained placeholder generations from an earlier provider credential snapshot. +Static credential delivery is capability-negotiated and endpoint-bound. The +gateway classifies each returned environment entry as either a credential or +non-secret provider configuration and associates every credential key with the +host, port, and path selectors from its effective provider profile. It withholds +static credential material from supervisors that do not advertise binding +support. If a selected provider profile has no usable endpoint, the gateway +withholds only that profile's static credential keys and their expiry and +binding metadata. It continues to return provider-generated non-secret +configuration, valid endpoint-bound static credentials from other attached +providers, and the dynamic credential snapshot. Provider environment revisions +include profile endpoint and binding changes. + ## Inference Resolution Cluster inference routes store only `provider_name`, `model_id`, and optional @@ -600,6 +694,15 @@ Driver implementation settings live in the TOML driver tables. See `docs/reference/gateway-config.mdx` for worked per-driver examples and RFC 0003 for the full schema. +Each installation has an operator-assigned gateway name. Configure it with +`[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. +The built-in default is `openshell`; the Helm chart defaults it to the chart +fullname so every replica in one installation reports the same identity. +Operators must set a globally distinct name when one telemetry collector serves +installations in multiple Kubernetes namespaces or clusters. +The name identifies the gateway installation independently of client-side +aliases, network names, and the sandbox JWT issuer. + `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). @@ -648,10 +751,27 @@ between a trace and its log lines. Store and compute-driver spans become children of the request span. Reconciliation, provider refresh, and driver-watch loops create their own operation spans because they have no inbound request to provide a parent. gRPC status is recorded when response -trailers arrive. - -The gateway forwards OTLP configuration and W3C trace context to managed -external drivers. Each driver exports under its own service name. +trailers arrive. Gateway spans carry resource attributes for the gateway +identity and configured compute driver. + +The gateway forwards OTLP configuration, its configured gateway name, and W3C +trace context to managed external drivers. Built-in drivers use dedicated +in-process providers that preserve the same RPC trace boundary. A shared +compute-driver tracing descriptor derives provider layers and standalone +installation from each driver's service identity, and routes both the shared +RPC boundary target and that driver's crate target prefix. A shared RPC tracer +owns unary and streaming boundary outcomes. Each driver +exports to the configured collector under its own service name and carries the +gateway name and configured compute driver as resource attributes. +Compute-driver client and server spans share the fully qualified protobuf +operation name, such as `openshell.compute.v1.ComputeDriver/CreateSandbox`, +in both the span name and `rpc.method`; the current RPC semantic conventions +integrate the service into that fully qualified method and do not emit +`rpc.service`. `service.name` and span kind distinguish the two sides. +Backend-prefixed spans describe implementation work beneath that boundary. +Streaming `WatchSandboxes` spans remain open for the stream lifetime on both +sides. A terminal stream status records its outcome; +consumer teardown without a terminal status leaves the span status unset. Two invariants shape the failure behavior. Telemetry is diagnostic, so no OTLP failure stops the gateway from serving: a malformed endpoint is logged at @@ -681,8 +801,10 @@ system entry instead of pretending to delete package-manager owned state. - Compute runtimes own the mechanics of starting workloads and injecting callback configuration. - Docker-backed local gateways use Docker's `host-gateway` callback alias on - macOS and Docker Desktop-style runtimes. Native Linux Docker may expose an - additional bridge-gateway listener because the host can bind that bridge IP. + macOS and Docker Desktop-style runtimes. They request IPv4 loopback callback + reachability and add a listener only when the primary does not cover it. + Native Linux Docker may expose an additional bridge-gateway listener because + the host can bind that bridge IP. - Podman-backed macOS gateways use gvproxy's host-loopback IP for sandbox host aliases by default so stale Podman machine images do not need Podman's `host-gateway` resolver. Linux Podman keeps the resolver unless diff --git a/architecture/google-vertex-ai-provider.md b/architecture/google-vertex-ai-provider.md index aac0c11c27..e161471c9f 100644 --- a/architecture/google-vertex-ai-provider.md +++ b/architecture/google-vertex-ai-provider.md @@ -375,7 +375,7 @@ The `discovery` section lists `[service_account_token, gcloud_adc_token]` as the credential sources the gateway will scan during `--from-existing`. The `endpoints` section enumerates all Vertex AI API hosts that sandbox network -policies must permit when `providers_v2_enabled=true`: +policies permit through provider profile composition: - `*-aiplatform.googleapis.com:443` (regional endpoints) - `aiplatform.googleapis.com:443` (global endpoint) diff --git a/architecture/sandbox-limits.md b/architecture/sandbox-limits.md new file mode 100644 index 0000000000..9635bc1c30 --- /dev/null +++ b/architecture/sandbox-limits.md @@ -0,0 +1,182 @@ +# Sandbox Limits + +The sandbox supervisor processes untrusted agent traffic while sharing one +process across connections. Limits protect that process from unbounded memory, +work queues, parser effort, and waits. They are safety boundaries, not capacity +targets or compatibility guarantees. OpenShell is still early, so the values +and some ownership boundaries will change as production evidence improves. + +This document inventories durable sandbox supervisor and egress limits. It +intentionally omits retry cadence, test-only values, and ordinary buffer chunk +sizes that do not cap aggregate resource use. + +## Limit Model + +OpenShell uses three kinds of limits: + +| Kind | Purpose | Configuration | +|---|---|---| +| Platform ceiling | Protect the supervisor even when policy or an external service is hostile or mistaken. | Fixed in code by default. | +| Operator ceiling | Bound an operator-run integration below the platform maximum. | Configurable within platform validation bounds. | +| Policy inspection bound | State how much application data a policy needs the supervisor to buffer and inspect. | Configurable when the application protocol needs it, preferably below a platform ceiling. | + +The component that first allocates, queues, or waits on a resource owns its +limit. Network frame and message assembly belongs to the network supervisor; +middleware RPC and envelope limits belong to the middleware runner; application +inspection limits belong to the corresponding L7 parser. + +New limits should follow these rules: + +- Enforce the bound before allocation or admission whenever possible. +- Acquire shared capacity before buffering and retain it through the complete + buffered operation. +- Give partial-progress protocols both an idle bound and an absolute bound when + either one alone still permits resource pinning. +- Let operator or policy configuration narrow a platform ceiling, never raise + it silently. +- Define the terminal behavior: reject, close, truncate, shed, or backpressure. +- Do not let `fail_open` bypass a platform safety or protocol-integrity bound. +- Emit safe saturation or limit telemetry without request bodies, credentials, + query parameters, or external free-form diagnostics. +- Test time bounds with simulated time and test shared budgets under saturation. + +## Gateway Sandbox Resources + +Gateway-owned sandbox resources also carry admission limits before they can +produce supervisor work. Reusable workload templates are capped at 1000 per +workspace. Template payloads reuse sandbox spec validation for environment +entry count and size, image and resource field sizes, driver-config serialized +size, and GPU count. Template names use the same DNS-style resource-name rules +as other named gateway resources. + +## Middleware + +Middleware limits are process-wide per sandbox. Registry replacement preserves +the shared work, waiter, and persistent-session admission state so activity +retained by an older generation still consumes the same process-lifetime +budgets as new activity. + +| Resource | Current bound | Scope and behavior | +|---|---:|---| +| Concurrent buffered work | 32 | Shared by HTTP requests, WebSocket messages, and WebSocket preflight. One permit covers one complete unit of work. | +| Admission waiters | 64 | Additional work is shed when both the active budget and waiter budget are full. HTTP receives a complete 503 response before its body is buffered. | +| Persistent middleware sessions | 32 | Shared process-wide session budget for streaming middleware protocols. WebSocket preflight uses immediate admission before opening streams and retains one permit while any stage remains active. | +| HTTP body or WebSocket text message | 4 MiB | Platform maximum for input and replacement payloads. Service, operator, and stage limits may narrow it. | +| Middleware configs and stages | 10 | At most 10 configs in policy and 10 selected stages in one chain. | +| Selector patterns | 32 | Combined include and exclude patterns per middleware config. | +| Per-stage RPC | 500 ms default, 10 ms–30 s | An operator timeout caps a binding timeout. | +| Complete message chain | 30 s | Starts after work admission; admission backpressure does not consume the chain budget. | +| WebSocket preflight | 1 s maximum | Caps handshake delay independently of the message RPC timeout. | +| Remote service connect | 5 s | Applies while establishing a middleware gRPC channel. | + +Middleware also validates every non-body envelope component. Important examples +include 64 KiB service config, 4 KiB request context, 32 KiB target data, 128 +request headers totaling 64 KiB, 64 header mutations, 32 findings per stage, +and 64 metadata entries. The detailed external contract lives in +[Supervisor Middleware](../docs/extensibility/supervisor-middleware.mdx). + +The work semaphore bounds aggregate buffered middleware input to approximately +`32 × 4 MiB`, plus bounded envelope and parser overhead. It is a concurrency +safety valve, not rate limiting or a promise that 32 simultaneous maximum-size +messages are inexpensive. + +The persistent session semaphore is independent from the work semaphore. One +WebSocket middleware session consumes one permit regardless of its active-stage +fan-out, which is separately capped at 10 stages. All-skip preflight releases +the permit immediately. A retained session releases it at connection end or as +soon as its last active stage is disabled. Session admission does not wait: +capacity exhaustion follows each selected config's `on_error` behavior before +any stream opens. The protocol-neutral registry ownership allows future +streaming HTTP middleware to use the same process-wide budget. + +## Egress Framing and Inspection + +| Path | Current bound | Terminal behavior | +|---|---:|---| +| Initial CONNECT request headers | 8 KiB | Reject the proxy request. | +| Inspected HTTP/1 request headers | 16 KiB | Reject the request. | +| Credential-rewritten HTTP body | 256 KiB | Reject when rewriting requires a larger buffered body. | +| SigV4 body signing | 10 MiB | Reject when signing requires a larger buffered body. | +| GraphQL request body | 64 KiB default | Policy can set a positive `graphql_max_body_bytes`; there is no shared platform ceiling yet. | +| MCP or JSON-RPC request body | 64 KiB default | Policy can set a positive `max_body_bytes`; there is no shared platform ceiling yet. | +| Parsed WebSocket client text message | 4 MiB | Close with `1009` when the complete or decompressed message is larger. | +| Concurrent parsed WebSocket text assemblies | 32 active, 64 waiters | Shared process-wide across all parsed relays. Additional messages close with `1013` before payload allocation or reading when both bounds are full. | +| Parsed WebSocket fragments per message | 4,096 | Close with `1002`. | +| Parsed WebSocket text assembly | 30 s input idle, 2 min total | Close with `1002`; the total includes initial and continuation payloads, continuation headers, and interleaved control frames. These deliberately permissive initial bounds can be tightened, or made operator-tunable within platform bounds, after production behavior is understood. | +| Parsed WebSocket text forwarding | 2 min total | End the relay and release assembly capacity. A timeout does not append a close frame to a partially written data frame. | +| Parsed raw WebSocket binary frame | 16 MiB | Close with `1002`; binary messages are relayed rather than inspected. | +| HTTP relay waiting for EOF | 5 s input idle | End the relay with a timeout. | +| TLS certificate cache | 256 hosts | Clear the cache before inserting another host. | + +Ordinary allowed traffic is streamed rather than accumulated to a +connection-sized buffer. A parsing or transformation feature introduces a +buffer only when it owns an explicit bound. + +Every parsed WebSocket text message acquires network-owned assembly capacity before payload allocation or reading, including relays used only for native policy, credential rewriting, compression, or a disabled fail-open middleware session. The process-lifetime budget survives policy reloads, and the assembly retains its permit through decompression, policy and middleware evaluation, credential rewriting, and upstream forwarding. Active middleware sessions additionally acquire shared middleware work before buffering. Input progress resets only the idle deadline. Forwarding uses one total deadline across the complete frame header, payload, and flush. Every timeout and terminal parser error releases both permits through ordinary ownership. Queue exhaustion emits a payload-free network denial event. + +The operator middleware `max_payload_bytes` ceiling applies to payloads exposed +through HTTP-body and WebSocket text-message bindings. It does not replace the +raw binary frame safety bound because binary messages are never delivered to V1 +middleware. A passed binary logical message still advances the active +middleware session sequence and emits coverage telemetry, so a later text RPC +can contain a valid sequence gap. + +## Inference and Upstream Proxying + +| Path | Current bound | Terminal behavior | +|---|---:|---| +| `inference.local` request parse buffer | 10 MiB | Return `413` for an oversized request. | +| Chunked inference request | 10 MiB and 4,096 chunks | Reject an invalid or over-limit request. | +| Streaming inference response | 32 MiB and 120 s chunk idle | Truncate the stream and attempt a safe SSE error. | +| Corporate proxy CONNECT response headers | 8 KiB | Fail the tunnel. | +| Corporate proxy CONNECT handshake | 30 s total | Fail the tunnel; validated-address attempts share the aggregate budget. | +| Token-grant HTTP request | 30 s request and connect | Fail credential resolution. | +| Response-derived token cache TTL | 5 min default; 1 h response cap; 30 s expiry margin | A positive profile `cache_ttl_seconds` override replaces the response-derived calculation. | + +Streaming response byte limits are integrity-relevant. Protocols whose clients +require one complete buffered object do not use the truncating SSE path. + +## Sandbox-Local Surfaces + +| Surface | Current bound | Scope and behavior | +|---|---:|---| +| `policy.local` request body | 64 KiB and 15 s read | Reject an oversized or stalled local request. | +| Policy proposal long-poll | 60 s default, 1–300 s | Clamp the requested hold time; clients can issue another poll. | +| `policy.local` denial read | 100 records, 4 KiB per surfaced line | Bound response and log parsing work. | +| Log push reconnect buffer | 200 records | Drop new records above the local batch ceiling while disconnected. | +| Log push reconnect backoff | 30 s maximum | Cap the delay between reconnect attempts. | +| Policy status outbox | No fixed capacity | Preserve FIFO revision status without blocking policy reconciliation. | +| Policy status retry backoff | 32 s maximum | Retain a retryable update and retry independently of enforcement. | + +The bounded log batch favors supervisor health over retaining an unbounded +diagnostic backlog. The policy status outbox makes the opposite tradeoff: +revision ordering and delivery survive an extended gateway outage at the cost +of potential queue growth. + +## Known Gaps and Review Triggers + +The current limits grew with individual features and are not yet a complete +resource model. Known gaps include: + +- GraphQL, MCP, and JSON-RPC policy body limits have defaults but no common + platform maximum. +- A positive token-cache TTL override replaces the response-derived one-hour + ceiling rather than narrowing it. +- Socket read deadlines are not expressed consistently as idle plus total + budgets across every parser. +- There is no documented aggregate connection budget or per-destination + fairness policy in the supervisor. +- The policy status outbox is intentionally unbounded and can grow if policy + revisions continue while its gateway endpoint remains unavailable. +- Limit telemetry is not yet uniform enough to derive saturation trends across + all paths. + +Revisit this document when adding a parser, body transformation, persistent +stream, shared queue, cache, or external call. A change should state: + +1. What untrusted resource can grow or wait. +2. Which component owns the bound. +3. Whether the scope is per message, connection, destination, or sandbox. +4. Whether configuration can narrow the limit. +5. How saturation or timeout terminates. +6. Which telemetry and deterministic tests prove the behavior. diff --git a/architecture/sandbox.md b/architecture/sandbox.md index a39f699a57..055ef7e4a3 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -29,10 +29,11 @@ only when the set is already empty; any other outcome fails the spawn. gateway, depending on mode. 3. It prepares filesystem access, process restrictions, network namespace routing, trust stores, provider credential resolution, and inference routes. -4. It starts the policy proxy and local SSH server. -5. It opens a supervisor session back to the gateway for connect, exec, file +4. It launches the persisted canonical main-process argv and retains its PTY + or pipes in the main-session multiplexer. +5. It starts the policy proxy and local SSH server. +6. It opens a supervisor session back to the gateway for connect, exec, file sync, config polling, and log push. -6. It launches the agent command as the resolved restricted identity. ## Isolation Layers @@ -51,6 +52,9 @@ paths, such as proxy support files or GPU device paths when a GPU is present. ## Network and Inference +See [Sandbox Limits](sandbox-limits.md) for the current numeric safety ceilings, +their ownership, terminal behavior, and known gaps. + All ordinary agent egress is routed through the sandbox proxy. The proxy identifies the calling binary, checks trust-on-first-use binary identity, rejects unsafe internal destinations, and evaluates the active policy. On Linux, it @@ -60,10 +64,10 @@ socket inode. CONNECT and absolute-form forward HTTP are explicit-proxy adapters over the same egress pipeline. Each adapter normalizes its request into an egress intent, and -the shared authorization result carries the process evidence used by destination -validation and relay selection. During the compatibility migration, endpoint -state is hydrated at the adapters' existing policy query points; it is not yet -one atomic, generation-consistent authorization result. Destination validation +the shared authorization result carries the process evidence and endpoint +metadata used by destination validation and relay selection. Network action, +matched policy, endpoint configuration, and exact-host authorization are +evaluated as one atomic snapshot from one policy generation. Destination validation returns an unopened connector so adapters retain their existing response and upstream-dial timing. CONNECT prepares a generation-pinned relay context before entering shared TLS-terminated or plaintext HTTP relays; non-HTTP traffic uses @@ -71,16 +75,85 @@ the shared raw byte relay after the existing adapter gates. Forward HTTP retains its guarded single-request relay while sharing authorization, request context, policy-pinning, and destination boundaries. Adapter-specific response and OCSF event shapes remain at the protocol boundary. +An explicit `protocol: tcp` endpoint with a valid DNS hostname opts into native +DNS and transparent TCP when the selected runtime advertises that substrate. +Hostless `allowed_ips` and literal-IP selectors remain available only to the +legacy explicit-proxy path when `protocol` is omitted. The shared supervisor +answers only eligible DNS names, returns an epoch-scoped synthetic address, and +publishes the expiring name, endpoint, ports, policy generation, and validated +real addresses as one correlation. A connection to that synthetic address is +captured before the bypass fence, mapped back to its workload process, authorized +through the same egress pipeline, and dialed only through the pinned addresses. +Omitted protocol endpoints retain explicit-proxy behavior. + +The DNS store is in-memory and sandbox-local. A combined-supervisor restart also +restarts its workload; before execution, the supervisor advances a persisted +boot epoch and installs only that epoch's synthetic capture ranges. An address +cached from the preceding epoch therefore falls through to the bypass fence +instead of inheriting a new mapping. Policy reload, expiry, wrong ports, direct real-IP access, missing +mappings, or pool exhaustion fail closed. Resolver injection, DNS listeners, +capture rules, and the transparent listener are all ready before workload +execution. A runtime that cannot provide the complete contract rejects a policy +containing explicit TCP endpoints rather than partially activating it. Because +that substrate is startup infrastructure, a sandbox created without explicit +TCP endpoints rejects a hot reload that introduces one and keeps its complete +previous policy active; recreating the sandbox installs the substrate before +the workload starts. A sandbox that started with the substrate may continue to +remove and re-add TCP endpoints through ordinary atomic policy reloads. +Workload DNS targets port 53, while nftables redirects eligible IPv4 DNS traffic +to an unprivileged supervisor listener. The filter admits DNS and transparent +TCP only when the kernel records the traffic as DNATed to the corresponding +supervisor listener, so direct dials to either unprivileged listener port remain +fenced. `SO_ORIGINAL_DST`, synthetic mapping lookup, endpoint correlation, and +generation-pinned authorization form the transparent TCP security boundary. +Docker and Podman do not currently advertise usable IPv6 egress for this +substrate, so AAAA queries return NOERROR/NODATA and IPv6 DNS remains fenced. + +Provider credential placeholders are resolved through the live provider state +for each HTTP request, after destination and L7 policy admission. A static +credential resolves only when the request host, port, and path match an endpoint +in that provider's effective profile. CONNECT, absolute-form forward HTTP, +request targets, headers, supported request bodies, SigV4 signing, and opted-in +WebSocket text rewriting use the same scoped resolver. Provider refresh swaps +credential values and endpoint bindings atomically. An invalid or unavailable +refresh revokes the previous static credential state instead of leaving a +partially active or last-known-good static set. Invalid metadata preserves the +supplied dynamic snapshot, while a fetch failure preserves the currently active +dynamic snapshot. + +In the Kubernetes sidecar topology, the provider environment revision remains +an opaque content fingerprint and has no numeric ordering semantics. The +network supervisor assigns a separate, connection-local monotonic generation +to each distinct environment it publishes. The process supervisor applies only +newer generations, which accepts descending fingerprint values while rejecting +duplicate or delayed sidecar messages. + +Gateway-managed refresh credentials use an opaque workload handle derived from +the sandbox, provider identity, credential key, refresh authorization epoch, +and canonical endpoint boundary. The handle remains stable while the gateway +rotates the short-lived value, so an already-running process keeps one +placeholder and each request resolves against the current token. Explicit +refresh reconfiguration, provider replacement or detachment, and endpoint +boundary changes produce a new handle and revoke the old one. Supervisors do +not retain old values for these handles. Public provider updates cannot replace +or delete the refresh-owned primary credential or co-minted outputs; internal +CAS rotation and explicit refresh lifecycle operations own those values. +Unmanaged static credentials retain the bounded revision-generation behavior. + +Route selection and policy evaluation use a syntax-only redacted request target; +they do not materialize real credentials. Cross-endpoint placeholder use returns +HTTP 403. After a WebSocket upgrade it closes the connection with policy +violation code 1008. Both paths emit a denied activity event and a detection +finding without logging the placeholder, environment key, secret, or query. For inspected HTTP traffic, the proxy can enforce REST method/path rules, WebSocket upgrade and text-message rules, GraphQL operation rules, and MCP method, tool, and supported params rules or generic JSON-RPC method rules -on sandbox-to-server request bodies. MCP and JSON-RPC inspection buffers up to -the endpoint `mcp.max_body_bytes` or `json_rpc.max_body_bytes` limit. MCP -`tools/call` tool names are checked against the spec-recommended syntax by -default before policy evaluation, with a per-endpoint `mcp.strict_tool_names` -compatibility opt-out. Generic JSON-RPC policies do not support `params` -matchers; generic JSON-RPC rules match only the method. +on sandbox-to-server request bodies. MCP and JSON-RPC inspection buffers +bounded request bodies. MCP `tools/call` tool names are checked against the +spec-recommended syntax by default before policy evaluation, with a per-endpoint +`mcp.strict_tool_names` compatibility opt-out. Generic JSON-RPC policies do not +support `params` matchers; generic JSON-RPC rules match only the method. JSON-RPC responses and server-to-client MCP messages on response or SSE streams are relayed but are not currently parsed for policy enforcement. @@ -90,24 +163,56 @@ host selectors choose the chain independently of the network rule that admitted the request. Policy-local map keys identify configs, while built-in names or operator-owned registration names identify implementations. -Built-ins run in-process; operator services use the same bounded gRPC contract. -`openshell-policy` validates policy-owned structure, and the active middleware -registry validates implementation-owned config. The generic registry and chain -runner live in `openshell-supervisor-middleware`; first-party implementations -live in `openshell-supervisor-middleware-builtins`. +Built-ins run in-process against a borrowed view of the chain's current HTTP +request state. Operator services retain the bounded protobuf/gRPC contract, and +the remote adapter materializes an owned HTTP evaluation only when a request +crosses that transport boundary. Both paths support bounded bidirectional +WebSocket sessions, so a manifest advertises capabilities independently of +transport. +When a stage ends, the remote adapter sends its terminal event, half-closes the +request stream, and briefly drains the response stream before releasing the +transport. This keeps a queued terminal event from being canceled with the +bidirectional RPC. +The runtime keeps three states distinct: host selection attaches policy configs, +manifest operation and phase bindings select the active chain, and the parsed +message type determines whether that chain can inspect an individual payload. +An attachment without a WebSocket binding is not a failed WebSocket stage. +Binary messages are outside the V1 text-message binding. Both cases pass through +with informational coverage telemetry rather than applying `on_error`. +The chain runner owns shared sequencing, deadlines, backpressure, and response +validation. `openshell-policy` validates policy-owned structure, and the active +middleware registry validates implementation-owned config. The generic +registry and chain runner live in `openshell-supervisor-middleware`; first-party +implementations live in `openshell-supervisor-middleware-builtins`. The supervisor installs policy and middleware registry changes as one runtime generation and preserves the last-known-good generation if preparation fails. Policy-only updates reuse the connected registry, so an external middleware outage cannot block unrelated policy changes. -Middleware cannot observe injected credentials or mutate supervisor-owned -credential, routing, or framing headers. Body transformations are re-evaluated +For authenticated operator middleware, the supervisor requests credentials by +registration name through `RefreshSandboxToken`. The gateway resolves names +against the effective policy and mints exact-audience credentials. The +supervisor keeps them in refreshable in-memory slots outside stable middleware +configuration, so rotation neither changes `config_revision` nor reconnects +the registry. Public custom-CA PEM travels with the stable registration. + +The slots live in a supervisor-owned `ExtensionCredentialStore` shared by every +gateway connection the supervisor opens, so the registry's clients and the +polling loop that rotates them observe the same credentials. Configuration +polling runs far more frequently than credentials expire, so the loop rotates +only when a credential is missing or has passed four fifths of its lifetime, +and bounds its sleep by the soonest rotation deadline. + +Middleware cannot observe injected credentials, introduce credential +placeholders, or mutate supervisor-owned credential, routing, or framing +headers. Body transformations are re-evaluated against body-aware L7 policy before later stages or the upstream can observe them. Requests, results, chain length, execution time, and diagnostics are bounded; external free-form diagnostic text is not exposed in responses or -security logs. See [Supervisor Middleware](../docs/extensibility/supervisor-middleware.mdx) -for configuration and protocol details. +security logs. See +[Supervisor Middleware](../docs/extensibility/supervisor-middleware.mdx) for +configuration and protocol details. `https://inference.local` is special. It bypasses OPA network policy and is handled by the inference interception path: @@ -138,9 +243,12 @@ loopback always dial directly; add driver-injected host aliases (e.g. proxy cannot reach the container host. `NO_PROXY` matching is port-aware and resolution-aware: an entry with a `:port` qualifier only bypasses that port, and IP/CIDR entries also match hostnames through their validated resolved -addresses, with the direct dial limited to the addresses the entry contains. Only `http://` proxy URLs in explicit -`http://host:port` form are supported — the scheme and port are both -required, and a path, query, or fragment is rejected. Local DNS resolution +addresses, with the direct dial limited to the addresses the entry contains. `http://` and `https://` proxy URLs in explicit +`scheme://host:port` form are supported — the scheme and port are both +required, and a path, query, or fragment is rejected. For an `https://` proxy +the supervisor wraps the connection to the proxy in TLS before the CONNECT +handshake, verifying the proxy certificate against the built-in and system +roots plus the optional operator CA bundle (see below). Local DNS resolution and SSRF validation still run before the proxied dial, and the CONNECT target sent to the corporate proxy is a validated resolved address, so the proxy performs no DNS resolution of its own and the tunnel stays bound to @@ -156,17 +264,38 @@ own DNS view, e.g. DoH tunneled via CONNECT, is a possible future enhancement and out of scope.) The workload child's proxy variables are unaffected — they are always rewritten to point at the local policy proxy. +Template environment is treated like user-provided sandbox environment. It can +shape the workload child, but it cannot override driver-controlled identity, +gateway callback, TLS, relay socket, proxy, provider, or supervisor coordination +variables. Drivers and the supervisor rewrite those reserved values after image +and template environment are considered. + The configuration is fail-closed: a setting that is present but invalid — an -empty value, an unsupported or malformed proxy URL, an unreadable auth file, -a malformed credential, or an auth file or `NO_PROXY` list set while no proxy -URL is configured — is fatal to supervisor startup instead of being treated -as unset, so a misconfiguration can never silently degrade to direct dialing -or unauthenticated proxy access. Only an omitted argument means "no proxy". -The driver validates the same rules at sandbox-create time through -validators shared with the supervisor +empty value, an unsupported or malformed proxy URL, an unreadable auth file or +CA bundle, a malformed credential, or an auth file, `NO_PROXY` list, or CA +bundle set while no proxy URL is configured — is fatal to supervisor startup +instead of being treated as unset, so a misconfiguration can never silently +degrade to direct dialing or unauthenticated proxy access. Only an omitted +argument means "no proxy". The driver validates the same rules at +sandbox-create time through validators shared with the supervisor (`openshell_core::driver_utils::parse_upstream_proxy_url` and `parse_upstream_proxy_credential`). +An optional operator CA bundle (`--upstream-proxy-ca-bundle`, a PEM path the +driver bind-mounts read-only into the sandbox) extends the trust boundary for +corporate proxies. A CA certificate is not secret, so unlike the auth file it +travels as a plain read-only bind mount rather than a driver secret. It is +trusted in two places: the TLS handshake with an `https://` proxy, and — +because a TLS-intercepting proxy (mitmproxy, squid `ssl-bump`) re-signs +tunneled server certificates with the same CA — the sandbox combined trust +bundle (`write_ca_files`) and the L7 upstream re-encryption store +(`build_upstream_client_config`). Folding it into both means intercepted +upstream handshakes succeed and sandbox workload processes trust the re-signed +certificates; trusting it only for the proxy-listener handshake would leave +every intercepted upstream connection failing. The bundle is valid with either +an `http://` or `https://` proxy (an intercepting proxy can be reached over +plain HTTP) and is fail-closed: an unreadable or certificate-free file is fatal. + Proxy credentials are never embedded in the URL: an inline `user:pass@` is rejected because it would be stored in `gateway.toml` and exposed in container metadata. Operators supply credentials via `proxy_auth_file`; the driver @@ -176,6 +305,43 @@ file and builds the `Proxy-Authorization: Basic` header; a credential that is empty, contains control characters, or is not in `user:pass` form is fatal on both sides. +The VM driver has no argv seam of its own: its guest init script runs as PID 1 +and execs a fixed supervisor command line, and the libkrun and QEMU launch +backends both reach the supervisor through that script. Driver-owned +supervisor arguments therefore travel in a per-sandbox file the driver writes +into the overlay upperdir at a fixed guest path, one argument per line, which +the guest reads verbatim (no word splitting or globbing) and appends to every +supervisor exec. The file is written on **every** launch, including an empty +file when there is nothing to pass: the upperdir copy always shadows the +read-only image layer, so a sandbox image can neither supply its own +supervisor arguments by baking a file at that path nor disable the operator's +by omitting one. This mirrors the driver-authored `init.d` manifest, which +solves the same trust problem for guest init drop-ins. + +A microVM has no bind mounts or container secrets, so the VM driver stages the +credential and the CA bundle into the per-sandbox overlay disk instead — the +credential root-only, the CA world-readable, both at fixed `/opt/openshell` +paths and both removed with the sandbox state directory. The consequence, +which differs from the Podman secret model, is that the credential is at rest +inside that overlay image on the gateway host; the per-sandbox gateway JWT +already travels the same path. Proxy reachability differs by VM backend. libkrun-backed +sandboxes egress through gvproxy, so a proxy on the gateway host's loopback is +reachable through the host alias `host.openshell.internal`, which gvproxy NATs +to the host's `127.0.0.1`. QEMU/TAP sandboxes (GPU) have no equivalent: that +alias resolves to the TAP host address, and the driver's nftables `input` +chain accepts only the gateway port from the guest, so no gateway-host proxy +is reachable. The driver rejects a gateway-host proxy URL on the QEMU path at +launch rather than producing CONNECT timeouts. The guest's gateway callback is +unaffected in both backends and never traverses the proxy. + +For Kubernetes sandboxes, the operator configures a Secret name and key rather +than a gateway-host file path. Kubernetes projects that Secret only into the +container that runs network supervision. Proxy credential Secrets require the +sidecar topology, which gives them a separate container boundary from the +workload. Combined topology is rejected because Kubernetes `fsGroup` volume +permission handling can make a shared credential mount readable by the sandbox +group. + The Basic header travels over the plain-TCP connection to the `http://` proxy, so it is readable on the network path between sandbox host and proxy. Configuring `proxy_auth_file` therefore requires the explicit opt-in @@ -203,27 +369,49 @@ when policy allows the target endpoint. For GCP providers, a loopback metadata server inside the network namespace serves placeholders to SDKs that bypass the proxy (e.g. Go's `cloud.google.com/go/compute/metadata`). Secrets must not be logged in OCSF or plain tracing output. The supervisor uses revision-scoped -placeholders for rotating provider credentials; provider environment keys -beginning with `v_` are reserved for that placeholder namespace. +placeholders for unmanaged rotating credentials and identity-stable opaque +handles for gateway-managed refresh credentials. Provider environment keys +beginning with `v_` or `s<64 lowercase hex characters>_` are reserved +for those placeholder namespaces. Provider profiles can also declare dynamic token grants. For matching HTTP -endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API, -exchanges it for an OAuth2 access token, caches the token, and injects it as an -`Authorization: Bearer` header before forwarding the request. Token grant -endpoints are HTTPS-only except for loopback and Kubernetes service DNS hosts, -and returned access tokens must be bearer-compatible before they are cached or -injected. Token response lifetimes are capped and cached with an expiry margin -unless a profile supplies an explicit cache TTL override. +endpoints, the supervisor obtains or exchanges OAuth2 access tokens, caches +them, and injects them before forwarding the request. `client_credentials` +grants use the supervisor SPIFFE JWT-SVID directly as the client assertion. +`token_exchange` grants ask the gateway to broker an intermediate token using a +stored provider subject credential and the gateway's own SPIFFE JWT-SVID; the +supervisor then exchanges that intermediate token for the final upstream token +using its own JWT-SVID. The gateway validates that its own JWT-SVID has the +requested audience, a SPIFFE subject, and a non-expired `exp` claim when +present. It also validates that the stored subject credential is declared by the +provider profile, and that the supervisor JWT-SVID is a well-formed +three-segment JWT with a SPIFFE subject in the same trust domain as the gateway +SVID. The gateway verifies the supervisor JWT-SVID signature with JWT bundles +fetched from its SPIFFE Workload API. Token grant endpoints are HTTPS-only +except for loopback and Kubernetes service DNS hosts, and returned access tokens +must be bearer-compatible before they are cached or injected. Token response +lifetimes are capped and cached with an expiry margin unless a profile supplies +an explicit cache TTL override. Cache entries are scoped by the sandbox provider +environment revision so provider credential updates miss the old token cache +without changing endpoint matching semantics. Gateway-brokered intermediate +tokens are cached separately by provider resource version, supervisor SPIFFE +subject, and gateway SPIFFE subject, and their cache lifetime is capped by the +intermediate token response, stored subject-token expiry, and supervisor SVID +expiry. For AWS endpoints that require request-level signing, the proxy supports SigV4 re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy strips the client's placeholder-based AWS auth headers, re-signs with real credentials from the provider, and forwards the request upstream. The signing -mode is auto-detected from the client SDK's `x-amz-content-sha256` header: - -- **Signed body** (hex hash): buffers the request body (up to 10 MiB), computes - its SHA-256, and includes the hash in the signature. Used by Bedrock and most - AWS services. +endpoint must have a credential source before the policy generation activates: +an attached endpoint-bearing AWS profile whose boundary covers the endpoint, or +an attached endpointless AWS profile explicitly named by the endpoint's +`credential_binding.provider`. Policy activation rejects missing or mismatched +sources atomically. The signing mode is auto-detected from the client SDK's +`x-amz-content-sha256` header: + +- **Signed body** (hex hash): buffers the request body, computes its SHA-256, + and includes the hash in the signature. Used by Bedrock and most AWS services. - **Streaming unsigned** (`STREAMING-UNSIGNED-PAYLOAD-TRAILER`): signs headers only and streams the body through without buffering. Used by S3 uploads with `aws-chunked` encoding. @@ -254,8 +442,17 @@ The supervisor runs an SSH server on a Unix socket inside the sandbox. The gateway reaches it through the outbound supervisor relay, not by dialing the sandbox workload directly. The relay supports: -- Interactive shell sessions. -- Command execution. +- Attachment to the canonical main process through the `openshell-main` SSH + subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay + buffer, and a single stdin lease across client disconnects. +- Independent interactive shell sessions. +- Command execution. Commands run through a login shell (`bash -lc`) by default, + so the first of the user's `.bash_profile`, `.bash_login`, or `.profile` is + sourced (and `.bashrc` only if that file sources it). Callers set + `ExecSandboxRequest.no_login_shell` to skip those files; the gateway signals + this to the supervisor over the SSH `OPENSHELL_NO_LOGIN_SHELL` env request, + which selects `bash -c` instead of `bash -lc`. Note `bash -c` still reads + `BASH_ENV` when the child environment sets it. - Tar-based file sync. - Port forwarding where supported by the CLI/TUI surface. @@ -296,6 +493,15 @@ remains `Pending`. If the first poll returns a different revision, the superviso processes it through the normal reload path instead of treating it as already loaded. +A newer sandbox-scoped revision can carry the same non-empty effective policy +hash as the currently loaded revision, for example when provenance changes +without changing enforcement content. The supervisor acknowledges that newer +revision without reloading identical policy. If the revision also requires +middleware or policy-runtime reconciliation, acknowledgement waits until that +reconciliation succeeds. Global policies, local overrides, equal or older +versions, and different hashes do not use this shortcut. Success telemetry is +emitted only after the gateway accepts the resulting loaded-status report. + Policy status delivery uses a FIFO background worker. Retryable delivery failures retain the ordered update and retry with capped exponential backoff; terminal errors are logged and discarded. The outbox is nonblocking and does @@ -322,3 +528,15 @@ engine with a gateway policy revision. re-evaluate. - If the supervisor relay drops, the sandbox can keep running, but connect and exec operations fail until the supervisor registers again. +- If the canonical main process exits, the supervisor durably reports the + normalized result immediately. A foreground create declares a one-shot main + attachment, so the supervisor accepts it even after a fast process exits, + sends the retained output and SSH exit status, waits for the peer's channel + close, and then finalizes ephemeral cleanup. With no declared or active + attachment, it finalizes and exits without a grace period. The gateway waits + for that finalized supervisor session to disconnect before deleting an + ephemeral sandbox. Exit code 0 records + `Completed/MainProcessCompleted`; nonzero and signal-normalized exits record + `Error/MainProcessFailed`. Infrastructure failures also use `Error`, with a + distinct condition reason and no fabricated canonical-process result. Runtime + restart policies must not replace the canonical process. diff --git a/architecture/security-policy.md b/architecture/security-policy.md index c68e9a9a1b..9203ba3178 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -81,6 +81,14 @@ with the sandbox's ephemeral CA and inspect method/path or protocol-specific metadata before forwarding. The proxy also supports credential injection on terminated HTTP streams when policy allows the endpoint. +Static provider credentials have an independent endpoint-binding boundary. +Provider profile endpoints define that boundary by default. An endpointless +profile can delegate binding authority to sandbox policy through an endpoint +that names the concrete attached provider instance. The gateway rejects +unattached, profileless, endpointful, and gateway-global uses of that policy +binding. Policy endpoint changes rotate the provider-environment revision so +the supervisor installs policy and credential binding snapshots atomically. + Raw streams and long-lived response bodies are connection scoped. Policy generation changes close relays pinned to the previous generation instead of allowing them to continue under stale authorization. HTTP upgrades switch to @@ -89,6 +97,70 @@ raw relay by default. A `protocol: rest` endpoint can opt in to after an allowed `101` upgrade; server-to-client traffic and all other upgraded protocols remain raw passthrough. +## Credentialed Endpoints + +OpenShell keeps provider credentials on paths it can inspect or rewrite by +default. The gateway derives credential provenance from the attached providers +and stamps it onto the effective policy at composition time. This provenance is +internal, contains no credential identifiers or values, and is never trusted +from user-authored policy. + +Every evaluation clears provenance across the whole policy and re-derives it +from two sources: + +- the endpoints of attached provider profiles that carry credentials, and +- the valid `credential_binding` entries of the sandbox policy that name an + attached provider whose profile is endpointless. + +A binding reduces to a host and port scope only. Dropping the path is +deliberate: a path is not observable on an L4 or `tls: skip` endpoint, so a +path-scoped derivation would omit the marker on exactly the surfaces the +uninspected-credential gate exists to catch. A malformed binding — empty +provider, missing host, or a port outside `1..=65535` — fails the evaluation +instead of contributing a scope. Bindings naming an endpointful profile or an +unattached provider contribute nothing; the gateway rejects those uses +separately. + +Both sources merge into one deduplicated scope set, and each endpoint is +stamped once per evaluation from that set, so binding-derived scopes reach the +same gates as profile-derived ones. The stamp is an assignment, not an +accumulation, so an endpoint that stops matching a credentialed scope — or +whose binding was removed — loses its marker in the same pass. This must remain +a full recomputation: a delta-based derivation would let a series of +individually valid edits reach a state no single edit would have admitted. + +Credentialed L4-only and `tls: skip` endpoints fail policy validation unless the +public `allow_uninspected_credentials` escape hatch is explicitly enabled. The +flag defaults to `false` and is security-flagged in policy approval flows. +Incremental merges only ever add the flag to a matching endpoint; clearing it +requires removing the endpoint or replacing the policy. + +The network supervisor independently enforces the same boundary. Credentialed +WebSocket upgrades use the parsed relay, binary frames fail closed, and text +placeholders require rewrite. REST bodies can continue streaming when body +rewrite is disabled, but the relay withholds enough trailing bytes to detect a +placeholder split across reads before forwarding its marker. Explicitly opted-in +endpoints retain raw passthrough behavior. + +Denials emit both the relevant network activity and a detection finding. Events +identify only the destination, policy, and traffic surface; they never include +credential names, placeholders, body content, or secret values. + +Credential provenance is gateway-derived and deliberately absent from the policy +YAML schema, so it does not survive a policy that never transits the gateway. +Gateway-delivered policy is the authoritative source for this control, and a +policy without provenance applies neither the raw-tunnel refusal nor the +WebSocket binary-frame refusal. The request-body backstop still applies, because +it keys off the presence of a secret resolver rather than endpoint provenance. + +Two paths load a policy without provenance. A supervisor booting from a +container-image policy is a bounded window: that policy is resynchronized to the +gateway, which then serves a stamped effective policy. An explicit local Rego and +data override is permanent, because gateway revisions are observed for settings +and providers but never replace the local policy. When that override is combined +with injected provider credentials, the supervisor emits a high-severity +detection finding at startup naming the inactive controls. + ## Live Updates The gateway stores sandbox-authored policy revisions separately from derived @@ -144,10 +216,16 @@ through the proposal loop instead of treating the denial as terminal. 1. **Submit.** Both proposers POST through the same `SubmitPolicyAnalysis` path. Each chunk is persisted with its `analysis_mode` for audit provenance. -2. **Validate.** The gateway runs the prover (`openshell-prover`) on every - chunk regardless of mode. The prover builds a Z3 model from the merged - policy plus the sandbox's attached-provider credential set, then computes - the delta of findings between the current baseline and the merged policy. +2. **Build and validate the candidate.** The gateway first canonicalizes a + mechanistic proposal against the live effective policy. If an endpoint is + already governed by an inspected or provider-owned contract, the candidate + preserves that contract and adds only the proposed sandbox binary. Provider + rules are immutable inputs; the sandbox contribution is stored as an + overlay. The gateway then performs the same merge, policy validation, + provider composition, credential preflight, and prover evaluation that the + candidate would encounter when applied. Each chunk stores the resulting + effective candidate, its hashes, any application error, and a review token + derived from the candidate and its non-secret live inputs. 3. **Auto-approval gate (proposer-agnostic, opt-in).** Auto-approval fires only when *all three* conditions hold: (a) `proposal_approval_mode` resolves to `"auto"` — gateway scope wins, sandbox scope is the @@ -155,13 +233,12 @@ through the proposal loop instead of treating the denial as terminal. (`prover: no new findings`); and (c) the security notes recomputed from the chunk's current proposed rule are empty (see [Security-notes gate](#security-notes-gate)). Before merging, the gateway - reloads the stored chunk and reruns both checks on its current rule. This is - important after edits and mechanistic deduplication: the stored rule, not a - duplicate incoming payload or stale persisted analysis, controls the - decision. The recalculated prover verdict is decision-local rather than - persisted, so `validation_result` reads can still show the submit-time - verdict after an edit or deduplication. Decode, prover, or merge failures - leave the chunk pending. The audit event uses `CONFIG:APPROVED` and carries + reloads the stored chunk and recomputes its candidate from live policy, + provider, and credential inputs. If the review token is unchanged, the + gateway reuses the persisted prover result. If it changed, the gateway + persists the refreshed candidate and requires a fresh review instead of + applying it. Decode, prover, merge, provider-composition, or credential + failures leave the chunk pending with an application error. The audit event uses `CONFIG:APPROVED` and carries `auto=true`, `source=`, `prover_delta=empty`, and `resolved_from=` as unmapped fields, with message text `"auto-approved: no new prover findings"` — never `safe`. The opt-in gate @@ -187,6 +264,26 @@ through the proposal loop instead of treating the denial as terminal. policy. 6. **Escalation.** Anything else lands in `pending` for human review. +After any successful policy write, pending chunks already covered by the new +live effective policy are rejected as redundant. This keeps the review inbox +aligned with what the sandbox currently enforces. + +Endpoint and binary advisor markers are provenance, not authorization or +connection metadata. Provider- or user-authored declarations carry explicit +provenance; `policy.local` declarations carry advisor provenance. A difference +in endpoint provenance alone is compatible during effective-policy ambiguity +validation. When identical endpoint or binary identities merge, an explicit +declaration dominates an advisor declaration. Proposal coverage likewise +ignores provenance so an approved overlay converges when an existing explicit +declaration already supplies the same identity. + +This compatibility does not weaken SSRF classification. Exact-host trust +requires one matching rule to contain both an exact explicit endpoint and an +explicit binary identity. An advisor-only endpoint or binary cannot assemble +that trust from unrelated rules. A provider rule may independently establish +trust for its own explicit endpoint and binary pair, but an advisor overlay +does not broaden that pair to a different binary. + ### Security-notes gate Separately from the prover, each chunk carries advisory `security_notes`. diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md new file mode 100644 index 0000000000..6b0d675a9c --- /dev/null +++ b/architecture/windows-msvc-build.md @@ -0,0 +1,169 @@ +# Windows MSVC Build Design + +This page records the design decisions for the native Windows MSVC build lane. +It provides the native build lane and validates the in-process MXC compute +driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. + +## Goals + +- Compile the OpenShell gateway and CLI for `x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`. +- Keep the Linux and macOS build paths unchanged. +- Preserve gateway configuration parsing for all existing compute driver names. +- Build and test the in-process MXC driver on supported Windows hosts. +- Use the ordinary in-process compute-driver composition path; MXC receives the + canonical sandbox policy through `DriverSandboxSpec` and advertises that it + reports runtime readiness. +- Return clear unsupported errors when a Windows gateway is configured to use Docker, Kubernetes, Podman, or VM. +- Keep dedicated `windows:*` validation tasks while allowing the repository-wide + `pre-commit` task to delegate compiler-bearing Rust checks to the native + Windows MSVC environment. + +## Non-Goals + +- Do not support Docker Desktop, WSL, Hyper-V, Podman machine, Podman Desktop, Kubernetes, or VM-backed sandbox execution on Windows. +- Do not ship Windows standalone binaries for Docker, Kubernetes, Podman, or VM drivers. +- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, or DPAPI integration in this lane. + +## Unsupported Driver Strategy + +The gateway composition crate installs platform-specific registration stubs on +Windows. These registrations preserve config-file selection and reject +unsupported drivers with a clear error without depending on their runtime +crates. + +The Windows lane does not build, release, package, or smoke-test standalone +driver binaries for Docker, Kubernetes, Podman, or VM. Those binaries are Linux +or macOS deliverables only. + +The Kubernetes Secrets and Vault packages are also excluded as top-level +Windows workspace targets because their standalone driver binaries use Unix +domain sockets. Their libraries remain in the gateway dependency graph, so the +gateway's credential-driver configuration and in-process behavior still compile +on Windows. + +| Driver | Windows build behavior | Runtime behavior | +|---|---|---| +| Docker | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | +| Kubernetes | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | +| Podman | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | +| VM | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | +| MXC | Driver links into the native gateway and runs in Windows validation. | `process_container` is default-deny; grant-only `isolation_session` requires explicit configuration. | + +This keeps Windows behavior explicit without carrying runtime dependencies or +creating misleading Windows driver artifacts. + +## Mise Lane + +The GitHub Actions workflow is manually dispatched. Each architecture restores +and saves a dedicated Rust cache containing the Cargo registry and dependency +build artifacts, including artifacts from failed runs. Keep the workflow manual +until cache-hit runtimes demonstrate that it is suitable for pull requests and +merges to `main`. + +Windows validation is exposed through `tasks/windows.toml`: + +| Task | Purpose | +|---|---| +| `windows:check:x64` | Check the x64 MSVC gateway/CLI build graph. | +| `windows:check:arm64` | Check the ARM64 MSVC gateway/CLI build graph. | +| `windows:build:x64` | Build release x64 `openshell-gateway.exe` and `openshell.exe`. | +| `windows:build:arm64` | Build release ARM64 `openshell-gateway.exe` and `openshell.exe`. | +| `windows:test:x64` | Run native x64 workspace tests, including MXC mapper and lifecycle tests, while excluding unsupported Windows packages as top-level test targets. | +| `windows:test:arm64` | Run native ARM64 workspace tests with the same package exclusions. | +| `windows:test:unsupported:x64` | Run focused gateway-composition tests for unsupported driver contracts. | +| `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. | +| `windows:ci` | Run check, build, test, unsupported-contract tests, and artifact reporting. | + +The Windows tasks call `tasks/scripts/windows-msvc.ps1`. The wrapper discovers +Visual Studio's `VsDevCmd.bat` with `vswhere` or by enumerating installed +release directories, validates the requested compiler and ARM64 Spectre +libraries, adds rustup MSVC targets, clears inherited `RUSTC_WRAPPER`, and +keeps build artifacts under the normal Cargo target tree. +On Windows, the generic `rust:check`, `rust:lint`, and `test:rust` tasks call +the same wrapper with the host-native MSVC target. The wrapper preserves the +Unix Cargo commands on Linux and macOS, excludes unsupported Windows runtime +packages, and runs the server test-support suite separately. Windows Clippy +continues to deny all warnings except unused imports, dead code, and unused +async functions caused by cfg-gated Windows stubs. Repository-wide pre-commit +skips only Linux-specific installer, build-environment shell-helper, and +packaging-asset tests; its +cross-platform Python, Markdown, license, and documentation checks still run. +Test tasks require the Rust target architecture to match the Windows host, so +an ARM64 test result is native coverage rather than x64 emulation coverage. +By default it enables bundled Z3 for reproducible Windows builds. When +`Z3_LIBRARY_PATH_OVERRIDE` points at a directory containing `libz3.lib`, the +wrapper uses that system Z3 instead and requires `Z3_SYS_Z3_HEADER` to point at +the full path to `z3.h`. For bundled builds, the wrapper fetches the Z3 source +revision pinned by `z3-sys` through Git and sets +`Z3_SYS_BUNDLED_DIR_OVERRIDE`. When `CARGO_TARGET_DIR` is explicit, the wrapper +uses it for the source cache. Otherwise, it caches under the current user's +local application data directory, outside the checkout. Publishing uses an +atomic directory rename so concurrent x64 and ARM64 commands can share the +cache safely. This keeps downloaded sources outside the checkout by default and +avoids the unauthenticated GitHub API lookup in the `z3-sys` build script, which +can fail with HTTP 403 when a shared runner or developer network exhausts its +API rate limit. An explicitly set `Z3_SYS_BUNDLED_DIR_OVERRIDE` remains +supported and must contain `src/api/z3.h`. + +The lane uses `mise run --skip-tools windows:*` because Windows Rust comes from +rustup and linking comes from Visual Studio Build Tools. Mise orchestrates the +tasks; it does not own the Windows toolchain. + +ARM64 validation requires the Visual Studio ARM64 MSVC tools, ARM64 +Spectre-mitigated libraries, host-native Clang tools, CMake tools, and an +ARM64-capable Windows SDK. Clang provides `libclang.dll` for `bindgen` and +`clang-cl.exe` for ARM64 crypto dependencies. During x64-to-ARM64 check/build, +the wrapper discovers and adds the Visual Studio-bundled Ninja to `PATH` for +native dependencies. It lets `cmake-rs` select the Visual Studio ARM64 +generator with native MSVC `cl.exe` for bundled Z3 so the Z3 build does not +inherit the crypto crates' compiler requirement. Z3 stays on the Visual Studio +generator because `z3-sys` emits an MSBuild-only `-m` argument that Ninja +rejects. Artifact hashing uses .NET SHA256 directly because module autoloading +in the mise-launched Windows PowerShell process is not guaranteed. + +The wrapper defaults Cargo compilation to four jobs. Set +`OPENSHELL_WINDOWS_BUILD_JOBS` to a positive integer to override that limit. +A host-local mutex serializes wrapper-owned Cargo commands so concurrent +pre-commit tasks do not multiply the process count while bundled Z3 compiles. +The wrapper does not set `CL` or `_CL_`: those variables are also consumed by +`clang-cl`, where MSVC's `/MP` option can be interpreted as an input file and +break ARM64 crypto dependency builds. + +## CI Shape + +The x64 GitHub Actions job runs on `windows-2025` and executes: + +```powershell +mise run --skip-tools windows:check:x64 +mise run --skip-tools windows:build:x64 +mise run --skip-tools windows:test:x64 +mise run --skip-tools windows:test:unsupported:x64 +``` + +The cache is partitioned by architecture so incompatible x64 and ARM64 target +artifacts cannot collide. It does not cache Cargo-installed binaries, which +also keeps the disabled self-hosted ARM64 scaffold from modifying persistent +runner tooling. + +The local aggregate `windows:ci` task cross-builds ARM64 on an x64 host. The +GitHub x64 job currently runs only the x64 tasks, and native ARM64 tests remain +exclusive to an ARM64 runner. + +The ARM64 job is scaffolded but disabled until a Windows ARM64 runner is +available. Once enabled, it should run check, release build, native workspace +tests, and the focused unsupported-driver contracts for +`aarch64-pc-windows-msvc`. + +## Validation Contract + +A successful Windows build report should include: + +- x64 and ARM64 `cargo check` status. +- x64 and ARM64 release build status for `openshell-gateway.exe` and `openshell.exe`. +- x64 test summary. +- Native ARM64 test summary when validation runs on an ARM64 host. +- Focused unsupported-driver contract test status. +- Artifact size and SHA256 for each Windows binary. + +Warnings from Linux-only dead code are acceptable in the native Windows lane when +they come from code paths intentionally disabled on Windows. diff --git a/bazel/BUILD.bazel b/bazel/BUILD.bazel deleted file mode 100644 index 8d61bb55cf..0000000000 --- a/bazel/BUILD.bazel +++ /dev/null @@ -1 +0,0 @@ -exports_files(["cargo_version.bzl"]) diff --git a/bazel/annotations/BUILD.bazel b/bazel/annotations/BUILD.bazel deleted file mode 100644 index be268ae34c..0000000000 --- a/bazel/annotations/BUILD.bazel +++ /dev/null @@ -1,5 +0,0 @@ -exports_files([ - "aws-lc-sys.MODULE.bazel", - "z3-sys.MODULE.bazel", - "zstd-sys.MODULE.bazel", -]) diff --git a/bazel/annotations/aws-lc-sys.MODULE.bazel b/bazel/annotations/aws-lc-sys.MODULE.bazel deleted file mode 100644 index b84a67503c..0000000000 --- a/bazel/annotations/aws-lc-sys.MODULE.bazel +++ /dev/null @@ -1,22 +0,0 @@ -bazel_dep(name = "aws-lc", version = "5.1.0") - -rules_rust_bindgen = use_extension("@rules_rs//rs:rules_rust_bindgen.bzl", "rules_rust_bindgen") -use_repo(rules_rust_bindgen, "rules_rust_bindgen") - -register_toolchains("@rules_rust_bindgen//:all") - -crate = use_extension("@rules_rs//rs:extensions.bzl", "crate") -crate.annotation( - crate = "aws-lc-rs", - gen_build_script = "off", -) -crate.annotation( - additive_build_file = "@rules_rs//3rd_party/aws-lc-sys:additive.BUILD.bazel", - crate = "aws-lc-sys", - extra_aliased_targets = {"aws_lc_sys_build_info": "aws_lc_sys_build_info"}, - gen_build_script = "off", - rustc_flags = ["--cfg=use_bindgen_pregenerated"], - deps = ["@crates//:aws_lc_sys_build_info"], -) - -inject_repo(crate, "aws-lc") diff --git a/bazel/annotations/z3-sys.MODULE.bazel b/bazel/annotations/z3-sys.MODULE.bazel deleted file mode 100644 index cf916bf623..0000000000 --- a/bazel/annotations/z3-sys.MODULE.bazel +++ /dev/null @@ -1,10 +0,0 @@ -crate = use_extension("@rules_rs//rs:extensions.bzl", "crate") -crate.annotation( - additive_build_file = "//bazel/annotations/z3-sys:additive.BUILD.bazel", - crate = "z3-sys", - extra_aliased_targets = {"z3_sys_build_info": "z3_sys_build_info"}, - gen_build_script = "off", - deps = ["@crates//:z3_sys_build_info"], -) - -inject_repo(crate, "z3") diff --git a/bazel/annotations/z3-sys/BUILD.bazel b/bazel/annotations/z3-sys/BUILD.bazel deleted file mode 100644 index de39e660c7..0000000000 --- a/bazel/annotations/z3-sys/BUILD.bazel +++ /dev/null @@ -1 +0,0 @@ -exports_files(["additive.BUILD.bazel"]) diff --git a/bazel/annotations/z3-sys/additive.BUILD.bazel b/bazel/annotations/z3-sys/additive.BUILD.bazel deleted file mode 100644 index e6d16377a8..0000000000 --- a/bazel/annotations/z3-sys/additive.BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("@@//bazel/annotations/z3-sys:defs.bzl", "z3_sys") - -z3_sys( - name = "z3_sys_build_info", - z3 = "@z3//:z3", -) diff --git a/bazel/annotations/z3-sys/defs.bzl b/bazel/annotations/z3-sys/defs.bzl deleted file mode 100644 index 89dc4b8702..0000000000 --- a/bazel/annotations/z3-sys/defs.bzl +++ /dev/null @@ -1,81 +0,0 @@ -"""z3-sys build-script replacement.""" - -load("@bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory") -load("@rules_cc//cc:defs.bzl", "CcInfo", "cc_library") -load("@rules_rs//rs:rules_rust_bindgen.bzl", "rust_bindgen") -load("@rules_rust//rust:rust_common.bzl", "BuildInfo") - -_ENUMS = [ - "ast_kind", - "ast_print_mode", - "decl_kind", - "error_code", - "goal_prec", - "param_kind", - "parameter_kind", - "sort_kind", - "symbol_kind", -] - -def _z3_sys_build_info_impl(ctx): - out_dir = ctx.file.out_dir - if not out_dir.is_directory: - fail("out_dir must be a directory") - - return [ - BuildInfo( - compile_data = depset(), - dep_env = None, - flags = None, - linker_flags = None, - link_search_paths = None, - out_dir = out_dir, - rustc_env = None, - ), - ctx.attr.cc_lib[CcInfo], - ] - -_z3_sys_build_info = rule( - implementation = _z3_sys_build_info_impl, - attrs = { - "cc_lib": attr.label(mandatory = True, providers = [CcInfo]), - "out_dir": attr.label(allow_single_file = True, mandatory = True), - }, -) - -def z3_sys(name, z3): - """Injects generated enum bindings and native Z3 into z3-sys.""" - wrapper = name + "_wrapper" - out_dir = name + "_out_dir" - - cc_library( - name = wrapper, - hdrs = ["wrapper.h"], - deps = [z3], - ) - - bindings = [] - for enum in _ENUMS: - rust_bindgen( - name = enum, - bindgen_flags = [ - "--allowlist-type=Z3_" + enum, - "--no-doc-comments", - "--rustified-enum=Z3_" + enum, - ], - cc_lib = wrapper, - header = "wrapper.h", - ) - bindings.append(enum) - - copy_to_directory( - name = out_dir, - srcs = bindings, - ) - - _z3_sys_build_info( - name = name, - cc_lib = wrapper, - out_dir = out_dir, - visibility = ["//visibility:public"], - ) diff --git a/bazel/annotations/zstd-sys.MODULE.bazel b/bazel/annotations/zstd-sys.MODULE.bazel deleted file mode 100644 index 27da5dcc54..0000000000 --- a/bazel/annotations/zstd-sys.MODULE.bazel +++ /dev/null @@ -1,8 +0,0 @@ -bazel_dep(name = "zstd", version = "1.5.7.bcr.1") - -crate = use_extension("@rules_rs//rs:extensions.bzl", "crate") -crate.annotation( - crate = "zstd-sys", - gen_build_script = "off", - deps = ["@zstd"], -) diff --git a/bazel/cargo_version.bzl b/bazel/cargo_version.bzl deleted file mode 100644 index 0dc9f844f0..0000000000 --- a/bazel/cargo_version.bzl +++ /dev/null @@ -1,33 +0,0 @@ -def _workspace_version_repository_impl(repository_ctx): - in_workspace_package = False - - for raw_line in repository_ctx.read(repository_ctx.attr.manifest).splitlines(): - line = raw_line.strip() - - if line.startswith("["): - in_workspace_package = line == "[workspace.package]" - continue - - if not in_workspace_package or not line.startswith("version"): - continue - - value = line.split("=", 1)[1].strip() - if not value.startswith('"') or value.find('"', 1) == -1: - fail("workspace package version must be a quoted string") - - version = value[1:value.find('"', 1)] - repository_ctx.file("BUILD.bazel", 'exports_files(["version.bzl"])\n') - repository_ctx.file("version.bzl", 'WORKSPACE_VERSION = "{}"\n'.format(version)) - return - - fail("workspace package version not found in {}".format(repository_ctx.attr.manifest)) - -workspace_version_repository = repository_rule( - implementation = _workspace_version_repository_impl, - attrs = { - "manifest": attr.label( - allow_single_file = True, - mandatory = True, - ), - }, -) diff --git a/bazel/releases/BUILD.bazel b/bazel/releases/BUILD.bazel deleted file mode 100644 index e424cef404..0000000000 --- a/bazel/releases/BUILD.bazel +++ /dev/null @@ -1,95 +0,0 @@ -load("@bazel_lib//lib:transitions.bzl", "platform_transition_binary") - -platform( - name = "linux_x86_64_gnu_2_28", - constraint_values = [ - "@llvm//constraints/cxxstdlib:libcxx", - "@llvm//constraints/libc:gnu.2.28", - "@platforms//cpu:x86_64", - "@platforms//os:linux", - "@rules_rs//rs/platforms/constraints:glibc", - ], -) - -platform( - name = "linux_aarch64_gnu_2_28", - constraint_values = [ - "@llvm//constraints/cxxstdlib:libcxx", - "@llvm//constraints/libc:gnu.2.28", - "@platforms//cpu:aarch64", - "@platforms//os:linux", - "@rules_rs//rs/platforms/constraints:glibc", - ], -) - -platform_transition_binary( - name = "openshell_linux_x86_64", - basename = "openshell", - binary = "//crates/openshell-cli:openshell", - tags = ["manual"], - target_platform = "@rules_rs//rs/platforms:x86_64-unknown-linux-musl", -) - -platform_transition_binary( - name = "openshell_linux_aarch64", - basename = "openshell", - binary = "//crates/openshell-cli:openshell", - tags = ["manual"], - target_platform = "@rules_rs//rs/platforms:aarch64-unknown-linux-musl", -) - -platform_transition_binary( - name = "openshell_sandbox_linux_x86_64", - basename = "openshell-sandbox", - binary = "//crates/openshell-sandbox:openshell-sandbox-bin", - tags = ["manual"], - target_platform = "@rules_rs//rs/platforms:x86_64-unknown-linux-musl", -) - -platform_transition_binary( - name = "openshell_sandbox_linux_aarch64", - basename = "openshell-sandbox", - binary = "//crates/openshell-sandbox:openshell-sandbox-bin", - tags = ["manual"], - target_platform = "@rules_rs//rs/platforms:aarch64-unknown-linux-musl", -) - -platform_transition_binary( - name = "openshell_gateway_linux_x86_64", - basename = "openshell-gateway", - binary = "//crates/openshell-server:openshell-gateway", - tags = ["manual"], - target_platform = ":linux_x86_64_gnu_2_28", -) - -platform_transition_binary( - name = "openshell_gateway_linux_aarch64", - basename = "openshell-gateway", - binary = "//crates/openshell-server:openshell-gateway", - tags = ["manual"], - target_platform = ":linux_aarch64_gnu_2_28", -) - -platform_transition_binary( - name = "openshell_macos_aarch64", - basename = "openshell", - binary = "//crates/openshell-cli:openshell", - tags = ["manual"], - target_platform = "@rules_rs//rs/platforms:aarch64-apple-darwin", -) - -platform_transition_binary( - name = "openshell_sandbox_macos_aarch64", - basename = "openshell-sandbox", - binary = "//crates/openshell-sandbox:openshell-sandbox-bin", - tags = ["manual"], - target_platform = "@rules_rs//rs/platforms:aarch64-apple-darwin", -) - -platform_transition_binary( - name = "openshell_gateway_macos_aarch64", - basename = "openshell-gateway", - binary = "//crates/openshell-server:openshell-gateway", - tags = ["manual"], - target_platform = "@rules_rs//rs/platforms:aarch64-apple-darwin", -) diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000000..a9ada8c9eb --- /dev/null +++ b/buf.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Repo-level buf module. Declares proto/ as the single module so buf generate, +# buf lint, buf breaking, and the editor LSP all resolve imports the same way. +# Code generation lives with each consumer (see sdk/go/buf.gen.yaml, +# sdk/typescript/buf.gen.yaml); this file owns the module boundary and proto +# validation policy. +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD + except: + # Flat proto/ layout: all files live in one directory with nested + # packages (openshell.v1, openshell.sandbox.v1, ...). Adopting these + # would require restructuring the tree into openshell//v1/ and + # updating every Rust/Python/TS codegen path and import. + - DIRECTORY_SAME_PACKAGE + - PACKAGE_DIRECTORY_MATCH + # Established API shape: services are unsuffixed (OpenShell, not + # OpenShellService) and RPCs reuse shared request/response messages with + # short names. Renaming these is a breaking change across the codebase. + - RPC_REQUEST_RESPONSE_UNIQUE + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - SERVICE_SUFFIX +breaking: + use: + - FILE diff --git a/crates/BUILD.bazel b/crates/BUILD.bazel deleted file mode 100644 index d2cefbf9be..0000000000 --- a/crates/BUILD.bazel +++ /dev/null @@ -1,7 +0,0 @@ -test_suite( - name = "rustfmt_test", - tests = [ - "//crates/{package}:rustfmt_test".format(package = package) - for package in subpackages(include = ["openshell-*"]) - ], -) diff --git a/crates/openshell-bootstrap/BUILD.bazel b/crates/openshell-bootstrap/BUILD.bazel deleted file mode 100644 index c43812e2da..0000000000 --- a/crates/openshell-bootstrap/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-bootstrap", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-bootstrap_test", - crate = ":openshell-bootstrap", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-bootstrap", - ":openshell-bootstrap_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-bootstrap/Cargo.toml b/crates/openshell-bootstrap/Cargo.toml index c860cb1384..96a7985085 100644 --- a/crates/openshell-bootstrap/Cargo.toml +++ b/crates/openshell-bootstrap/Cargo.toml @@ -11,7 +11,6 @@ rust-version.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } -bollard = "0.20" bytes = { workspace = true } futures = { workspace = true } miette = { workspace = true } @@ -24,6 +23,9 @@ tempfile = "3" tokio = { workspace = true } tracing = { workspace = true } +[target.'cfg(not(target_os = "windows"))'.dependencies] +bollard = "0.20" + [dev-dependencies] [lints] diff --git a/crates/openshell-bootstrap/src/build_windows.rs b/crates/openshell-bootstrap/src/build_windows.rs new file mode 100644 index 0000000000..93af1345d3 --- /dev/null +++ b/crates/openshell-bootstrap/src/build_windows.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows stub for local Dockerfile image builds. + +use std::collections::HashMap; +use std::path::Path; + +use miette::Result; + +// Keep this stub's signature aligned with the supported-platform implementation. +#[allow(clippy::implicit_hasher)] +pub async fn build_local_image( + _dockerfile_path: &Path, + _tag: &str, + _context_dir: &Path, + _build_args: &HashMap, + _on_log: &mut impl FnMut(String), +) -> Result<()> { + Err(miette::miette!( + "local Dockerfile sandbox sources are unsupported on Windows" + )) +} diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index aa0532260e..5598d524ee 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#[cfg(not(target_os = "windows"))] +pub mod build; +#[cfg(target_os = "windows")] +#[path = "build_windows.rs"] pub mod build; pub mod edge_token; pub mod jwt; diff --git a/crates/openshell-bootstrap/src/oidc_token.rs b/crates/openshell-bootstrap/src/oidc_token.rs index 3fa0730956..f21a115c80 100644 --- a/crates/openshell-bootstrap/src/oidc_token.rs +++ b/crates/openshell-bootstrap/src/oidc_token.rs @@ -39,6 +39,11 @@ pub fn oidc_token_path(gateway_name: &str) -> Result { Ok(user_gateway_dir(gateway_name)?.join("oidc_token.json")) } +/// Path to the one-shot marker that asks the next browser login to prompt. +pub fn oidc_login_prompt_required_path(gateway_name: &str) -> Result { + Ok(user_gateway_dir(gateway_name)?.join("oidc_login_prompt_required")) +} + /// Store an OIDC token bundle for a gateway. pub fn store_oidc_token(gateway_name: &str, bundle: &OidcTokenBundle) -> Result<()> { let path = oidc_token_path(gateway_name)?; @@ -50,6 +55,7 @@ pub fn store_oidc_token(gateway_name: &str, bundle: &OidcTokenBundle) -> Result< .into_diagnostic() .wrap_err_with(|| format!("failed to write OIDC token to {}", path.display()))?; set_file_owner_only(&path)?; + clear_oidc_login_prompt(gateway_name)?; Ok(()) } @@ -76,6 +82,33 @@ pub fn remove_oidc_token(gateway_name: &str) -> Result<()> { Ok(()) } +/// Mark the next interactive OIDC login as requiring a fresh `IdP` prompt. +pub fn request_oidc_login_prompt(gateway_name: &str) -> Result<()> { + let path = oidc_login_prompt_required_path(gateway_name)?; + ensure_parent_dir_restricted(&path)?; + std::fs::write(&path, b"1\n") + .into_diagnostic() + .wrap_err_with(|| format!("failed to write {}", path.display()))?; + set_file_owner_only(&path)?; + Ok(()) +} + +/// Return whether the next interactive OIDC login should request a fresh prompt. +pub fn oidc_login_prompt_required(gateway_name: &str) -> bool { + oidc_login_prompt_required_path(gateway_name).is_ok_and(|path| path.exists()) +} + +/// Clear the one-shot fresh-login marker for a gateway. +pub fn clear_oidc_login_prompt(gateway_name: &str) -> Result<()> { + let path = oidc_login_prompt_required_path(gateway_name)?; + if path.exists() { + std::fs::remove_file(&path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to remove {}", path.display()))?; + } + Ok(()) +} + /// Check if the stored access token is expired or near expiry. /// /// Returns `true` if the token expires within the next 30 seconds. @@ -129,4 +162,36 @@ mod tests { assert!(remove_oidc_token("../escape").is_err()); }); } + + #[test] + fn oidc_login_prompt_marker_is_per_gateway() { + let tmp = tempfile::tempdir().unwrap(); + with_tmp_xdg(tmp.path(), || { + assert!(!oidc_login_prompt_required("alpha")); + assert!(!oidc_login_prompt_required("beta")); + + request_oidc_login_prompt("alpha").unwrap(); + + assert!(oidc_login_prompt_required("alpha")); + assert!(!oidc_login_prompt_required("beta")); + + let bundle = OidcTokenBundle { + access_token: "token".to_string(), + refresh_token: None, + expires_at: None, + issuer: "https://issuer.example.com".to_string(), + client_id: "openshell-cli".to_string(), + }; + store_oidc_token("alpha", &bundle).unwrap(); + + assert!(!oidc_login_prompt_required("alpha")); + + request_oidc_login_prompt("alpha").unwrap(); + assert!(oidc_login_prompt_required("alpha")); + + clear_oidc_login_prompt("alpha").unwrap(); + + assert!(!oidc_login_prompt_required("alpha")); + }); + } } diff --git a/crates/openshell-cli/BUILD.bazel b/crates/openshell-cli/BUILD.bazel deleted file mode 100644 index edd62654ee..0000000000 --- a/crates/openshell-cli/BUILD.bazel +++ /dev/null @@ -1,162 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_binary.bzl", "rust_binary") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") -load("@workspace_version//:version.bzl", "WORKSPACE_VERSION") - -rust_library( - name = "openshell-cli", - srcs = glob( - ["src/**/*.rs"], - exclude = ["src/main.rs"], - ), - aliases = aliases(), - version = WORKSPACE_VERSION, - deps = all_crate_deps(normal = True), -) - -rust_binary( - name = "openshell", - srcs = ["src/main.rs"], - aliases = aliases(), - version = WORKSPACE_VERSION, - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True) + [":openshell-cli"], -) - -rust_binary( - name = "fake-forward-process", - testonly = True, - srcs = ["tests/fixtures/fake_forward.rs"], - crate_root = "tests/fixtures/fake_forward.rs", -) - -rust_test( - name = "openshell-cli_lib_test", - crate = ":openshell-cli", - deps = all_crate_deps(normal_dev = True), -) - -rust_test( - name = "openshell-cli_bin_test", - srcs = ["src/main.rs"], - aliases = aliases(), - version = WORKSPACE_VERSION, - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-cli"], -) - -rust_test( - name = "ensure_providers_integration_test", - srcs = [ - "tests/ensure_providers_integration.rs", - "tests/helpers/mod.rs", - ], - aliases = aliases(), - crate_root = "tests/ensure_providers_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-cli"], -) - -rust_test( - name = "mtls_integration_test", - srcs = [ - "tests/helpers/mod.rs", - "tests/mtls_integration.rs", - ], - aliases = aliases(), - crate_root = "tests/mtls_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-cli"], -) - -rust_test( - name = "provider_commands_integration_test", - srcs = [ - "tests/helpers/mod.rs", - "tests/provider_commands_integration.rs", - ], - aliases = aliases(), - crate_root = "tests/provider_commands_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-cli"], -) - -rust_test( - name = "sandbox_create_lifecycle_integration_test", - srcs = [ - "tests/fixtures/fake_forward.rs", - "tests/helpers/mod.rs", - "tests/sandbox_create_lifecycle_integration.rs", - ], - aliases = aliases(), - compile_data = [":openshell"], - crate_root = "tests/sandbox_create_lifecycle_integration.rs", - data = [ - ":fake-forward-process", - ":openshell", - ], - env = { - "OPENSHELL_TEST_FAKE_FORWARD_PATH": "$(rootpath :fake-forward-process)", - }, - rustc_env = { - "CARGO_BIN_EXE_openshell": "$(rootpath :openshell)", - }, - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-cli"], -) - -rust_test( - name = "sandbox_name_fallback_integration_test", - srcs = [ - "tests/helpers/mod.rs", - "tests/sandbox_name_fallback_integration.rs", - ], - aliases = aliases(), - crate_root = "tests/sandbox_name_fallback_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-cli"], -) - -rust_test( - name = "sandbox_upload_integration_test", - srcs = ["tests/sandbox_upload_integration.rs"], - aliases = aliases(), - crate_root = "tests/sandbox_upload_integration.rs", - data = [":openshell"], - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-cli"], -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":ensure_providers_integration_test", - ":fake-forward-process", - ":mtls_integration_test", - ":openshell", - ":openshell-cli", - ":openshell-cli_bin_test", - ":openshell-cli_lib_test", - ":provider_commands_integration_test", - ":sandbox_create_lifecycle_integration_test", - ":sandbox_name_fallback_integration_test", - ":sandbox_upload_integration_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index d7b8fd502c..ddaf09f889 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -37,6 +37,9 @@ chrono = "0.4" clap = { workspace = true } clap_complete = { workspace = true } crossterm = { workspace = true } +# Styling backend shared by dialoguer and indicatif; `color` overrides its +# global switch so both follow the CLI's --color setting. +console = "0.15" dialoguer = "0.11" indicatif = { workspace = true } owo-colors = { workspace = true } @@ -46,7 +49,7 @@ bytes = { workspace = true } http-body-util = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } -hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring", "webpki-tokio"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring"] } rustls = { workspace = true } rustls-pemfile = { workspace = true } tokio-rustls = { workspace = true } @@ -63,7 +66,7 @@ tar = "0.4" tempfile = "3" # OIDC/Auth -oauth2 = "5" +oauth2 = { version = "5", default-features = false, features = ["reqwest"] } base64 = { workspace = true } # WebSocket (Cloudflare tunnel proxy) @@ -72,7 +75,6 @@ tokio-tungstenite = { workspace = true } # Streams futures = { workspace = true } tokio-stream = { workspace = true } -nix = { workspace = true } # URL parsing url = { workspace = true } @@ -84,6 +86,9 @@ tracing-subscriber = { workspace = true } [lints] workspace = true +[target.'cfg(unix)'.dependencies] +nix = { workspace = true } + [dev-dependencies] futures = { workspace = true } rcgen = { version = "0.13", features = ["crypto", "pem"] } diff --git a/crates/openshell-cli/src/color.rs b/crates/openshell-cli/src/color.rs new file mode 100644 index 0000000000..9b78b4ca90 --- /dev/null +++ b/crates/openshell-cli/src/color.rs @@ -0,0 +1,613 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime color control for CLI output. +//! +//! The CLI styles its human-readable tables and status lines with ANSI escape +//! sequences. Those sequences must not appear when the output is being consumed +//! by another program, or callers end up writing brittle patterns against bytes +//! they cannot see. +//! +//! `owo_colors::OwoColorize` emits escapes unconditionally, so this module wraps +//! it with a process-wide switch resolved once at startup by [`init`]. Command +//! modules import [`Colorize`] instead of `OwoColorize`; the method names match, +//! so call sites are unchanged, but each one now checks the switch when it +//! renders. +//! +//! Styling is not confined to `owo-colors`, and the other paths each carry +//! their own default, so [`init`] brings them under the same setting: +//! +//! - `tracing_subscriber` formats with ANSI on, does no terminal detection, and +//! writes to stdout. Left alone, `openshell -v ... | ...` leaks escapes into a +//! pipe exactly like the styled tables did. `main` passes [`stdout_enabled`] +//! to `with_ansi`. +//! - `indicatif` and `dialoguer` both style through `console`, which has its own +//! detection and honors `NO_COLOR` but cannot know about `--color`. [`init`] +//! overrides it globally, which covers every progress bar and prompt rather +//! than the specific ones the CLI happens to construct today. +//! - `miette` renders errors to stderr with its own detection, likewise unaware +//! of `--color`. [`init`] installs a report handler built from +//! [`stderr_enabled`]. +//! +//! Resolution order, highest precedence first: +//! +//! 1. `--color always|never` on the command line. +//! 2. `NO_COLOR`, set and non-empty, disables color (). +//! 3. `FORCE_COLOR`, set and non-empty, forces color on (). +//! 4. Otherwise the stream is styled only when that stream is a terminal *and* +//! that terminal renders ANSI. +//! +//! Attachment and capability are separate questions. `TERM=dumb` is a terminal +//! that does not interpret escapes, so `auto` must not style it — and neither +//! `console` nor `miette` can apply their own `TERM` checks any more, because +//! [`init`] overrides both. Capability is consulted only under `auto`, so +//! `--color always` and `FORCE_COLOR` still force styling on a `dumb` terminal +//! for anyone who wants it. +//! +//! Step 4 is resolved per stream. Redirecting one must not decide for the other: +//! `openshell ... 2> build.log` from a terminal should keep a styled stdout and +//! write a plain-text log, and `openshell ... | grep` should keep styled +//! diagnostics on the terminal while feeding the pipe clean bytes. [`init`] +//! therefore stores one answer per stream and hands each library the one for the +//! stream it writes to. +//! +//! The `owo-colors` wrapper is the exception, because its call sites are split +//! across `println!` and `eprintln!` and a [`Painted`] value cannot tell which +//! macro will consume it. It styles only when both streams accept escapes, which +//! errs toward plain text rather than risk writing escapes to a redirected +//! stream. + +use std::ffi::OsStr; +use std::fmt::{self, Display}; +use std::io::IsTerminal; +use std::sync::atomic::{AtomicBool, Ordering}; + +use owo_colors::{OwoColorize, Style}; + +/// Whether stdout may carry escapes. Consulted for `tracing` and console's +/// stdout switch. +/// +/// Defaults to disabled so that any output produced before [`init`] runs — and +/// output from unit tests, which never call `init` — stays free of escapes. +static STDOUT_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Whether stderr may carry escapes. Consulted for `miette` and console's stderr +/// switch. +static STDERR_ENABLED: AtomicBool = AtomicBool::new(false); + +/// When to colorize CLI output. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum ColorChoice { + /// Colorize a stream only when that stream is a terminal and no environment + /// override applies. + #[default] + Auto, + /// Always colorize, even when output is redirected. + Always, + /// Never colorize. + Never, +} + +/// Resolve the color setting and store it for the rest of the process. +/// +/// Call once, as early as possible after argument parsing and before any output +/// is written. +pub fn init(choice: ColorChoice) { + let no_color = std::env::var_os("NO_COLOR"); + let force_color = std::env::var_os("FORCE_COLOR"); + let term = std::env::var_os("TERM"); + + // Under `auto` each stream answers for itself. Redirecting one must not + // decide for the other: `openshell ... 2> build.log` from a terminal has a + // styled stdout and a plain-text stderr, and vice versa for `| grep`. + let stdout_enabled = resolve( + choice, + no_color.as_deref(), + force_color.as_deref(), + terminal_supports_ansi(std::io::stdout().is_terminal(), term.as_deref()), + ); + let stderr_enabled = resolve( + choice, + no_color.as_deref(), + force_color.as_deref(), + terminal_supports_ansi(std::io::stderr().is_terminal(), term.as_deref()), + ); + STDOUT_ENABLED.store(stdout_enabled, Ordering::Relaxed); + STDERR_ENABLED.store(stderr_enabled, Ordering::Relaxed); + + // `indicatif` and `dialoguer` both style through `console`, which keeps its + // own detection. Override it so progress bars and prompts follow the same + // setting as everything else — including `--color`, which console has no way + // to learn about on its own. + console::set_colors_enabled(stdout_enabled); + console::set_colors_enabled_stderr(stderr_enabled); + + // miette renders errors to stderr. The hook can only be installed once per + // process; a failure means something already installed one, and error + // rendering is not worth aborting the command over. + let _ = miette::set_hook(Box::new(move |_| { + Box::new( + miette::MietteHandlerOpts::new() + .color(stderr_enabled) + .build(), + ) + })); +} + +/// Whether stdout may carry ANSI escapes. +/// +/// [`init`] configures `console` and `miette` directly; `tracing` is wired up by +/// the caller, which writes to stdout and passes this to `with_ansi`. +#[must_use] +pub fn stdout_enabled() -> bool { + STDOUT_ENABLED.load(Ordering::Relaxed) +} + +/// Whether stderr may carry ANSI escapes. +#[must_use] +pub fn stderr_enabled() -> bool { + STDERR_ENABLED.load(Ordering::Relaxed) +} + +/// Whether a [`Painted`] value should render styled. +/// +/// The `owo-colors` call sites are split across `println!` and `eprintln!` and +/// the wrapper cannot tell which one will consume it, so it styles only when +/// *both* streams accept escapes. Erring toward plain text keeps a redirected +/// stream clean, which is the whole point of the switch; the cost is that +/// `openshell ... 2>/dev/null` from a terminal prints an uncolored table. +/// `--color always` overrides it. +#[must_use] +fn painted_enabled() -> bool { + stdout_enabled() && stderr_enabled() +} + +/// Decide whether one stream may carry escapes. Split out from [`init`] so the +/// precedence rules are testable without mutating process state. +fn resolve( + choice: ColorChoice, + no_color: Option<&OsStr>, + force_color: Option<&OsStr>, + stream_supports_ansi: bool, +) -> bool { + match choice { + ColorChoice::Always => return true, + ColorChoice::Never => return false, + ColorChoice::Auto => {} + } + + // Both conventions key on presence rather than value: set and non-empty + // means yes, regardless of what the value is. and + // . + if is_set(no_color) { + return false; + } + if is_set(force_color) { + return true; + } + + // Only `auto` consults the terminal. An explicit request above has already + // returned, so `--color always` and `FORCE_COLOR` still win on a terminal + // that reports no ANSI support. + stream_supports_ansi +} + +/// Whether this output stream's terminal renders ANSI escapes. +/// +/// Being attached to a terminal is not the same as that terminal rendering +/// ANSI. `TERM` is the unix signal for it; Windows consoles enable virtual +/// terminal processing instead and do not set `TERM`, so the check does not +/// apply there. +fn terminal_supports_ansi(stream_is_terminal: bool, term: Option<&OsStr>) -> bool { + stream_is_terminal + && if cfg!(unix) { + term_supports_ansi(term) + } else { + true + } +} + +/// Whether the terminal named by `TERM` renders ANSI escapes on Unix. +/// +/// Follows the rule `console` applies on unix, which this module overrides: +/// `dumb` means no, and an unset `TERM` means no because nothing identifies a +/// capable terminal. +/// +/// Empty is treated as unset, which is a deliberate divergence: `console` reads +/// `TERM=""` as `Ok("")`, and since that is not `"dumb"` it counts as capable. +/// An empty value names no terminal type, and every other variable here already +/// treats empty as unset, so it is handled the same way. +fn term_supports_ansi(term: Option<&OsStr>) -> bool { + is_set(term) && term != Some(OsStr::new("dumb")) +} + +/// Whether an environment variable counts as set: present and not empty. +fn is_set(value: Option<&OsStr>) -> bool { + value.is_some_and(|value| !value.is_empty()) +} + +/// A value tagged with a style, rendered only when color is enabled. +/// +/// Borrows its value and forwards the caller's format specification to the +/// inner `Display`, so width and alignment apply to the text rather than to the +/// text plus escapes. +pub struct Painted<'a, T: ?Sized> { + value: &'a T, + style: Style, +} + +impl Display for Painted<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if !painted_enabled() { + return Display::fmt(&self.value, f); + } + // Hand the whole style to owo-colors in one go. `Styled` writes the + // prefix, forwards `f` to the inner `Display` so padding still measures + // the text, then writes the reset. + Display::fmt(&OwoColorize::style(&self.value, self.style), f) + } +} + +/// Style-combining methods, so `x.green().bold()` renders as one escape +/// sequence rather than nesting two. +/// +/// These are inherent so they take precedence over the [`Colorize`] blanket +/// impl, which would otherwise wrap a `Painted` in another `Painted`. +impl Painted<'_, T> { + /// Add bold to the current style. + #[must_use] + pub fn bold(self) -> Self { + Self { + style: self.style.bold(), + ..self + } + } + /// Add cyan to the current style. + #[must_use] + pub fn cyan(self) -> Self { + Self { + style: self.style.cyan(), + ..self + } + } + /// Add dimmed to the current style. + #[must_use] + pub fn dimmed(self) -> Self { + Self { + style: self.style.dimmed(), + ..self + } + } + /// Add green to the current style. + #[must_use] + pub fn green(self) -> Self { + Self { + style: self.style.green(), + ..self + } + } + /// Add red to the current style. + #[must_use] + pub fn red(self) -> Self { + Self { + style: self.style.red(), + ..self + } + } + /// Add yellow to the current style. + #[must_use] + pub fn yellow(self) -> Self { + Self { + style: self.style.yellow(), + ..self + } + } +} + +/// Styling methods mirroring the `owo_colors::OwoColorize` surface the CLI uses. +/// +/// Import this instead of `OwoColorize` so styled output honors [`init`]. The +/// two traits have colliding method names on purpose: importing both in one +/// module is an ambiguity error, which keeps unconditional coloring from +/// creeping back in. +pub trait Colorize { + /// Render in bold. + fn bold(&self) -> Painted<'_, Self>; + /// Render in cyan. + fn cyan(&self) -> Painted<'_, Self>; + /// Render dimmed. + fn dimmed(&self) -> Painted<'_, Self>; + /// Render in green. + fn green(&self) -> Painted<'_, Self>; + /// Render in red. + fn red(&self) -> Painted<'_, Self>; + /// Render in yellow. + fn yellow(&self) -> Painted<'_, Self>; +} + +impl Colorize for T { + fn bold(&self) -> Painted<'_, Self> { + Painted { + value: self, + style: Style::new().bold(), + } + } + fn cyan(&self) -> Painted<'_, Self> { + Painted { + value: self, + style: Style::new().cyan(), + } + } + fn dimmed(&self) -> Painted<'_, Self> { + Painted { + value: self, + style: Style::new().dimmed(), + } + } + fn green(&self) -> Painted<'_, Self> { + Painted { + value: self, + style: Style::new().green(), + } + } + fn red(&self) -> Painted<'_, Self> { + Painted { + value: self, + style: Style::new().red(), + } + } + fn yellow(&self) -> Painted<'_, Self> { + Painted { + value: self, + style: Style::new().yellow(), + } + } +} + +#[cfg(test)] +mod tests { + // Deliberately not `use super::*`: that would also pull in `OwoColorize` + // and make every `.green()` below ambiguous. + use super::{ + ColorChoice, Colorize, Ordering, STDERR_ENABLED, STDOUT_ENABLED, Style, painted_enabled, + resolve, term_supports_ansi, + }; + use std::ffi::OsStr; + + /// Serializes tests that flip the process-wide switches. + static SWITCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Set both stream switches, since [`Painted`] requires both. + fn with_color(on: bool, body: impl FnOnce() -> R) -> R { + with_streams(on, on, body) + } + + fn with_streams(stdout: bool, stderr: bool, body: impl FnOnce() -> R) -> R { + let _guard = SWITCH_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let prev_out = STDOUT_ENABLED.swap(stdout, Ordering::Relaxed); + let prev_err = STDERR_ENABLED.swap(stderr, Ordering::Relaxed); + let result = body(); + STDOUT_ENABLED.store(prev_out, Ordering::Relaxed); + STDERR_ENABLED.store(prev_err, Ordering::Relaxed); + result + } + + /// Helper so the `resolve` cases read as plain strings. + fn env(value: &str) -> &OsStr { + OsStr::new(value) + } + + #[test] + fn disabled_output_has_no_escapes() { + with_color(false, || { + assert_eq!("running".green().to_string(), "running"); + assert_eq!("dead".red().to_string(), "dead"); + assert_eq!("STATUS".bold().to_string(), "STATUS"); + }); + } + + #[test] + fn enabled_output_is_wrapped_in_the_expected_escapes() { + with_color(true, || { + // The styling the bug report observed from `forward list`. + assert_eq!("running".green().to_string(), "\u{1b}[32mrunning\u{1b}[0m"); + // Delegation is to owo-colors, so the bytes match what it would + // produce for the same style. + assert_eq!( + "running".green().to_string(), + owo_colors::OwoColorize::style(&"running", Style::new().green()).to_string() + ); + assert_eq!( + "x".dimmed().to_string(), + owo_colors::OwoColorize::style(&"x", Style::new().dimmed()).to_string() + ); + }); + } + + #[test] + fn format_width_applies_to_text_not_escapes() { + // Padding must measure the value, so columns line up identically whether + // or not color is on. + let plain = with_color(false, || format!("[{:<10}]", "running".green())); + let colored = with_color(true, || format!("[{:<10}]", "running".green())); + + assert_eq!(plain, "[running ]"); + assert_eq!(colored, "[\u{1b}[32mrunning \u{1b}[0m]"); + } + + #[test] + fn styles_merge_into_one_escape_sequence() { + with_color(true, || { + // Chaining combines into a single style rather than nesting two + // wrappers, so there is one prefix and one reset. + assert_eq!("hi".green().bold().to_string(), "\u{1b}[32;1mhi\u{1b}[0m"); + assert_eq!( + "hi".green().bold().to_string(), + owo_colors::OwoColorize::style(&"hi", Style::new().green().bold()).to_string() + ); + // Order of the calls does not change the resulting style. + assert_eq!( + "hi".green().bold().to_string(), + "hi".bold().green().to_string() + ); + }); + with_color(false, || { + assert_eq!("hi".green().bold().to_string(), "hi"); + }); + } + + #[test] + fn non_string_values_render() { + with_color(false, || { + assert_eq!(8443.green().to_string(), "8443"); + assert_eq!(true.yellow().to_string(), "true"); + }); + } + + /// Render a `dialoguer` confirm prompt, which styles through `console`. + /// + /// Asserting on emitted bytes rather than on `console::colors_enabled()` + /// keeps this honest about what a user would actually see. + fn rendered_prompt() -> String { + use dialoguer::theme::Theme as _; + + let mut out = String::new(); + dialoguer::theme::ColorfulTheme::default() + .format_confirm_prompt(&mut out, "Continue?", Some(false)) + .expect("format confirm prompt"); + out + } + + #[test] + fn console_override_governs_indicatif_and_dialoguer_styling() { + let _guard = SWITCH_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Prompts and progress bars draw to stderr, so their styles consult + // console's stderr switch. `init` sets both; so does this test. + let previous = console::colors_enabled_stderr(); + + console::set_colors_enabled_stderr(true); + let styled = rendered_prompt(); + console::set_colors_enabled_stderr(false); + let plain = rendered_prompt(); + + console::set_colors_enabled_stderr(previous); + + // Positive control first: without it, the plain assertion could pass + // simply because dialoguer stopped emitting anything at all. + assert!( + styled.contains('\u{1b}'), + "console override should permit styling, got: {styled:?}" + ); + assert!( + !plain.contains('\u{1b}'), + "console override should suppress styling, got: {plain:?}" + ); + assert!(plain.contains("Continue?"), "got: {plain:?}"); + } + + #[test] + fn explicit_choice_overrides_environment_and_terminal() { + assert!(resolve(ColorChoice::Always, Some(env("1")), None, false)); + assert!(!resolve(ColorChoice::Never, None, Some(env("1")), true)); + } + + #[test] + fn no_color_disables_when_set_and_non_empty() { + assert!(!resolve(ColorChoice::Auto, Some(env("1")), None, true)); + // Presence is what counts; the value is not interpreted, so a value that + // reads as falsy still disables color. + assert!(!resolve(ColorChoice::Auto, Some(env("0")), None, true)); + // NO_COLOR outranks FORCE_COLOR. + assert!(!resolve( + ColorChoice::Auto, + Some(env("1")), + Some(env("1")), + true + )); + // An empty value is not "set" for the purposes of the convention. + assert!(resolve(ColorChoice::Auto, Some(env("")), None, true)); + } + + #[test] + fn force_color_enables_when_set_and_non_empty() { + assert!(resolve(ColorChoice::Auto, None, Some(env("1")), false)); + // Same presence rule as NO_COLOR: `0` is a value, not an opt-out. + // keys on presence and non-emptiness only. + assert!(resolve(ColorChoice::Auto, None, Some(env("0")), false)); + assert!(!resolve(ColorChoice::Auto, None, Some(env("")), false)); + } + + #[test] + fn term_capability_follows_the_console_rule() { + assert!(term_supports_ansi(Some(OsStr::new("xterm-256color")))); + assert!(term_supports_ansi(Some(OsStr::new("screen")))); + assert!(!term_supports_ansi(Some(OsStr::new("dumb")))); + // Nothing to suggest a capable terminal, so assume none. + assert!(!term_supports_ansi(None)); + // Empty names no terminal type; treated as unset, unlike `console`. + assert!(!term_supports_ansi(Some(OsStr::new("")))); + // Only an exact match counts; `dumb-something` is a different terminal. + assert!(term_supports_ansi(Some(OsStr::new("dumb-but-color")))); + } + + #[test] + fn auto_does_not_style_an_incapable_terminal() { + // A `dumb` terminal is still a terminal, so `is_terminal()` alone would + // wrongly enable color. + assert!(!resolve(ColorChoice::Auto, None, None, false)); + assert!(resolve(ColorChoice::Auto, None, None, true)); + } + + #[test] + fn explicit_requests_outrank_terminal_capability() { + // `--color always` and FORCE_COLOR are for callers who know better than + // the detection, so an incapable terminal must not veto them. + assert!(resolve(ColorChoice::Always, None, None, false)); + assert!(resolve(ColorChoice::Auto, None, Some(env("1")), false)); + // The negative direction still wins over capability too. + assert!(!resolve(ColorChoice::Never, None, None, true)); + assert!(!resolve(ColorChoice::Auto, Some(env("1")), None, true)); + } + + #[test] + fn capability_does_not_rescue_a_redirected_stream() { + // Capability is an additional requirement, not an alternative one. + assert!(!resolve(ColorChoice::Auto, None, None, false)); + } + + #[test] + fn auto_resolves_each_stream_independently() { + // `openshell ... 2> build.log` from a terminal: stdout is styled, the + // log file is not. Resolving both from stdout's answer would put escapes + // in the log. + assert!(resolve(ColorChoice::Auto, None, None, true)); + assert!(!resolve(ColorChoice::Auto, None, None, false)); + } + + #[test] + fn painted_requires_both_streams() { + // A `Painted` value cannot tell whether it is bound for stdout or + // stderr, so it stays plain unless both streams accept escapes. + assert!(with_streams(true, true, painted_enabled)); + assert!(!with_streams(true, false, painted_enabled)); + assert!(!with_streams(false, true, painted_enabled)); + assert!(!with_streams(false, false, painted_enabled)); + + // The rendered consequence: stderr redirected to a file leaves the + // styled text plain rather than writing escapes into it. + assert_eq!( + with_streams(true, false, || "running".green().to_string()), + "running" + ); + } + + #[test] + fn auto_follows_the_terminal() { + assert!(resolve(ColorChoice::Auto, None, None, true)); + assert!(!resolve(ColorChoice::Auto, None, None, false)); + } +} diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index e6edb4d33a..0f12c3df4e 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -3,6 +3,7 @@ //! Shared helpers, types, and parsing utilities used across CLI command groups. +use crate::color::Colorize; use chrono::DateTime; use dialoguer::{Confirm, theme::ColorfulTheme}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; @@ -16,12 +17,14 @@ use openshell_core::proto::{ PlatformEvent, SandboxPhase, SandboxPolicy, SettingValue, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; -use owo_colors::OwoColorize; +use openshell_providers::builtin_profiles; use std::collections::HashMap; use std::io::IsTerminal; use std::process::Command; use std::time::{Duration, Instant}; +const DOCS_PROVIDERS_URL: &str = "https://docs.nvidia.com/openshell/latest/sandboxes/providers-v2"; + // --------------------------------------------------------------------------- // View types // --------------------------------------------------------------------------- @@ -59,6 +62,10 @@ pub fn phase_name(phase: i32) -> &'static str { Ok(SandboxPhase::Ready) => "Ready", Ok(SandboxPhase::Error) => "Error", Ok(SandboxPhase::Deleting) => "Deleting", + Ok(SandboxPhase::Stopping) => "Stopping", + Ok(SandboxPhase::Stopped) => "Stopped", + Ok(SandboxPhase::Starting) => "Starting", + Ok(SandboxPhase::Completed) => "Completed", Ok(SandboxPhase::Unknown) | Err(_) => "Unknown", } } @@ -440,7 +447,7 @@ pub fn noninteractive_active_label(step: ProvisioningStep) -> String { pub fn handle_platform_progress_event( event: &PlatformEvent, - display: &mut Option, + mut display: Option<&mut ProvisioningDisplay>, provision_start: Instant, ) -> bool { let completed_step = event @@ -466,7 +473,7 @@ pub fn handle_platform_progress_event( .metadata .get(PROGRESS_COMPLETE_LABEL_KEY) .map_or_else(|| step.completed_label(), String::as_str); - if let Some(d) = display.as_mut() { + if let Some(d) = display.as_deref_mut() { d.complete_step_with_label(step, label); } else { let ts = format_timestamp(provision_start.elapsed()); @@ -475,13 +482,13 @@ pub fn handle_platform_progress_event( } if let Some(step) = active_step - && let Some(d) = display.as_mut() + && let Some(d) = display.as_deref_mut() { d.set_active_step(step); } if let Some(detail) = active_detail { - if let Some(d) = display.as_mut() { + if let Some(d) = display { d.set_active_detail(detail); } else { let ts = format_timestamp(provision_start.elapsed()); @@ -736,13 +743,105 @@ pub fn parse_duration_to_ms(s: &str) -> Result { )); } }; - Ok(num * multiplier) + num.checked_mul(multiplier).ok_or_else(|| { + miette::miette!("duration out of range: {s} (must fit in milliseconds as a 64-bit integer)") + }) } // --------------------------------------------------------------------------- // Parsing utilities // --------------------------------------------------------------------------- +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileSuggestion { + pub provider_type: String, + pub credential: String, +} + +fn credential_env_matches(env: &HashMap) -> Vec<(String, Vec)> { + const KEYWORDS: [&str; 7_usize] = [ + "TOKEN", + "SECRET", + "PASSWORD", + "CREDENTIAL", + "ACCESS_KEY", + "SECRET_KEY", + "API_KEY", + ]; + let looks_like_credential = |key: &str| -> bool { + let upper = key.to_ascii_uppercase(); + let segs = upper.split('_').collect::>(); + KEYWORDS.iter().any(|kw| { + let words = kw.split('_').collect::>(); + segs.windows(words.len()).any(|w| w == words.as_slice()) + }) + }; + + // scan builtin_profiles() + let profile_suggestions = |key: &str| -> Vec { + let mut suggestions = Vec::new(); + for profile in builtin_profiles() { + for cred in &profile.credentials { + if cred.env_vars.iter().any(|v| v.eq_ignore_ascii_case(key)) { + suggestions.push(ProfileSuggestion { + provider_type: profile.id.clone(), + credential: cred.name.clone(), + }); + } + } + } + suggestions + }; + + let mut matches = Vec::new(); + + for key in env.keys() { + let sug = profile_suggestions(key); + if !sug.is_empty() || looks_like_credential(key) { + matches.push((key.clone(), sug)); + } + } + + matches.sort_by(|a, b| a.0.cmp(&b.0)); + matches +} + +#[allow(clippy::implicit_hasher)] +pub fn warn_credential_env_vars(env: &HashMap, suppress: bool) { + if suppress { + return; + } + + let matches = credential_env_matches(env); + if matches.is_empty() { + return; + } + + for (key, suggestions) in &matches { + eprintln!( + "{} {key} looks like a credential passed as a plain environment variable.", + "⚠".yellow() + ); + eprintln!(" The agent inside the sandbox can read this value directly."); + eprintln!(); + + if suggestions.is_empty() { + eprintln!(" To hide it from the agent, use a provider instead of --env."); + } else { + eprintln!(" To hide it from the agent, use a provider instead:"); + for s in suggestions { + eprintln!( + " openshell provider create --name my-{ty} --type {ty} --credential {key}", + ty = s.provider_type + ); + } + eprintln!(" openshell sandbox create --provider my- ..."); + } + eprintln!(" See: {DOCS_PROVIDERS_URL}"); + eprintln!(); + } +} + pub fn parse_key_value_pairs(items: &[String], flag: &str) -> Result> { let mut map = HashMap::new(); @@ -975,4 +1074,176 @@ mod tests { let err = parse_duration_to_ms("\u{20ac}").expect_err("missing number should error"); assert!(err.to_string().contains("invalid duration")); } + + #[test] + fn parse_duration_to_ms_rejects_out_of_range_values_without_overflowing() { + let err = parse_duration_to_ms("9223372036854775807h").expect_err("overflow should error"); + assert!(err.to_string().contains("duration out of range")); + + let err = parse_duration_to_ms("-9223372036854775808h").expect_err("overflow should error"); + assert!(err.to_string().contains("duration out of range")); + } + + #[test] + fn parse_duration_to_ms_accepts_the_largest_representable_duration() { + let max_hours = i64::MAX / 3_600_000; + assert_eq!( + parse_duration_to_ms(&format!("{max_hours}h")).expect("parse"), + max_hours * 3_600_000 + ); + } + + #[test] + fn platform_progress_events_update_borrowed_display_without_duplicate_steps() { + let event = PlatformEvent { + metadata: HashMap::from([ + ( + PROGRESS_COMPLETE_STEP_KEY.to_string(), + PROGRESS_STEP_REQUESTING_SANDBOX.to_string(), + ), + ( + PROGRESS_ACTIVE_STEP_KEY.to_string(), + PROGRESS_STEP_STARTING_SANDBOX.to_string(), + ), + ]), + ..PlatformEvent::default() + }; + let mut display = ProvisioningDisplay::new(); + + assert!(handle_platform_progress_event( + &event, + Some(&mut display), + Instant::now(), + )); + assert!(handle_platform_progress_event( + &event, + Some(&mut display), + Instant::now(), + )); + + assert_eq!( + display.completed_steps, + vec![ProvisioningStep::RequestingSandbox] + ); + assert_eq!(display.completed_bars.len(), 1); + assert_eq!( + display.active_label, + ProvisioningStep::StartingSandbox.active_label() + ); + display.clear(); + } + + // helper for building input + fn env(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn suffix_match_no_profile() { + let env = env(&[("FOO_TOKEN", "x")]); + let prof = credential_env_matches(&env); + assert_eq!(prof.len(), 1_usize); + assert_eq!(&prof[0].0, "FOO_TOKEN"); + assert!(prof[0].1.is_empty()); + } + + #[test] + fn exact_profile_match() { + let env = env(&[("GITHUB_TOKEN", "x")]); + + let prof = credential_env_matches(&env); + assert_eq!(prof.len(), 1_usize); + assert_eq!(prof[0].0, "GITHUB_TOKEN"); + + let sug = &prof[0].1; + assert_eq!(sug.len(), 2_usize); + + assert_eq!(sug[0].provider_type, "copilot"); + assert_eq!(sug[0].credential, "api_token"); + + assert_eq!(sug[1].provider_type, "github"); + assert_eq!(sug[1].credential, "api_token"); + } + + #[test] + fn case_insensitive() { + let env = env(&[("gh_token", "x")]); + + let prof = credential_env_matches(&env); + assert_eq!(prof.len(), 1_usize); + assert_eq!(prof[0].0, "gh_token"); + + let sug = &prof[0].1; + assert_eq!(sug.len(), 2_usize); + + assert_eq!(sug[0].provider_type, "copilot"); + assert_eq!(sug[0].credential, "api_token"); + + assert_eq!(sug[1].provider_type, "github"); + assert_eq!(sug[1].credential, "api_token"); + } + + #[test] + fn non_credential_skipped() { + let env = env(&[("PATH", "x"), ("HOME", "y")]); + + let prof = credential_env_matches(&env); + assert!(prof.is_empty()); + } + + #[test] + fn no_value_leak() { + let env = env(&[("APP_SECRET", "secretVALUE42")]); + + let prof = credential_env_matches(&env); + assert_eq!(prof.len(), 1_usize); + + let dumped = format!("{prof:?}"); + assert!(!dumped.contains("secretVALUE42"), "value leaked: {dumped}"); + } + + #[test] + fn deterministic_order() { + let env = env(&[ + ("ZED_TOKEN", "a"), + ("ABC_SECRET", "b"), + ("MID_PASSWORD", "c"), + ]); + + let prof = credential_env_matches(&env); + let keys: Vec<&str> = prof.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, ["ABC_SECRET", "MID_PASSWORD", "ZED_TOKEN"]); + } + + #[test] + fn nonsecrets() { + let env = env(&[ + ("TOKENIZERS_PARALLELISM", "x"), + ("PASSWORDLESS_LOGIN", "y"), + ("SECRETARY_EMAIL", "z"), + ]); + + let prof = credential_env_matches(&env); + assert!(prof.is_empty()); + } + + #[test] + fn segment_matches() { + let env = env(&[ + ("DB_TOKEN", "a"), + ("MY_ACCESS_KEY", "b"), + ("PRIMARY_KEY", "c"), + ]); + + let prof = credential_env_matches(&env); + assert_eq!(prof.len(), 2_usize); + + let keys = prof.iter().map(|(k, _)| k.as_str()).collect::>(); + assert!(keys.contains(&"DB_TOKEN")); + assert!(keys.contains(&"MY_ACCESS_KEY")); + assert!(!keys.contains(&"PRIMARY_KEY")); + } } diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index acf87a1466..0a7950050d 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use crate::color::Colorize; use crate::tls::{ TlsOptions, build_insecure_rustls_config, build_rustls_config, grpc_client, require_tls_materials, @@ -20,7 +21,6 @@ use openshell_bootstrap::{ }; use openshell_bootstrap::{GatewayMetadataSource, ListedGateway}; use openshell_core::proto::{GetGatewayInfoRequest, HealthRequest, ServiceStatus}; -use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; use tonic::{Code, Status}; @@ -905,6 +905,26 @@ pub async fn gateway_add( false } } + } else if is_browser_suppressed() { + match crate::oidc_auth::oidc_device_code_flow( + issuer, + oidc_client_id, + oidc_audience, + oidc_scopes, + gateway_insecure, + ) + .await + { + Ok(bundle) => { + openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; + eprintln!("{} Authenticated via device code", "✓".green().bold()); + true + } + Err(e) => { + eprintln!("{} Authentication failed: {e}", "!".yellow()); + false + } + } } else { match crate::oidc_auth::oidc_browser_auth_flow( issuer, @@ -912,6 +932,7 @@ pub async fn gateway_add( oidc_audience, oidc_scopes, gateway_insecure, + false, ) .await { @@ -1116,6 +1137,8 @@ pub async fn gateway_login(name: &str, gateway_insecure: bool) -> Result<()> { .unwrap_or("openshell-cli"); let audience = metadata.oidc_audience.as_deref(); let scopes = metadata.oidc_scopes.as_deref(); + let force_fresh_login = + openshell_bootstrap::oidc_token::oidc_login_prompt_required(name); let bundle = if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() { crate::oidc_auth::oidc_client_credentials_flow( @@ -1126,6 +1149,15 @@ pub async fn gateway_login(name: &str, gateway_insecure: bool) -> Result<()> { gateway_insecure, ) .await? + } else if is_browser_suppressed() { + crate::oidc_auth::oidc_device_code_flow( + issuer, + client_id, + audience, + scopes, + gateway_insecure, + ) + .await? } else { crate::oidc_auth::oidc_browser_auth_flow( issuer, @@ -1133,12 +1165,14 @@ pub async fn gateway_login(name: &str, gateway_insecure: bool) -> Result<()> { audience, scopes, gateway_insecure, + force_fresh_login, ) .await? }; let username = jwt_preferred_username(&bundle.access_token); openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; + openshell_bootstrap::oidc_token::clear_oidc_login_prompt(name)?; if let Some(user) = username { eprintln!( @@ -1184,6 +1218,7 @@ pub fn gateway_logout(name: &str) -> Result<()> { match metadata.auth_mode.as_deref() { Some("oidc") => { openshell_bootstrap::oidc_token::remove_oidc_token(name)?; + openshell_bootstrap::oidc_token::request_oidc_login_prompt(name)?; } Some("cloudflare_jwt") => { openshell_bootstrap::edge_token::remove_edge_token(name)?; @@ -1430,6 +1465,9 @@ fn remove_gateway_registration(name: &str) { if let Err(err) = openshell_bootstrap::oidc_token::remove_oidc_token(name) { tracing::debug!("failed to remove oidc token: {err}"); } + if let Err(err) = openshell_bootstrap::oidc_token::clear_oidc_login_prompt(name) { + tracing::debug!("failed to clear oidc login prompt marker: {err}"); + } if let Err(err) = remove_gateway_metadata(name) { tracing::debug!("failed to remove gateway metadata: {err}"); } diff --git a/crates/openshell-cli/src/commands/mod.rs b/crates/openshell-cli/src/commands/mod.rs index 09ea2941a4..8d75cf5ae3 100644 --- a/crates/openshell-cli/src/commands/mod.rs +++ b/crates/openshell-cli/src/commands/mod.rs @@ -3,3 +3,4 @@ pub mod common; pub mod gateway; +pub mod provider; diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs new file mode 100644 index 0000000000..c903156af0 --- /dev/null +++ b/crates/openshell-cli/src/commands/provider.rs @@ -0,0 +1,3021 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use crate::color::Colorize; +use crate::commands::common::{ + format_epoch_ms, format_optional_epoch_ms, parse_credential_expiry_pairs, + parse_credential_pairs, parse_key_value_pairs, parse_secret_material_env_pairs, + truncate_display, truncate_status_field, +}; +use crate::tls::{TlsOptions, grpc_client}; +use dialoguer::Confirm; +use miette::{IntoDiagnostic, Result, WrapErr, miette}; +use openshell_core::proto::ProviderProfileCategory; +use openshell_core::proto::{ + AttachSandboxProviderRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, + DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, + DetachSandboxProviderRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRequest, GetSandboxRequest, ImportProviderProfilesRequest, + LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, + ListSandboxProvidersRequest, Provider, ProviderCredentialRefreshRecoveryAction, + ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, + ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileDiagnostic, + ProviderProfileImportItem, RotateProviderCredentialRequest, UpdateProviderProfilesRequest, + UpdateProviderRequest, +}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; +use openshell_providers::{ + ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, discover_from_profile, + normalize_profile_id, normalize_provider_type, parse_profile_json, parse_profile_yaml, + profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, +}; +use std::collections::{HashMap, HashSet}; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use tonic::{Code, Status}; + +fn aggregate_delete_failures(resource: &str, failures: &[String]) -> Result<()> { + if failures.is_empty() { + Ok(()) + } else { + Err(miette!( + "failed to delete {} {}{}: {}", + failures.len(), + resource, + if failures.len() == 1 { "" } else { "s" }, + failures.join(", ") + )) + } +} + +pub async fn sandbox_provider_list( + server: &str, + name: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_sandbox_providers(ListSandboxProvidersRequest { + sandbox_name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let providers = response.into_inner().providers; + + if crate::output::print_output_collection(output, &providers, attached_provider_to_json)? { + return Ok(()); + } + + if providers.is_empty() { + println!("No providers attached to sandbox {name}."); + return Ok(()); + } + + print_provider_attachment_table(&providers); + Ok(()) +} + +pub async fn sandbox_provider_attach( + server: &str, + name: &str, + provider: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + + // Fetch current sandbox to get resource_version for CAS + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; + + let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); + + let response = match client + .attach_sandbox_provider(AttachSandboxProviderRequest { + sandbox_name: name.to_string(), + provider_name: provider.to_string(), + expected_resource_version: resource_version, + workspace: workspace.to_string(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(status) if status.code() == Code::Aborted => { + return Err(miette::miette!( + "Failed to attach provider: sandbox was modified by another operation.\n\ + Please retry the command." + ) + .with_source_code(status.message().to_string())); + } + Err(e) => return Err(e).into_diagnostic(), + }; + + if response.attached { + println!( + "{} Attached provider {} to sandbox {}", + "✓".green().bold(), + provider, + name + ); + } else { + println!("Provider {provider} is already attached to sandbox {name}."); + } + Ok(()) +} + +pub async fn sandbox_provider_detach( + server: &str, + name: &str, + provider: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + + // Fetch current sandbox to get resource_version for CAS + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; + + let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); + + let response = match client + .detach_sandbox_provider(DetachSandboxProviderRequest { + sandbox_name: name.to_string(), + provider_name: provider.to_string(), + expected_resource_version: resource_version, + workspace: workspace.to_string(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(status) if status.code() == Code::Aborted => { + return Err(miette::miette!( + "Failed to detach provider: sandbox was modified by another operation.\n\ + Please retry the command." + ) + .with_source_code(status.message().to_string())); + } + Err(e) => return Err(e).into_diagnostic(), + }; + + if response.detached { + println!( + "{} Detached provider {} from sandbox {}", + "✓".green().bold(), + provider, + name + ); + } else { + println!("Provider {provider} was not attached to sandbox {name}."); + } + Ok(()) +} + +fn print_provider_attachment_table(providers: &[Provider]) { + print!("{}", format_provider_attachment_table(providers, true)); +} + +fn attached_provider_to_json(provider: &Provider) -> serde_json::Value { + let mut config_keys = provider.config.keys().cloned().collect::>(); + config_keys.sort(); + + serde_json::json!({ + "name": provider.object_name(), + "type": provider.r#type, + "credential_keys": provider_credential_keys(provider), + "config_keys": config_keys, + }) +} + +fn format_provider_attachment_table(providers: &[Provider], color: bool) -> String { + use std::fmt::Write as _; + + let name_width = providers + .iter() + .map(|provider| provider.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + let type_width = providers + .iter() + .map(|provider| provider.r#type.len()) + .max() + .unwrap_or(4) + .max(4); + + let name_header = if color { + "NAME".bold().to_string() + } else { + "NAME".to_string() + }; + let type_header = if color { + "TYPE".bold().to_string() + } else { + "TYPE".to_string() + }; + let credential_keys_header = if color { + "CREDENTIAL_KEYS".bold().to_string() + } else { + "CREDENTIAL_KEYS".to_string() + }; + let config_keys_header = if color { + "CONFIG_KEYS".bold().to_string() + } else { + "CONFIG_KEYS".to_string() + }; + + let mut output = String::new(); + let _ = writeln!( + output, + "{name_header: Option { + detect_provider_from_command(command).map(str::to_string) +} + +/// Ensure all required providers exist. +/// +/// `explicit_names` are provider **names** supplied via `--provider`. They are +/// passed through directly; the server validates they exist at sandbox creation. +/// +/// `inferred_types` are provider **types** inferred from the trailing command +/// (e.g. `claude` -> type `"claude-code"`). These are resolved to provider names via +/// a type→name lookup, and missing types may be auto-created interactively. +/// +/// Returns a deduplicated list of provider **names** suitable for +/// `SandboxSpec.providers`. +pub async fn ensure_required_providers( + client: &mut crate::tls::GrpcClient, + explicit_names: &[String], + inferred_types: &[String], + auto_providers_override: Option, + workspace: &str, +) -> Result> { + if explicit_names.is_empty() && inferred_types.is_empty() { + return Ok(Vec::new()); + } + + let mut configured_names: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + + // ── Fetch all existing providers ───────────────────────────────────── + // Build both a name set (for explicit --provider lookups) and a + // type-to-name map (for inferred provider resolution). + let mut known_names: HashSet = HashSet::new(); + let mut type_to_name: HashMap = HashMap::new(); + { + let mut offset = 0_u32; + let limit = 100_u32; + loop { + let response = client + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: workspace.to_string(), + all_workspaces: false, + }) + .await + .into_diagnostic()?; + let providers = response.into_inner().providers; + for provider in &providers { + known_names.insert(provider.object_name().to_string()); + if !provider.r#type.is_empty() { + let type_lower = provider.r#type.to_ascii_lowercase(); + type_to_name + .entry(type_lower) + .or_insert_with(|| provider.object_name().to_string()); + } + } + if providers.len() < limit as usize { + break; + } + offset = offset.saturating_add(limit); + } + } + + // ── Explicit provider names ────────────────────────────────────────── + // If the name exists on the server, use it directly. Otherwise, if the + // name matches a known provider type, auto-create a provider of that + // type with the requested name. + for name in explicit_names { + if known_names.contains(name) { + if seen_names.insert(name.clone()) { + configured_names.push(name.clone()); + } + } else { + let profile_id = name.trim(); + let profile = fetch_provider_profile(client, profile_id, workspace) + .await + .map_err(|_| { + miette::miette!( + "provider '{name}' not found and no provider profile named '{profile_id}' is available. \ + Create or import the profile first, then create the provider" + ) + })?; + let provider_type = profile.id; + auto_create_provider( + client, + &provider_type, + Some(name), + auto_providers_override, + &mut seen_names, + &mut configured_names, + workspace, + ) + .await?; + // Record the type mapping so the inferred-types pass below + // doesn't attempt to create a duplicate provider. + type_to_name + .entry(provider_type.to_ascii_lowercase()) + .or_insert_with(|| name.clone()); + } + } + + // ── Resolve inferred provider types ────────────────────────────────── + if !inferred_types.is_empty() { + // Collect resolved names for types that already have a provider. + for t in inferred_types { + if let Some(name) = type_to_name.get(&t.to_ascii_lowercase()) + && seen_names.insert(name.clone()) + { + configured_names.push(name.clone()); + } + } + + let missing = inferred_types + .iter() + .filter(|t| !type_to_name.contains_key(&t.to_ascii_lowercase())) + .cloned() + .collect::>(); + + for provider_type in missing { + auto_create_provider( + client, + &provider_type, + None, + auto_providers_override, + &mut seen_names, + &mut configured_names, + workspace, + ) + .await?; + } + } + + Ok(configured_names) +} + +/// Prompt for (or auto-confirm) creation of a provider from local credentials. +/// +/// When `preferred_name` is `Some`, the provider is created with that exact +/// name (used for explicit `--provider ` values). When `None`, the name +/// defaults to the type and retries with suffixes on conflict (used for +/// inferred provider types). +async fn auto_create_provider( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + preferred_name: Option<&str>, + auto_providers_override: Option, + seen_names: &mut HashSet, + configured_names: &mut Vec, + workspace: &str, +) -> Result<()> { + eprintln!("Missing provider: {provider_type}"); + + // --no-auto-providers: skip silently. + if auto_providers_override == Some(false) { + eprintln!( + "{} Skipping provider '{provider_type}' (--no-auto-providers)", + "!".yellow(), + ); + eprintln!(); + return Ok(()); + } + + // No override and non-interactive: error. + if auto_providers_override.is_none() && !std::io::stdin().is_terminal() { + return Err(miette::miette!( + "missing required provider '{provider_type}'. Create it first with \ + `openshell provider create --type {provider_type} --name {provider_type} --from-existing`, \ + pass --auto-providers to auto-create, or set it up manually from inside the sandbox" + )); + } + + // --auto-providers: auto-confirm; otherwise prompt. + let should_create = if auto_providers_override == Some(true) { + true + } else { + Confirm::new() + .with_prompt("Create from local credentials?") + .default(true) + .interact() + .into_diagnostic()? + }; + + if !should_create { + eprintln!("{} Skipping provider '{provider_type}'", "!".yellow()); + eprintln!(); + return Ok(()); + } + + let profile = fetch_provider_profile(client, provider_type, workspace).await?; + let discovered = discover_existing_provider_data(client, provider_type, workspace) + .await + .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; + let discovered = match discovered { + Some(discovered) => discovered, + None if provider_profile_allows_empty_credentials(&profile) => { + openshell_providers::DiscoveredProvider::default() + } + None => { + return Err(miette::miette!( + "no existing local credentials found for provider profile '{provider_type}'. \ + Create it first with `openshell provider create --type {provider_type} --name {provider_type} --credential `" + )); + } + }; + + if let Some(exact_name) = preferred_name { + // Explicit name: create with exactly that name, no retries. + let request = CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: exact_name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.to_string(), + credentials: discovered.credentials.clone(), + config: discovered.config.clone(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), + credential_handles: HashMap::new(), + }), + workspace: workspace.to_string(), + }; + + let response = client.create_provider(request).await.map_err(|status| { + miette::miette!("failed to create provider '{exact_name}': {status}") + })?; + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + eprintln!( + "{} Created provider {} ({}) from existing local state", + "✓".green().bold(), + provider.object_name(), + provider.r#type + ); + if seen_names.insert(provider.object_name().to_string()) { + configured_names.push(provider.object_name().to_string()); + } + } else { + // Inferred type: try type as name, then suffixed variants. + let mut created = false; + for attempt in 0..5 { + let name = if attempt == 0 { + provider_type.to_string() + } else { + format!("{provider_type}-{attempt}") + }; + + let request = CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.clone(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.to_string(), + credentials: discovered.credentials.clone(), + config: discovered.config.clone(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), + credential_handles: HashMap::new(), + }), + workspace: workspace.to_string(), + }; + + match client.create_provider(request).await { + Ok(response) => { + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + eprintln!( + "{} Created provider {} ({}) from existing local state", + "✓".green().bold(), + provider.object_name(), + provider.r#type + ); + if seen_names.insert(provider.object_name().to_string()) { + configured_names.push(provider.object_name().to_string()); + } + created = true; + break; + } + Err(status) if status.code() == Code::AlreadyExists => {} + Err(status) => { + return Err(miette::miette!( + "failed to create provider for type '{provider_type}': {status}" + )); + } + } + } + + if !created { + return Err(miette::miette!( + "failed to create provider for type '{provider_type}' after name retries" + )); + } + } + + eprintln!(); + Ok(()) +} + +fn read_gcloud_adc() -> Result<(String, String, String)> { + let path = if let Some(env_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") + .ok() + .filter(|v| !v.is_empty()) + { + PathBuf::from(env_path) + } else if let Some(config_dir) = std::env::var("CLOUDSDK_CONFIG") + .ok() + .filter(|v| !v.is_empty()) + { + PathBuf::from(config_dir).join("application_default_credentials.json") + } else { + let home = std::env::var("HOME") + .map_err(|_| miette::miette!("HOME is not set; cannot locate gcloud ADC file"))?; + PathBuf::from(home) + .join(".config") + .join("gcloud") + .join("application_default_credentials.json") + }; + + let content = std::fs::read_to_string(&path).map_err(|err| { + miette::miette!( + "failed to read gcloud ADC file at {}: {}. \ + Run: gcloud auth application-default login", + path.display(), + err + ) + })?; + + let json: serde_json::Value = serde_json::from_str(&content) + .map_err(|err| miette::miette!("failed to parse gcloud ADC file: {err}"))?; + + let cred_type = json.get("type").and_then(|v| v.as_str()); + match cred_type { + Some("service_account") => { + return Err(miette::miette!( + "Application Default Credentials are a service account key, not user credentials. \ + To use a service account, create the provider with the service account JSON key \ + and configure gateway-managed refresh for 'GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN'. \ + See: openshell provider create --help" + )); + } + Some("authorized_user") => {} + Some(other) => { + return Err(miette::miette!( + "Application Default Credentials have unsupported type '{other}' \ + (expected 'authorized_user'). \ + Run: gcloud auth application-default login" + )); + } + None => { + return Err(miette::miette!( + "gcloud ADC file is missing the 'type' field. \ + The file may be malformed. \ + Run: gcloud auth application-default login" + )); + } + } + + let client_id = json + .get("client_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_id'"))? + .to_string(); + + let client_secret = json + .get("client_secret") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_secret'"))? + .to_string(); + + let refresh_token = json + .get("refresh_token") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'refresh_token'"))? + .to_string(); + + Ok((client_id, client_secret, refresh_token)) +} + +async fn rollback_provider_create_after_gcloud_adc_failure( + client: &mut crate::tls::GrpcClient, + provider_name: &str, + stage: &str, + source: &Status, + workspace: &str, +) -> Result<()> { + match client + .delete_provider(DeleteProviderRequest { + name: provider_name.to_string(), + workspace: workspace.to_string(), + }) + .await + { + Ok(_) => Err(miette!( + "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ + The provider was rolled back successfully." + )), + Err(cleanup_err) => { + eprintln!( + "{} Failed to clean up provider '{}' after {} failed: {}. \ + Run 'openshell provider delete {}' to remove it manually.", + "⚠".yellow(), + provider_name, + stage, + cleanup_err, + provider_name + ); + Err(miette!( + "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ + Cleanup also failed, so the provider may still exist. \ + Run 'openshell provider delete {provider_name}' to remove it manually." + )) + } + } +} + +async fn fetch_provider_profile( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + workspace: &str, +) -> Result { + let requested = provider_type.trim(); + let response = match fetch_provider_profile_exact(client, requested, workspace).await { + Ok(response) => response, + Err(status) if status.code() == Code::NotFound => { + let Some(alias) = normalize_provider_type(requested) + .filter(|alias| normalize_profile_id(requested).as_deref() != Some(*alias)) + else { + return Err(miette::miette!( + "provider profile '{requested}' not found; import a matching profile before using this provider type" + )); + }; + fetch_provider_profile_exact(client, alias, workspace) + .await + .map_err(|fallback_status| { + if fallback_status.code() == Code::NotFound { + miette::miette!( + "provider profile '{requested}' not found; import a matching profile before using this provider type" + ) + } else { + miette::miette!(fallback_status.to_string()) + } + })? + } + Err(status) => return Err(miette::miette!(status.to_string())), + }; + + Ok(response) +} + +async fn fetch_provider_profile_exact( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + workspace: &str, +) -> std::result::Result { + client + .get_provider_profile(GetProviderProfileRequest { + id: provider_type.to_string(), + workspace: workspace.to_string(), + }) + .await + .and_then(|response| { + response + .into_inner() + .profile + .ok_or_else(|| Status::internal("provider profile missing from response")) + }) +} + +async fn discover_existing_provider_data( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + workspace: &str, +) -> Result> { + let profile = fetch_provider_profile(client, provider_type, workspace).await?; + let profile = ProviderTypeProfile::from_proto(&profile); + let discovered = discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { + miette::miette!("failed to discover existing provider data from profile: {err}") + })?; + + Ok(discovered) +} + +/// Canonical provider type string for Google Vertex AI. +const VERTEX_AI_PROVIDER_TYPE: &str = "google-vertex-ai"; + +/// Canonical provider type string for Google Cloud (GCP APIs). +const GOOGLE_CLOUD_PROVIDER_TYPE: &str = "google-cloud"; + +fn missing_credentials_error(provider_type: &str) -> miette::Report { + if provider_type == VERTEX_AI_PROVIDER_TYPE { + return miette::miette!( + "no credentials resolved for provider type '{provider_type}'. \ + Set GOOGLE_VERTEX_AI_TOKEN, VERTEX_AI_TOKEN, \ + GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN, or VERTEX_AI_SERVICE_ACCOUNT_TOKEN; \ + or use --from-gcloud-adc or --from-existing with those env vars set." + ); + } + + if provider_type == GOOGLE_CLOUD_PROVIDER_TYPE { + return miette::miette!( + "no credentials resolved for provider type '{provider_type}'. \ + Set GCP_ADC_ACCESS_TOKEN or GCP_SA_ACCESS_TOKEN; \ + or use --from-gcloud-adc / --from-existing with those env vars set." + ); + } + + miette::miette!( + "no credentials resolved for provider type '{provider_type}'. \ + Use --credential KEY[=VALUE], --runtime-credentials for runtime-resolved profile credentials, or --from-existing \ + with the appropriate env vars set." + ) +} + +async fn provider_credential_from_oidc_token( + credentials: &[String], + profile: Option<&ProviderProfile>, + tls: &TlsOptions, +) -> Result<(HashMap, HashMap)> { + let credential_key = oidc_subject_credential_key(credentials, profile)?; + + let gateway_name = tls.gateway_name().ok_or_else(|| { + miette::miette!("--from-oidc-token requires an active named OIDC gateway") + })?; + let bundle = + crate::oidc_auth::ensure_valid_oidc_token_bundle(gateway_name, tls.gateway_insecure) + .await + .map_err(|err| { + miette::miette!( + "failed to load or refresh OIDC token for gateway '{gateway_name}' while preparing provider credential: {err}" + ) + })?; + + let mut credential_map = HashMap::new(); + credential_map.insert(credential_key.clone(), bundle.access_token); + + let mut credential_expires_at_ms = HashMap::new(); + if let Some(expires_at) = bundle.expires_at { + let expires_at_ms = i64::try_from(expires_at) + .unwrap_or(i64::MAX / 1000) + .saturating_mul(1000); + credential_expires_at_ms.insert(credential_key, expires_at_ms); + } + + Ok((credential_map, credential_expires_at_ms)) +} + +fn oidc_subject_credential_key( + credentials: &[String], + profile: Option<&ProviderProfile>, +) -> Result { + if credentials.len() > 1 { + return Err(miette::miette!( + "--from-oidc-token accepts at most one --credential KEY destination" + )); + } + + if let Some(credential) = credentials.first() { + let credential = credential.trim(); + if credential.is_empty() || credential.contains('=') { + return Err(miette::miette!( + "--from-oidc-token requires --credential KEY without an inline value" + )); + } + if let Some(profile) = profile { + ensure_profile_declares_subject_credential(profile, credential)?; + } + return Ok(credential.to_string()); + } + + let Some(profile) = profile else { + return Err(miette::miette!( + "--from-oidc-token requires --credential KEY when the provider profile is unavailable" + )); + }; + + infer_oidc_subject_credential_from_profile(profile) +} + +fn ensure_profile_declares_subject_credential( + profile: &ProviderProfile, + credential: &str, +) -> Result<()> { + let matches = token_exchange_subject_credentials(profile); + if matches.iter().any(|candidate| candidate == credential) { + return Ok(()); + } + Err(miette::miette!( + "credential '{credential}' is not declared as a token-exchange subject credential in provider profile '{}'; expected one of: {}", + profile.id, + matches.join(", ") + )) +} + +fn infer_oidc_subject_credential_from_profile(profile: &ProviderProfile) -> Result { + let matches = token_exchange_subject_credentials(profile); + match matches.as_slice() { + [credential] => Ok(credential.clone()), + [] => Err(miette::miette!( + "provider profile '{}' does not declare a token-exchange subject credential; pass --credential KEY", + profile.id + )), + _ => Err(miette::miette!( + "provider profile '{}' declares multiple token-exchange subject credentials ({}); pass --credential KEY", + profile.id, + matches.join(", ") + )), + } +} + +fn token_exchange_subject_credentials(profile: &ProviderProfile) -> Vec { + let mut matches = Vec::new(); + for credential in &profile.credentials { + let Some(token_grant) = credential.token_grant.as_ref() else { + continue; + }; + if ProviderCredentialTokenGrantType::try_from(token_grant.grant_type).ok() + != Some(ProviderCredentialTokenGrantType::TokenExchange) + { + continue; + } + let Some(subject_token) = token_grant.subject_token.as_ref() else { + continue; + }; + if subject_token.source != "provider_credential" || subject_token.credential.is_empty() { + continue; + } + if !matches.contains(&subject_token.credential) { + matches.push(subject_token.credential.clone()); + } + } + matches +} + +#[allow(clippy::too_many_arguments)] +pub async fn provider_create( + server: &str, + name: &str, + provider_type: &str, + from_existing: bool, + credentials: &[String], + from_gcloud_adc: bool, + config: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let credential_source = match (from_existing, from_gcloud_adc) { + (true, true) => { + return Err(miette::miette!( + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential; it also cannot be combined with --runtime-credentials" + )); + } + (true, false) => ProviderCreateCredentialSource::Existing, + (false, true) => ProviderCreateCredentialSource::GcloudAdc, + (false, false) => ProviderCreateCredentialSource::ExplicitCredentials, + }; + provider_create_with_options(ProviderCreateOptions { + server, + name, + provider_type, + credentials, + credential_source, + config, + workspace, + profile_workspace: workspace, + tls, + }) + .await +} + +pub struct ProviderCreateOptions<'a> { + pub server: &'a str, + pub name: &'a str, + pub provider_type: &'a str, + pub credentials: &'a [String], + pub credential_source: ProviderCreateCredentialSource, + pub config: &'a [String], + pub workspace: &'a str, + pub profile_workspace: &'a str, + pub tls: &'a TlsOptions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProviderCreateCredentialSource { + ExplicitCredentials, + Existing, + GcloudAdc, + OidcToken, + Runtime, +} + +pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> Result<()> { + let ProviderCreateOptions { + server, + name, + provider_type, + credentials, + credential_source, + config, + workspace, + profile_workspace, + tls, + } = options; + + let from_existing = credential_source == ProviderCreateCredentialSource::Existing; + let from_gcloud_adc = credential_source == ProviderCreateCredentialSource::GcloudAdc; + let from_oidc_token = credential_source == ProviderCreateCredentialSource::OidcToken; + let runtime_credentials = credential_source == ProviderCreateCredentialSource::Runtime; + + if from_gcloud_adc && !credentials.is_empty() { + return Err(miette::miette!( + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential; it also cannot be combined with --runtime-credentials" + )); + } + if from_existing && !credentials.is_empty() { + return Err(miette::miette!( + "--from-existing cannot be combined with --credential" + )); + } + if runtime_credentials && !credentials.is_empty() { + return Err(miette::miette!( + "--runtime-credentials cannot be combined with --credential" + )); + } + + let mut client = grpc_client(server, tls).await?; + + let profile_id = provider_type.trim(); + if profile_id.is_empty() { + return Err(miette::miette!("provider type is required")); + } + let provider_profile = fetch_provider_profile(&mut client, profile_id, profile_workspace) + .await + .map_err(|err| { + miette::miette!("unsupported provider type or profile: {profile_id} ({err})") + })?; + let provider_type = provider_profile.id.clone(); + + let adc_credential_key = if from_gcloud_adc { + let profile = ProviderTypeProfile::from_proto(&provider_profile); + let adc_cred = profile.adc_credential().ok_or_else(|| { + miette::miette!( + "--from-gcloud-adc is not supported for '{provider_type}' providers \ + (no ADC-compatible credential in the provider profile)" + ) + })?; + Some( + adc_cred + .env_vars + .first() + .ok_or_else(|| { + miette::miette!( + "ADC credential in '{provider_type}' profile has no env_vars declared" + ) + })? + .clone(), + ) + } else { + None + }; + + let oidc_profile = if from_oidc_token { + Some(provider_profile.clone()) + } else { + None + }; + + let (mut credential_map, oidc_credential_expires_at_ms) = if from_oidc_token { + provider_credential_from_oidc_token(credentials, oidc_profile.as_ref(), tls).await? + } else { + (parse_credential_pairs(credentials)?, HashMap::new()) + }; + let mut config_map = parse_key_value_pairs(config, "--config")?; + + if from_existing { + let discovered = + discover_existing_provider_data(&mut client, &provider_type, profile_workspace).await?; + let Some(discovered) = discovered else { + return Err(miette::miette!( + "no existing local credentials/config found for provider type '{provider_type}'" + )); + }; + + for (key, value) in discovered.credentials { + credential_map.entry(key).or_insert(value); + } + for (key, value) in discovered.config { + config_map.entry(key).or_insert(value); + } + } + + if credential_map.is_empty() { + if from_existing { + return Err(missing_credentials_error(&provider_type)); + } + if runtime_credentials && !provider_profile_allows_runtime_credentials(&provider_profile) { + return Err(miette::miette!( + "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" + )); + } + if !provider_profile_allows_empty_credentials(&provider_profile) { + return Err(missing_credentials_error(&provider_type)); + } + } + + // Validate and read the ADC file BEFORE creating the provider so that + // a bad/missing ADC does not leave an orphan provider behind. Bundle the + // credential key with the material so they stay coupled. + let gcloud_adc_bootstrap = if from_gcloud_adc { + let (client_id, client_secret, refresh_token) = read_gcloud_adc()?; + let key = adc_credential_key.expect("set when from_gcloud_adc is true"); + Some((key, client_id, client_secret, refresh_token)) + } else { + None + }; + + let response = client + .create_provider(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.clone(), + credentials: credential_map, + config: config_map, + credential_expires_at_ms: oidc_credential_expires_at_ms, + profile_workspace: profile_workspace.to_string(), + credential_handles: HashMap::new(), + }), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + let provider_name = provider.object_name().to_string(); + + if let Some((adc_credential_key, client_id, client_secret, refresh_token)) = + gcloud_adc_bootstrap + { + let mut material = HashMap::new(); + material.insert("client_id".to_string(), client_id); + material.insert("client_secret".to_string(), client_secret); + material.insert("refresh_token".to_string(), refresh_token); + + if let Err(configure_err) = client + .configure_provider_refresh(ConfigureProviderRefreshRequest { + provider: provider_name.clone(), + credential_key: adc_credential_key.clone(), + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, + material, + secret_material_keys: vec![ + "client_secret".to_string(), + "refresh_token".to_string(), + ], + expires_at_ms: None, + workspace: workspace.to_string(), + }) + .await + { + return rollback_provider_create_after_gcloud_adc_failure( + &mut client, + &provider_name, + "configure", + &configure_err, + workspace, + ) + .await; + } + + if let Err(rotate_err) = client + .rotate_provider_credential(RotateProviderCredentialRequest { + provider: provider_name.clone(), + credential_key: adc_credential_key, + workspace: workspace.to_string(), + }) + .await + { + return rollback_provider_create_after_gcloud_adc_failure( + &mut client, + &provider_name, + "mint the initial access token for", + &rotate_err, + workspace, + ) + .await; + } + + println!("{} Created provider {}", "✓".green().bold(), provider_name); + println!("Configured GCP credentials from gcloud ADC and minted the initial access token"); + return Ok(()); + } + + println!("{} Created provider {}", "✓".green().bold(), provider_name); + Ok(()) +} + +fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool { + ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() +} + +fn provider_profile_allows_runtime_credentials(profile: &ProviderProfile) -> bool { + ProviderTypeProfile::from_proto(profile).allows_runtime_provider_credentials() +} + +pub async fn provider_get( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider(GetProviderRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + + let credential_keys = provider_credential_keys(&provider); + let config_keys = provider.config.keys().cloned().collect::>(); + + println!("{}", "Provider:".cyan().bold()); + println!(); + println!(" {} {}", "Id:".dimmed(), provider.object_id()); + println!(" {} {}", "Name:".dimmed(), provider.object_name()); + println!(" {} {}", "Type:".dimmed(), provider.r#type); + println!( + " {} {}", + "Resource version:".dimmed(), + provider.metadata.as_ref().map_or(0, |m| m.resource_version) + ); + println!( + " {} {}", + "Credential keys:".dimmed(), + if credential_keys.is_empty() { + "".to_string() + } else { + credential_keys.join(", ") + } + ); + println!( + " {} {}", + "Config keys:".dimmed(), + if config_keys.is_empty() { + "".to_string() + } else { + config_keys.join(", ") + } + ); + + Ok(()) +} + +fn provider_to_json(provider: &Provider) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + + // Core fields + obj.insert("id".to_string(), serde_json::json!(provider.object_id())); + obj.insert( + "name".to_string(), + serde_json::json!(provider.object_name()), + ); + obj.insert( + "workspace".to_string(), + serde_json::json!(provider.object_workspace()), + ); + obj.insert("type".to_string(), serde_json::json!(provider.r#type)); + + // Credential keys (NEVER values - security) + let credential_keys = provider_credential_keys(provider); + obj.insert( + "credential_keys".to_string(), + serde_json::json!(credential_keys), + ); + + // Config keys (keys only, not values) + if !provider.config.is_empty() { + let config_keys: Vec = provider.config.keys().cloned().collect(); + obj.insert("config_keys".to_string(), serde_json::json!(config_keys)); + } + + // Metadata fields (only if metadata exists) + if let Some(meta) = &provider.metadata { + if !meta.labels.is_empty() { + obj.insert("labels".to_string(), serde_json::json!(meta.labels)); + } + if meta.resource_version != 0 { + obj.insert( + "resource_version".to_string(), + serde_json::json!(meta.resource_version), + ); + } + if meta.created_at_ms != 0 { + obj.insert( + "created_at".to_string(), + serde_json::json!(format_epoch_ms(meta.created_at_ms)), + ); + } + } + + // Credential expiration times (only if present) + if !provider.credential_expires_at_ms.is_empty() { + obj.insert( + "credential_expires_at_ms".to_string(), + serde_json::json!(provider.credential_expires_at_ms), + ); + } + + serde_json::Value::Object(obj) +} + +fn provider_credential_keys(provider: &Provider) -> Vec { + let mut keys: Vec = provider + .credentials + .keys() + .chain(provider.credential_handles.keys()) + .cloned() + .collect(); + keys.sort(); + keys.dedup(); + keys +} + +#[allow(clippy::too_many_arguments)] +pub async fn provider_list( + server: &str, + limit: u32, + offset: u32, + names_only: bool, + output: &str, + workspace: &str, + all_workspaces: bool, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, + }) + .await + .into_diagnostic()?; + let providers = response.into_inner().providers; + + // Handle structured output formats (json, yaml) + if crate::output::print_output_collection(output, &providers, provider_to_json)? { + return Ok(()); + } + + if providers.is_empty() { + if !names_only { + println!("No providers found."); + } + return Ok(()); + } + + if names_only { + for provider in &providers { + if all_workspaces { + println!("{}/{}", provider.object_workspace(), provider.object_name()); + } else { + println!("{}", provider.object_name()); + } + } + return Ok(()); + } + + let ws_width = if all_workspaces { + providers + .iter() + .map(|p| p.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; + let name_width = providers + .iter() + .map(|provider| provider.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + let type_width = providers + .iter() + .map(|provider| provider.r#type.len()) + .max() + .unwrap_or(4) + .max(4); + + if all_workspaces { + println!( + "{: Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_provider_profiles(ListProviderProfilesRequest { + limit: 100, + offset: 0, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let mut profiles = response.into_inner().profiles; + profiles.sort_by(|left, right| { + left.category + .cmp(&right.category) + .then_with(|| left.id.cmp(&right.id)) + }); + let dto_profiles = profiles + .iter() + .map(ProviderTypeProfile::from_proto) + .collect::>(); + + if crate::output::print_output_direct( + output, + || profiles_to_json(&dto_profiles).into_diagnostic(), + || profiles_to_yaml(&dto_profiles).into_diagnostic(), + )? { + return Ok(()); + } + + if profiles.is_empty() { + println!("No provider profiles found."); + return Ok(()); + } + + println!("{}", "Available Provider Profiles:".cyan().bold()); + let id_width = provider_profile_id_width(&profiles); + let display_width = provider_profile_display_width(&profiles); + let source_width = provider_profile_source_width(&profiles); + let scope_width = provider_profile_scope_width(&profiles); + let mut current_category = i32::MIN; + for profile in &profiles { + if profile.category != current_category { + current_category = profile.category; + println!(); + println!(" {}", display_provider_category(current_category).bold()); + print_provider_type_header(id_width, scope_width, source_width, display_width); + } + print_provider_type_row(profile, id_width, scope_width, source_width, display_width); + } + + Ok(()) +} + +pub async fn provider_profile_export( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; + if output == "json" { + println!("{rendered}"); + } else { + print!("{rendered}"); + } + Ok(()) +} + +pub async fn provider_profile_export_text( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider_profile(GetProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let profile = response + .into_inner() + .profile + .ok_or_else(|| miette!("provider profile '{id}' not found"))?; + let profile = ProviderTypeProfile::from_proto(&profile); + + match output { + "json" => profile_to_json(&profile).into_diagnostic(), + "yaml" => profile_to_yaml(&profile).into_diagnostic(), + "table" => Err(miette!( + "profile export supports '-o yaml' and '-o json'; table output is not supported" + )), + _ => Err(miette!("unsupported output format: {output}")), + } +} + +pub async fn provider_profile_import( + server: &str, + file: Option<&Path>, + from: Option<&Path>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let (items, mut diagnostics) = load_profile_import_items(file, from)?; + if items.is_empty() && diagnostics.is_empty() { + return Err(miette!("no provider profile files found")); + } + if profile_diagnostics_have_errors(&diagnostics) { + print_profile_diagnostics(&diagnostics); + return Err(miette!("provider profile import failed")); + } + + let mut client = grpc_client(server, tls).await?; + if !items.is_empty() { + let response = client + .import_provider_profiles(ImportProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + diagnostics.extend(response.diagnostics); + if response.imported { + println!( + "Imported {} provider profile{}.", + response.profiles.len(), + if response.profiles.len() == 1 { + "" + } else { + "s" + } + ); + return Ok(()); + } + } + + print_profile_diagnostics(&diagnostics); + Err(miette!("provider profile import failed")) +} + +pub async fn provider_profile_update( + server: &str, + id: &str, + file: &Path, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?; + if items.is_empty() && diagnostics.is_empty() { + return Err(miette!("no provider profile files found")); + } + if profile_diagnostics_have_errors(&diagnostics) { + print_profile_diagnostics(&diagnostics); + return Err(miette!("provider profile update failed")); + } + + let mut client = grpc_client(server, tls).await?; + if let Some(item) = items.pop() { + let expected_resource_version = item + .profile + .as_ref() + .map_or(0, |profile| profile.resource_version); + let response = client + .update_provider_profiles(UpdateProviderProfilesRequest { + profile: Some(item), + expected_resource_version, + id: id.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + diagnostics.extend(response.diagnostics); + if response.updated { + println!("Updated provider profile."); + return Ok(()); + } + } + + print_profile_diagnostics(&diagnostics); + Err(miette!("provider profile update failed")) +} + +pub async fn provider_profile_lint( + server: &str, + file: Option<&Path>, + from: Option<&Path>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let (items, mut diagnostics) = load_profile_import_items(file, from)?; + if items.is_empty() && diagnostics.is_empty() { + return Err(miette!("no provider profile files found")); + } + + if !items.is_empty() { + let mut client = grpc_client(server, tls).await?; + let response = client + .lint_provider_profiles(LintProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + diagnostics.extend(response.diagnostics); + } + + if profile_diagnostics_have_errors(&diagnostics) { + print_profile_diagnostics(&diagnostics); + return Err(miette!("provider profile lint failed")); + } + + println!("Provider profile lint passed."); + Ok(()) +} + +pub async fn provider_profile_delete( + server: &str, + ids: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let mut failures = Vec::new(); + for id in ids { + let response = match client + .delete_provider_profile(DeleteProviderProfileRequest { + id: id.clone(), + workspace: workspace.to_string(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(status) => { + eprintln!( + "{} Failed to delete provider profile {id}: {status}", + "!".red().bold() + ); + failures.push(id.clone()); + continue; + } + }; + if response.deleted { + println!("{} Deleted provider profile {id}", "✓".green().bold()); + } else { + println!("{} Provider profile {id} not found", "!".yellow()); + } + } + aggregate_delete_failures("provider profile", &failures) +} + +pub async fn provider_refresh_status( + server: &str, + name: &str, + credential_key: Option<&str>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider_refresh_status(GetProviderRefreshStatusRequest { + provider: name.to_string(), + credential_key: credential_key.unwrap_or_default().to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + + if response.credentials.is_empty() { + if let Some(credential_key) = credential_key { + println!( + "No refresh configuration found for provider '{name}' credential '{credential_key}'." + ); + } else { + println!("No refresh configurations found for provider '{name}'."); + } + return Ok(()); + } + + println!("{}", refresh_status_header()); + for status in response.credentials { + print_refresh_status_row(&status); + } + Ok(()) +} + +fn refresh_status_header() -> String { + format!( + "{:<24} {:<28} {:<28} {:<24} {:<18} {:<20} {:<20} {:<20} {:<44} {}", + "PROVIDER".bold(), + "CREDENTIAL_KEY".bold(), + "STRATEGY".bold(), + "STATUS".bold(), + "RECOVERY".bold(), + "EXPIRES_AT".bold(), + "NEXT_REFRESH".bold(), + "LAST_REFRESH".bold(), + "FAILURE_CODE".bold(), + "LAST_ERROR".bold(), + ) +} + +pub struct ProviderRefreshConfigInput<'a> { + pub name: &'a str, + pub credential_key: &'a str, + pub strategy: &'a str, + pub material: &'a [String], + pub secret_material_env: &'a [String], + pub secret_material_keys: &'a [String], + pub credential_expires_at_ms: Option, +} + +pub async fn provider_refresh_config( + server: &str, + input: ProviderRefreshConfigInput<'_>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let strategy = provider_refresh_strategy(input.strategy)?; + let mut material = parse_key_value_pairs(input.material, "--material")?; + let mut secret_material_keys = input.secret_material_keys.to_vec(); + // Env-resolved secrets are auto-marked secret; duplicate keys are an + // error rather than a precedence order. + for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? { + if material.contains_key(&key) { + return Err(miette!( + "duplicate material key '{key}': supplied via both --material and --secret-material-env" + )); + } + if !secret_material_keys.contains(&key) { + secret_material_keys.push(key.clone()); + } + material.insert(key, value); + } + let mut client = grpc_client(server, tls).await?; + let status = client + .configure_provider_refresh(ConfigureProviderRefreshRequest { + provider: input.name.to_string(), + credential_key: input.credential_key.to_string(), + strategy: strategy as i32, + material, + secret_material_keys, + expires_at_ms: input.credential_expires_at_ms, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .status + .ok_or_else(|| miette!("provider refresh status missing from response"))?; + + println!( + "{} Configured refresh for {} {}", + "✓".green().bold(), + status.provider_name, + status.credential_key + ); + Ok(()) +} + +pub async fn provider_rotate( + server: &str, + name: &str, + credential_key: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let status = client + .rotate_provider_credential(RotateProviderCredentialRequest { + provider: name.to_string(), + credential_key: credential_key.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .status + .ok_or_else(|| miette!("provider refresh status missing from response"))?; + + if status.last_error.is_empty() { + println!( + "{} Rotation requested for {} {} ({})", + "✓".green().bold(), + status.provider_name, + status.credential_key, + status.status + ); + } else { + println!( + "Rotation request recorded for {} {} ({}): {}", + status.provider_name, status.credential_key, status.status, status.last_error + ); + } + Ok(()) +} + +pub async fn provider_refresh_delete( + server: &str, + name: &str, + credential_key: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .delete_provider_refresh(DeleteProviderRefreshRequest { + provider: name.to_string(), + credential_key: credential_key.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + + if response.deleted { + println!( + "{} Deleted refresh config for {} {}", + "✓".green().bold(), + name, + credential_key + ); + } else { + println!("No refresh config found for provider '{name}' credential '{credential_key}'."); + } + Ok(()) +} + +fn provider_refresh_strategy(strategy: &str) -> Result { + match strategy { + "oauth2_refresh_token" => Ok(ProviderCredentialRefreshStrategy::Oauth2RefreshToken), + "oauth2_client_credentials" => { + Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) + } + "google_service_account_jwt" => { + Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) + } + "aws_sts_assume_role" => Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), + _ => Err(miette!("unsupported provider refresh strategy: {strategy}")), + } +} + +fn print_refresh_status_row(status: &ProviderCredentialRefreshStatus) { + println!("{}", refresh_status_row(status)); +} + +fn refresh_status_row(status: &ProviderCredentialRefreshStatus) -> String { + let strategy = ProviderCredentialRefreshStrategy::try_from(status.strategy) + .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); + let recovery_action = ProviderCredentialRefreshRecoveryAction::try_from(status.recovery_action) + .unwrap_or(ProviderCredentialRefreshRecoveryAction::Unspecified); + format!( + "{:<24} {:<28} {:<28} {:<24} {:<18} {:<20} {:<20} {:<20} {:<44} {}", + status.provider_name, + status.credential_key, + provider_refresh_strategy_name(strategy), + status.status, + provider_refresh_recovery_action_name(recovery_action), + format_optional_epoch_ms(status.expires_at_ms), + format_refresh_next_at_ms(status.next_refresh_at_ms), + format_optional_epoch_ms(status.last_refresh_at_ms), + status.failure_code, + truncate_status_field(&status.last_error, 72), + ) +} + +fn format_refresh_next_at_ms(next_refresh_at_ms: i64) -> String { + if next_refresh_at_ms == i64::MAX { + "-".to_string() + } else { + format_optional_epoch_ms(next_refresh_at_ms) + } +} + +fn provider_refresh_recovery_action_name( + action: ProviderCredentialRefreshRecoveryAction, +) -> &'static str { + match action { + ProviderCredentialRefreshRecoveryAction::Retry => "retry", + ProviderCredentialRefreshRecoveryAction::Reauthorize => "reauthorize", + ProviderCredentialRefreshRecoveryAction::FixConfiguration => "fix_configuration", + ProviderCredentialRefreshRecoveryAction::Investigate => "investigate", + ProviderCredentialRefreshRecoveryAction::Unspecified => "-", + } +} + +fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -> &'static str { + match strategy { + ProviderCredentialRefreshStrategy::Static => "static", + ProviderCredentialRefreshStrategy::External => "external", + ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", + ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", + ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", + ProviderCredentialRefreshStrategy::Unspecified => "unspecified", + } +} + +fn load_profile_import_items( + file: Option<&Path>, + from: Option<&Path>, +) -> Result<( + Vec, + Vec, +)> { + let paths = profile_source_paths(file, from)?; + let mut items = Vec::new(); + let mut diagnostics = Vec::new(); + for path in paths { + match load_profile_import_item(&path) { + Ok(item) => items.push(item), + Err(diagnostic) => diagnostics.push(diagnostic), + } + } + Ok((items, diagnostics)) +} + +fn profile_source_paths(file: Option<&Path>, from: Option<&Path>) -> Result> { + if let Some(file) = file { + return Ok(vec![file.to_path_buf()]); + } + let Some(from) = from else { + return Ok(Vec::new()); + }; + let mut paths = Vec::new(); + for entry in std::fs::read_dir(from) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read profile directory {}", from.display()))? + { + let entry = entry.into_diagnostic()?; + let path = entry.path(); + if path.is_file() && profile_extension_supported(&path) { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +fn profile_extension_supported(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("yaml" | "yml" | "json") + ) +} + +fn load_profile_import_item( + path: &Path, +) -> Result { + let source = path.display().to_string(); + let input = std::fs::read_to_string(path).map_err(|err| { + profile_file_diagnostic( + &source, + format!("failed to read provider profile file: {err}"), + ) + })?; + let profile = match path.extension().and_then(|ext| ext.to_str()) { + Some("yaml" | "yml") => parse_profile_yaml(&input), + Some("json") => parse_profile_json(&input), + _ => { + return Err(profile_file_diagnostic( + &source, + "unsupported provider profile file format".to_string(), + )); + } + } + .map_err(|err| profile_file_diagnostic(&source, err.to_string()))?; + + let pre_lower = profile.validate_before_lowering(&source); + if let Some(diag) = pre_lower.into_iter().find(|d| d.severity == "error") { + return Err(ProviderProfileDiagnostic { + source: diag.source, + profile_id: diag.profile_id, + field: diag.field, + message: diag.message, + severity: diag.severity, + }); + } + + Ok(ProviderProfileImportItem { + profile: Some(profile.to_proto()), + source, + }) +} + +fn profile_file_diagnostic(source: &str, message: String) -> ProviderProfileDiagnostic { + ProviderProfileDiagnostic { + source: source.to_string(), + profile_id: String::new(), + field: "file".to_string(), + message, + severity: "error".to_string(), + } +} + +fn print_profile_diagnostics(diagnostics: &[ProviderProfileDiagnostic]) { + if diagnostics.is_empty() { + return; + } + eprintln!("{}", "Provider profile diagnostics:".red().bold()); + for diagnostic in diagnostics { + let source = if diagnostic.source.is_empty() { + "" + } else { + &diagnostic.source + }; + let profile = if diagnostic.profile_id.is_empty() { + "-".to_string() + } else { + diagnostic.profile_id.clone() + }; + eprintln!( + " {} {} profile={} field={} {}", + diagnostic.severity.as_str().red(), + source, + profile, + diagnostic.field, + diagnostic.message + ); + } +} + +fn profile_diagnostics_have_errors(diagnostics: &[ProviderProfileDiagnostic]) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == "error") +} + +fn display_provider_category(category: i32) -> &'static str { + match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) { + ProviderProfileCategory::Inference => "INFERENCE", + ProviderProfileCategory::Agent => "AGENT", + ProviderProfileCategory::SourceControl => "SOURCE CONTROL", + ProviderProfileCategory::Messaging => "MESSAGING", + ProviderProfileCategory::Data => "DATA", + ProviderProfileCategory::Knowledge => "KNOWLEDGE", + ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER", + } +} + +const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32; +const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40; +const PROVIDER_PROFILE_SOURCE_MAX_WIDTH: usize = 24; + +fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| { + profile + .id + .chars() + .count() + .min(PROVIDER_PROFILE_ID_MAX_WIDTH) + }) + .max() + .unwrap_or(2) + .max(2) +} + +fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| { + profile + .display_name + .chars() + .count() + .min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH) + }) + .max() + .unwrap_or(4) + .max(4) +} + +fn provider_profile_scope_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| profile.scope.chars().count()) + .max() + .unwrap_or(5) + .max(5) +} + +fn provider_profile_source_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| { + profile + .source + .chars() + .count() + .min(PROVIDER_PROFILE_SOURCE_MAX_WIDTH) + }) + .max() + .unwrap_or(6) + .max(6) +} + +fn print_provider_type_header( + id_width: usize, + scope_width: usize, + source_width: usize, + display_width: usize, +) { + let endpoints = "ENDPOINTS"; + println!( + " {: { + pub server: &'a str, + pub name: &'a str, + pub from_existing: bool, + pub from_oidc_token: bool, + pub credentials: &'a [String], + pub config: &'a [String], + pub credential_expires_at: &'a [String], + pub workspace: &'a str, + pub tls: &'a TlsOptions, +} + +pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { + let ProviderUpdateOptions { + server, + name, + from_existing, + from_oidc_token, + credentials, + config, + credential_expires_at, + workspace, + tls, + } = options; + + if from_existing && !credentials.is_empty() { + return Err(miette::miette!( + "--from-existing cannot be combined with --credential" + )); + } + if from_existing && from_oidc_token { + return Err(miette::miette!( + "--from-existing cannot be combined with --from-oidc-token" + )); + } + + let mut client = grpc_client(server, tls).await?; + + // Look up the stored provider so the update can carry its type and profile + // workspace. Policy interceptors evaluate the request before the gateway + // merges it with stored state, so an update that omits them cannot be + // authorized against the profile that owns the provider. + // + // The read is best-effort. A caller holding `provider:write` without + // `provider:read` must still be able to rotate credentials, so a denied + // read keeps the previous behavior of sending empty metadata rather than + // failing the update. `--from-existing` and `--from-oidc-token` need the + // stored type, so they surface the error instead. + let existing = match client + .get_provider(GetProviderRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + { + Ok(response) => response.into_inner().provider, + Err(status) + if status.code() == Code::PermissionDenied && !from_existing && !from_oidc_token => + { + None + } + Err(status) => return Err(status).into_diagnostic(), + }; + + if existing.is_none() && (from_existing || from_oidc_token) { + return Err(miette::miette!("provider '{name}' not found")); + } + + let oidc_profile = if from_oidc_token { + let existing = existing.as_ref().expect("checked above"); + Some( + fetch_provider_profile(&mut client, &existing.r#type, &existing.profile_workspace) + .await?, + ) + } else { + None + }; + + let (mut credential_map, oidc_credential_expires_at_ms) = if from_oidc_token { + provider_credential_from_oidc_token(credentials, oidc_profile.as_ref(), tls).await? + } else { + (parse_credential_pairs(credentials)?, HashMap::new()) + }; + let mut config_map = parse_key_value_pairs(config, "--config")?; + let mut credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; + credential_expires_at_ms.extend(oidc_credential_expires_at_ms); + + if from_existing { + let stored = existing.as_ref().expect("checked above"); + let provider_type = stored.r#type.clone(); + let discovered = + discover_existing_provider_data(&mut client, &provider_type, &stored.profile_workspace) + .await?; + let Some(discovered) = discovered else { + return Err(miette::miette!( + "no existing local credentials/config found for provider type '{provider_type}'" + )); + }; + + for (key, value) in discovered.credentials { + credential_map.entry(key).or_insert(value); + } + for (key, value) in discovered.config { + config_map.entry(key).or_insert(value); + } + } + + let response = client + .update_provider(UpdateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: existing + .as_ref() + .map(|provider| provider.r#type.clone()) + .unwrap_or_default(), + credentials: credential_map, + config: config_map, + credential_expires_at_ms: HashMap::new(), + profile_workspace: existing + .as_ref() + .map(|provider| provider.profile_workspace.clone()) + .unwrap_or_default(), + credential_handles: HashMap::new(), + }), + credential_expires_at_ms, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + + println!( + "{} Updated provider {}", + "✓".green().bold(), + provider.object_name() + ); + Ok(()) +} + +pub async fn provider_delete( + server: &str, + names: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let mut failures = Vec::new(); + for name in names { + let response = match client + .delete_provider(DeleteProviderRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) + .await + { + Ok(response) => response, + Err(status) => { + eprintln!( + "{} Failed to delete provider {name}: {status}", + "!".red().bold() + ); + failures.push(name.clone()); + continue; + } + }; + if response.into_inner().deleted { + println!("{} Deleted provider {name}", "✓".green().bold()); + } else { + println!("{} Provider {name} not found", "!".yellow()); + } + } + aggregate_delete_failures("provider", &failures) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TEST_ENV_LOCK; + use crate::test_utils::EnvVarGuard; + use std::fs; + use std::io::Write; + + use openshell_core::proto::{ + CredentialHandle, Provider, ProviderCredentialRefresh, + ProviderCredentialRefreshRecoveryAction, ProviderCredentialRefreshStatus, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, + ProviderProfileCredential, datamodel::v1::ObjectMeta, + }; + + #[test] + fn attached_provider_json_is_sorted_and_secret_safe() { + let provider = Provider { + metadata: Some(ObjectMeta { + name: "github".to_string(), + ..Default::default() + }), + r#type: "github".to_string(), + credentials: HashMap::from([ + ("Z_TOKEN".to_string(), "inline-secret".to_string()), + ("SHARED".to_string(), "shared-secret".to_string()), + ]), + config: HashMap::from([ + ("Z_URL".to_string(), "https://secret.example".to_string()), + ("A_MODE".to_string(), "sensitive-config".to_string()), + ]), + credential_expires_at_ms: HashMap::from([("Z_TOKEN".to_string(), 123)]), + profile_workspace: "internal".to_string(), + credential_handles: HashMap::from([ + ( + "A_HANDLE".to_string(), + CredentialHandle { + driver: "vault".to_string(), + handle: "opaque-secret-handle".to_string(), + metadata: HashMap::from([( + "internal".to_string(), + "metadata-value".to_string(), + )]), + }, + ), + ("SHARED".to_string(), CredentialHandle::default()), + ]), + }; + + let value = attached_provider_to_json(&provider); + assert_eq!( + value, + serde_json::json!({ + "name": "github", + "type": "github", + "credential_keys": ["A_HANDLE", "SHARED", "Z_TOKEN"], + "config_keys": ["A_MODE", "Z_URL"], + }) + ); + let serialized = value.to_string(); + for secret in [ + "inline-secret", + "shared-secret", + "https://secret.example", + "sensitive-config", + "opaque-secret-handle", + "metadata-value", + "internal", + "123", + ] { + assert!(!serialized.contains(secret), "leaked {secret}"); + } + } + + #[test] + fn provider_attachment_table_formats_provider_counts() { + let output = format_provider_attachment_table( + &[Provider { + metadata: Some(ObjectMeta { + name: "work-custom".to_string(), + ..Default::default() + }), + r#type: "custom-api".to_string(), + credentials: [ + ("CUSTOM_API_KEY".to_string(), "REDACTED".to_string()), + ("CUSTOM_API_SECRET".to_string(), "REDACTED".to_string()), + ] + .into_iter() + .collect(), + config: std::iter::once(( + "BASE_URL".to_string(), + "https://api.custom.example".to_string(), + )) + .collect(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }], + false, + ); + + assert!(output.contains("NAME")); + assert!(output.contains("TYPE")); + assert!(output.contains("CREDENTIAL_KEYS")); + assert!(output.contains("CONFIG_KEYS")); + assert!(output.contains("work-custom")); + assert!(output.contains("custom-api")); + assert!(output.contains('2')); + assert!(output.contains('1')); + } + + #[test] + fn refresh_status_table_includes_operational_fields() { + let header = refresh_status_header(); + assert!(header.contains("NEXT_REFRESH")); + assert!(header.contains("LAST_REFRESH")); + assert!(header.contains("RECOVERY")); + assert!(header.contains("FAILURE_CODE")); + assert!(header.contains("LAST_ERROR")); + + let row = refresh_status_row(&ProviderCredentialRefreshStatus { + provider_name: "my-graph".to_string(), + provider_id: "provider-id".to_string(), + credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + status: "error".to_string(), + expires_at_ms: 1_767_225_600_000, + next_refresh_at_ms: i64::MAX, + last_refresh_at_ms: 1_767_225_000_000, + last_error: "token endpoint returned a very long error message that should be truncated for table readability" + .to_string(), + recovery_action: ProviderCredentialRefreshRecoveryAction::Reauthorize as i32, + failure_code: "oauth_rotated_refresh_token_handle_missing".to_string(), + provider_error_subtype: "invalid_rapt".to_string(), + last_error_at_ms: 1_767_225_000_000, + }); + + assert!(row.contains("my-graph")); + assert!(row.contains("MS_GRAPH_ACCESS_TOKEN")); + assert!(row.contains("oauth2_client_credentials")); + assert!(row.contains("error")); + assert!(row.contains("reauthorize")); + assert!(row.contains("oauth_rotated_refresh_token_handle_missing")); + assert!(row.contains("2026-01-01 00:00:00")); + assert!(!row.contains("292278994")); + assert!(row.contains("...")); + } + + #[test] + fn empty_provider_credentials_require_all_required_credentials_to_be_runtime_resolvable() { + let refresh_token_profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + name: "MS_GRAPH_ACCESS_TOKEN".to_string(), + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!(provider_profile_allows_empty_credentials( + &refresh_token_profile + )); + + let token_grant_profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + name: "ACCESS_TOKEN".to_string(), + required: true, + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: "https://auth.example.com/token".to_string(), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!(provider_profile_allows_empty_credentials( + &token_grant_profile + )); + + let mixed_static_profile = ProviderProfile { + credentials: vec![ + ProviderProfileCredential { + name: "ACCESS_TOKEN".to_string(), + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + ..Default::default() + }), + ..Default::default() + }, + ProviderProfileCredential { + name: "STATIC_API_KEY".to_string(), + required: true, + refresh: None, + ..Default::default() + }, + ], + ..Default::default() + }; + assert!(!provider_profile_allows_empty_credentials( + &mixed_static_profile + )); + + let optional_refresh_profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + name: "OPTIONAL_TOKEN".to_string(), + required: false, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!(provider_profile_allows_empty_credentials( + &optional_refresh_profile + )); + } + + #[test] + fn inferred_provider_type_returns_type_for_known_command() { + let result = inferred_provider_type(&["claude".to_string(), "--help".to_string()]); + assert_eq!(result, Some("claude-code".to_string())); + } + + #[test] + fn inferred_provider_type_returns_none_for_unknown_command() { + let result = inferred_provider_type(&["bash".to_string()]); + assert_eq!(result, None); + } + + #[test] + fn inferred_provider_type_returns_none_for_empty_command() { + let result = inferred_provider_type(&[]); + assert_eq!(result, None); + } + + #[test] + fn inferred_provider_type_normalizes_aliases() { + // Retired legacy types are not inferred, even when a custom profile + // with the same ID could be imported and attached explicitly. + let result = inferred_provider_type(&["glab".to_string()]); + assert_eq!(result, None); + + // `gh` should resolve to `github` + let result = inferred_provider_type(&["gh".to_string()]); + assert_eq!(result, Some("github".to_string())); + } + + #[test] + fn inferred_provider_type_handles_full_path() { + let result = inferred_provider_type(&["/usr/local/bin/claude".to_string()]); + assert_eq!(result, Some("claude-code".to_string())); + } + + #[test] + fn read_gcloud_adc_missing_file_errors() { + 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}" + ); + } + + #[test] + fn read_gcloud_adc_wrong_type_errors() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + let json = serde_json::json!({ + "type": "service_account", + "project_id": "my-project", + "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}" + ); + } + + #[test] + fn read_gcloud_adc_parses_user_creds() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + let json = serde_json::json!({ + "type": "authorized_user", + "client_id": "test-client-id.apps.googleusercontent.com", + "client_secret": "test-client-secret", + "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"); + } + + #[test] + fn read_gcloud_adc_uses_cloudsdk_config_fallback() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = tempfile::tempdir().expect("tempdir"); + let adc_path = dir.path().join("application_default_credentials.json"); + let json = serde_json::json!({ + "type": "authorized_user", + "client_id": "cloudsdk-client-id.apps.googleusercontent.com", + "client_secret": "cloudsdk-client-secret", + "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"); + } + + #[test] + fn read_gcloud_adc_malformed_json_errors() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + 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}" + ); + } + + #[test] + fn empty_provider_credentials_allow_oauth2_refresh_token() { + use openshell_core::proto::{ + ProviderCredentialRefresh, ProviderCredentialRefreshStrategy, ProviderProfile, + ProviderProfileCredential, + }; + + let strategy = ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32; + let profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!( + provider_profile_allows_empty_credentials(&profile), + "Oauth2RefreshToken should be allowed for refresh bootstrap" + ); + } + + #[test] + fn provider_to_json_includes_core_fields() { + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + ..Default::default() + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert_eq!(json["id"], "prov-123"); + assert_eq!(json["name"], "test-provider"); + assert_eq!(json["workspace"], ""); + assert_eq!(json["type"], "anthropic"); + } + + #[test] + fn provider_to_json_exposes_credential_keys_not_values() { + let mut credentials = HashMap::new(); + credentials.insert("ANTHROPIC_API_KEY".to_string(), "secret-value".to_string()); + credentials.insert("OTHER_KEY".to_string(), "other-secret".to_string()); + + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "anthropic".to_string(), + credentials, + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + let json_str = json.to_string(); + + // Assert credential keys are present + let keys = json["credential_keys"].as_array().unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().any(|k| k.as_str() == Some("ANTHROPIC_API_KEY"))); + assert!(keys.iter().any(|k| k.as_str() == Some("OTHER_KEY"))); + + // Assert credential values are NOT in the output (SECURITY) + assert!( + !json_str.contains("secret-value"), + "credential values must not be exposed" + ); + assert!( + !json_str.contains("other-secret"), + "credential values must not be exposed" + ); + } + + #[test] + fn provider_to_json_exposes_config_keys_not_values() { + let mut config = HashMap::new(); + config.insert("region".to_string(), "us-west".to_string()); + config.insert( + "endpoint".to_string(), + "https://api.example.com".to_string(), + ); + + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "custom".to_string(), + credentials: HashMap::new(), + config, + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + let json_str = json.to_string(); + + // Assert config keys are present + let keys = json["config_keys"].as_array().unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().any(|k| k.as_str() == Some("region"))); + assert!(keys.iter().any(|k| k.as_str() == Some("endpoint"))); + + // Assert config values are NOT in the output (SECURITY) + assert!( + !json_str.contains("us-west"), + "config values must not be exposed" + ); + assert!( + !json_str.contains("https://api.example.com"), + "config values must not be exposed" + ); + } + + #[test] + fn provider_to_json_omits_empty_config() { + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), // Empty config + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert!( + json.get("config_keys").is_none(), + "empty config_keys should be omitted" + ); + } + + #[test] + fn provider_to_json_includes_metadata_fields_when_present() { + let mut labels = HashMap::new(); + labels.insert("env".to_string(), "prod".to_string()); + + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + resource_version: 42, + created_at_ms: 1_234_567_890_000, + labels, + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert_eq!(json["resource_version"], 42); + assert_eq!(json["created_at"], "2009-02-13 23:31:30"); + assert_eq!(json["labels"]["env"], "prod"); + } + + #[test] + fn provider_to_json_omits_zero_metadata_fields() { + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + // resource_version and created_at_ms are 0 + // labels is empty + ..Default::default() + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert!( + json.get("resource_version").is_none(), + "zero resource_version should be omitted" + ); + assert!( + json.get("created_at").is_none(), + "zero created_at should be omitted" + ); + assert!( + json.get("labels").is_none(), + "empty labels should be omitted" + ); + } + + #[test] + fn provider_to_json_includes_credential_expiration() { + let mut credential_expires_at_ms = HashMap::new(); + credential_expires_at_ms.insert("ACCESS_TOKEN".to_string(), 1_234_567_890); + + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "oauth".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms, + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert_eq!( + json["credential_expires_at_ms"]["ACCESS_TOKEN"], + 1_234_567_890 + ); + } + + #[test] + fn provider_to_json_formats_created_at_as_human_readable() { + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + created_at_ms: 1_609_459_200_000, // 2021-01-01 00:00:00 + ..Default::default() + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + // Should format as human-readable datetime, not raw milliseconds + assert_eq!(json["created_at"], "2021-01-01 00:00:00"); + assert!( + json.get("created_at_ms").is_none(), + "raw milliseconds field should not exist" + ); + } +} diff --git a/crates/openshell-cli/src/edge_tunnel.rs b/crates/openshell-cli/src/edge_tunnel.rs index 814e245f3c..e9b1a92668 100644 --- a/crates/openshell-cli/src/edge_tunnel.rs +++ b/crates/openshell-cli/src/edge_tunnel.rs @@ -26,6 +26,7 @@ use futures::stream::{SplitSink, SplitStream}; use futures::{SinkExt, StreamExt}; use miette::{IntoDiagnostic, Result}; +use openshell_core::net::set_tcp_nodelay_best_effort; use std::net::SocketAddr; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -101,6 +102,7 @@ async fn accept_loop(listener: TcpListener, config: Arc) { match listener.accept().await { Ok((stream, peer)) => { debug!(peer = %peer, "accepted local tunnel connection"); + set_tcp_nodelay_best_effort(&stream); let config = Arc::clone(&config); tokio::spawn(async move { if let Err(e) = handle_connection(stream, &config).await { @@ -174,6 +176,20 @@ async fn open_ws(config: &TunnelConfig) -> Result Some(tcp), + MaybeTlsStream::Rustls(tls) => Some(tls.get_ref().0), + // `MaybeTlsStream` is #[non_exhaustive]; surface any future/unknown + // variant so a silent TCP_NODELAY miss doesn't go unnoticed. + _ => { + debug!("edge tunnel: unrecognized MaybeTlsStream variant; skipping TCP_NODELAY"); + None + } + }; + if let Some(tcp) = tcp { + set_tcp_nodelay_best_effort(tcp); + } + debug!( status = %response.status(), "WebSocket connected to edge" diff --git a/crates/openshell-cli/src/lib.rs b/crates/openshell-cli/src/lib.rs index e28b84b05d..910920f42a 100644 --- a/crates/openshell-cli/src/lib.rs +++ b/crates/openshell-cli/src/lib.rs @@ -11,6 +11,7 @@ pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(() pub(crate) mod test_utils; pub mod auth; +pub mod color; pub(crate) mod commands; pub mod completers; pub mod edge_tunnel; diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index b541d350ec..befac54759 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -7,7 +7,7 @@ use clap::{CommandFactory, Parser, Subcommand, ValueEnum, ValueHint}; use clap_complete::engine::ArgValueCompleter; use clap_complete::env::CompleteEnv; use miette::Result; -use owo_colors::OwoColorize; +use openshell_cli::color::{self, ColorChoice, Colorize}; use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; @@ -368,10 +368,9 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell settings get my-sandbox $ openshell settings get --global - $ openshell settings set --global --key providers_v2_enabled --value true $ openshell settings set my-sandbox --key ocsf_json_enabled --value true $ openshell settings set --global --key ocsf_json_enabled --value true - $ openshell settings delete --global --key providers_v2_enabled + $ openshell settings delete --global --key ocsf_json_enabled "; const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m @@ -469,6 +468,18 @@ struct Cli { #[arg(short, long, action = clap::ArgAction::Count, global = true, help_heading = "GLOBAL FLAGS")] verbose: u8, + /// When to colorize output. `auto` colorizes only when stdout is a terminal. + #[arg( + long, + global = true, + value_enum, + default_value_t = ColorChoice::Auto, + value_name = "WHEN", + env = "OPENSHELL_COLOR", + help_heading = "GLOBAL FLAGS" + )] + color: ColorChoice, + /// Print help. #[arg(short = 'h', long, action = clap::ArgAction::Help, global = true, help_heading = "GLOBAL FLAGS")] help: (), @@ -742,7 +753,7 @@ fn normalize_completion_script(output: Vec, executable: &std::path::Path) -> Ok(script.replace(executable.to_string_lossy().as_ref(), "openshell")) } -#[derive(Clone, Debug, ValueEnum)] +#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)] enum OutputFormat { Table, Yaml, @@ -811,7 +822,7 @@ impl From for openshell_cli::ssh::Editor { #[derive(Subcommand, Debug)] enum ProviderCommands { /// Create a provider config. - #[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(group = clap::ArgGroup::new("cred_source").required(false).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Create { /// Provider name. #[arg(long)] @@ -822,7 +833,7 @@ enum ProviderCommands { provider_type: String, /// Load provider credentials/config from existing local state. - #[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "runtime_credentials"])] + #[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"])] from_existing: bool, /// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`). @@ -836,11 +847,15 @@ enum ProviderCommands { /// Configure credentials from gcloud Application Default Credentials /// (`~/.config/gcloud/application_default_credentials.json`). /// Valid for providers whose profile declares an ADC-compatible credential. - #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials"])] + #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials", "from_oidc_token"])] from_gcloud_adc: bool, + /// Store the active gateway OIDC access token as the named provider credential. + #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "from_gcloud_adc", "runtime_credentials"])] + from_oidc_token: bool, + /// Create a provider whose required credentials are resolved at runtime by the gateway/sandbox. - #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc"])] + #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc", "from_oidc_token"])] runtime_credentials: bool, /// Provider config key/value pair. @@ -913,9 +928,13 @@ enum ProviderCommands { name: String, /// Re-discover credentials from existing local state (e.g. env vars, config files). - #[arg(long, conflicts_with = "credentials")] + #[arg(long, conflicts_with_all = ["credentials", "from_oidc_token"])] from_existing: bool, + /// Store the active gateway OIDC access token as the named provider credential. + #[arg(long, conflicts_with = "from_existing")] + from_oidc_token: bool, + /// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`). #[arg( long = "credential", @@ -1085,8 +1104,9 @@ enum ProviderProfileCommands { /// Delete a custom provider profile. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Delete { - /// Provider profile id. - id: String, + /// Provider profile id(s). + #[arg(required = true, num_args = 1.., value_name = "ID")] + ids: Vec, /// Target platform-scoped profile (ignores --workspace). #[arg(long)] @@ -1172,7 +1192,8 @@ enum GatewayCommands { /// Authenticate with an edge-authenticated or OIDC gateway. /// /// Opens a browser for the edge proxy's login flow and stores the - /// token locally. Use this to re-authenticate when a token expires. + /// token locally. After `gateway logout`, OIDC browser login requests a + /// fresh identity-provider prompt so you can switch users. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Login { /// Gateway name (defaults to the active gateway). @@ -1183,7 +1204,9 @@ enum GatewayCommands { /// Clear stored authentication credentials for a gateway. /// /// Removes the locally stored OIDC token or edge token so subsequent - /// commands require re-authentication via `gateway login`. + /// commands require re-authentication via `gateway login`. For OIDC + /// gateways, the next browser login asks the identity provider for a fresh + /// login instead of silently reusing an existing browser session. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Logout { /// Gateway name (defaults to the active gateway). @@ -1328,6 +1351,10 @@ enum SandboxCommands { #[arg(long, add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, + /// Create the sandbox from a named sandbox template. + #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] + template: Option, + /// Sandbox source: a community sandbox name (e.g., `ollama`), a path /// to a Dockerfile or directory containing one, or a full container /// image reference (e.g., `myregistry.com/img:tag`). @@ -1348,7 +1375,12 @@ enum SandboxCommands { /// working directory. /// `.gitignore` rules are applied by default; use `--no-git-ignore` to /// upload everything. - #[arg(long, value_hint = ValueHint::AnyPath, help_heading = "UPLOAD FLAGS")] + #[arg( + long, + value_hint = ValueHint::AnyPath, + help_heading = "UPLOAD FLAGS", + conflicts_with = "command" + )] upload: Vec, /// Disable `.gitignore` filtering for `--upload`. @@ -1391,7 +1423,9 @@ enum SandboxCommands { #[arg(long, value_name = "JSON")] driver_config_json: Option, - /// Provider names to attach to this sandbox. + /// Attach a configured credential provider to the sandbox. + /// Use providers for API keys, tokens, and other secrets so commands in + /// the sandbox do not receive the real credential values. Repeatable. #[arg(long = "provider")] providers: Vec, @@ -1416,6 +1450,10 @@ enum SandboxCommands { #[arg(long, overrides_with = "tty")] no_tty: bool, + /// Start the canonical main process without attaching to it. + #[arg(long, conflicts_with_all = ["editor", "no_keep"])] + detach: bool, + /// Auto-create missing providers from local credentials. /// /// Without this flag, an interactive prompt asks per-provider; @@ -1431,10 +1469,16 @@ enum SandboxCommands { #[arg(long = "label")] labels: Vec, - /// Environment variables to inject into the sandbox (KEY=VALUE format, repeatable). + /// Set a non-secret environment variable in the sandbox. + /// Do not use this option for API keys, tokens, or other secrets; create + /// a provider and attach it with `--provider` instead. Repeatable. #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, + /// Suppress warnings when --env values look like credentials. + #[arg(long = "no-credential-warnings")] + no_credential_warnings: bool, + /// Approval mode for agent-authored policy proposals. /// /// `manual` (default): every proposal lands in the draft inbox for @@ -1516,6 +1560,22 @@ enum SandboxCommands { all: bool, }, + /// Stop a sandbox while preserving its workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Stop { + /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] + name: Option, + }, + + /// Start a stopped sandbox. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Start { + /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] + name: Option, + }, + /// Execute a command in a running sandbox. /// /// Runs a command inside an existing sandbox using the gRPC exec endpoint. @@ -1553,7 +1613,18 @@ enum SandboxCommands { #[arg(long, overrides_with = "tty")] no_tty: bool, - /// Environment variables to set for the command (KEY=VALUE format, repeatable). + /// Run the command without sourcing shell login/profile startup files. + /// + /// Default sources them so tool-specific env (`VIRTUAL_ENV`, etc.) is + /// available. Use this for automation and managed checks that need + /// predictable startup behavior — sandbox-user startup files cannot run + /// before the requested command. + #[arg(long)] + no_login_shell: bool, + + /// Set a non-secret environment variable for the command. + /// Do not use this option for API keys, tokens, or other secrets; attach + /// a provider to the sandbox instead. Repeatable. #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, @@ -1565,6 +1636,7 @@ enum SandboxCommands { /// Connect to a sandbox. /// /// When no name is given, reconnects to the last-used sandbox. + /// Press Ctrl-P Ctrl-Q to disconnect without terminating the main process. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Connect { /// Sandbox name (defaults to last-used sandbox). @@ -1625,6 +1697,10 @@ enum SandboxCommands { /// Manage providers attached to a sandbox. #[command(subcommand)] Provider(SandboxProviderCommands), + + /// Manage reusable sandbox workload templates. + #[command(subcommand)] + Template(SandboxTemplateCommands), } #[derive(Subcommand, Debug)] @@ -1635,6 +1711,10 @@ enum SandboxProviderCommands { /// Sandbox name (defaults to last-used sandbox). #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// Attach a provider to a sandbox. @@ -1662,6 +1742,121 @@ enum SandboxProviderCommands { }, } +#[derive(Subcommand, Debug)] +// `Create` carries several optional strings and repeated key-value flags. This +// enum is only used for clap parsing, so boxing fields would add friction +// without a meaningful runtime win. +#[allow(clippy::large_enum_variant)] +enum SandboxTemplateCommands { + /// Create a reusable sandbox workload template. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Create { + /// Template name. + name: String, + + /// Container image for sandboxes created from this template. + /// When omitted, the gateway's default sandbox image is used at create time. + #[arg(long)] + image: Option, + + /// CPU limit for sandboxes created from this template (for example: 500m, 1, 2.5). + #[arg(long)] + cpu: Option, + + /// Memory limit for sandboxes created from this template (for example: 512Mi, 4Gi, 8G). + #[arg(long)] + memory: Option, + + /// Request GPU resources for sandboxes created from this template. + /// + /// Omit COUNT for the driver's default GPU selection, or pass COUNT + /// to request a specific number of GPUs. + #[arg(long, num_args = 0..=1, value_name = "COUNT", default_missing_value = "", value_parser = parse_gpu_request)] + gpu: Option, + + /// Experimental driver-keyed JSON object for driver-specific sandbox settings. + #[arg(long, value_name = "JSON")] + driver_config_json: Option, + + /// Target startup readiness duration for this template (for example: 30s, 5m, 1h). + #[arg(long, value_name = "DURATION")] + ready_within: Option, + + /// Maximum startup burst associated with this template. + #[arg(long, value_name = "COUNT", value_parser = clap::value_parser!(u32).range(1..))] + max_burst: Option, + + /// Attach labels to the template (key=value format, repeatable). + #[arg(long = "label", value_name = "KEY=VALUE")] + labels: Vec, + + /// Attach annotations to the template (key=value format, repeatable). + #[arg(long = "annotation", value_name = "KEY=VALUE")] + annotations: Vec, + + /// Set a non-secret environment variable in sandboxes created from this template. + /// Do not use this option for API keys, tokens, or other secrets; create + /// a provider and attach it when creating sandboxes instead. Repeatable. + #[arg(long = "env", value_name = "KEY=VALUE")] + envs: Vec, + + /// Suppress warnings when --env values look like credentials. + #[arg(long = "no-credential-warnings")] + no_credential_warnings: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// Fetch a sandbox workload template by name. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Get { + /// Template name. + name: String, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// List sandbox workload templates. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Maximum number of templates to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the template list. + #[arg(long, default_value_t = 0)] + offset: u32, + + /// Filter templates by labels, e.g. env=prod,team=runtime. + #[arg(long)] + label_selector: Option, + + /// Print only template names (one per line). + #[arg(long, conflicts_with = "output")] + names: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with = "names")] + output: OutputFormat, + + /// List templates across all workspaces (overrides --workspace). + #[arg(long)] + all_workspaces: bool, + }, + + /// Delete sandbox workload templates. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Template names. + #[arg(required = true, num_args = 1.., value_name = "NAME")] + names: Vec, + }, +} + #[derive(Subcommand, Debug)] enum DraftCommands { /// Show network rules for a sandbox. @@ -1765,6 +1960,8 @@ enum PolicyCommands { name: Option, /// Add or merge an endpoint: host:port[:access[:protocol[:enforcement[:options]]]]. + /// Options include allowed-ip=..., credential rewrite flags, and + /// allow-uninspected-credentials. #[arg(long = "add-endpoint")] add_endpoints: Vec, @@ -1847,6 +2044,10 @@ enum PolicyCommands { /// List global policy revisions. #[arg(long)] global: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// Delete the gateway-global policy lock, restoring sandbox-level policy control. @@ -1953,9 +2154,13 @@ enum ForwardCommands { name: Option, }, - /// List active port forwards. + /// List tracked port forwards. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] - List, + List { + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, /// Forward a local TCP port to a loopback service inside a sandbox over gRPC. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] @@ -2013,6 +2218,10 @@ enum ServiceCommands { /// List services across all workspaces (overrides --workspace). #[arg(long)] all_workspaces: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// Show one exposed sandbox service endpoint. @@ -2137,12 +2346,41 @@ enum WorkspaceMemberCommands { /// Offset into the member list. #[arg(long, default_value_t = 0)] offset: u32, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, } +#[cfg(target_os = "windows")] +fn main() -> Result<()> { + std::thread::Builder::new() + .name("openshell-main".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(run_main) + .map_err(|err| miette::miette!("failed to start OpenShell main thread: {err}"))? + .join() + .map_err(|_| miette::miette!("OpenShell main thread panicked"))? +} + +#[cfg(not(target_os = "windows"))] #[tokio::main] -#[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level. async fn main() -> Result<()> { + run_async().await +} + +#[cfg(target_os = "windows")] +fn run_main() -> Result<()> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|err| miette::miette!("failed to build Tokio runtime: {err}"))? + .block_on(run_async()) +} + +#[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; run on an expanded Windows stack. +async fn run_async() -> Result<()> { // Install the rustls crypto provider before completion runs — completers may // establish TLS connections to the gateway. rustls::crypto::ring::default_provider() @@ -2152,6 +2390,11 @@ async fn main() -> Result<()> { CompleteEnv::with_factory(Cli::command).complete(); let cli = Cli::parse(); + + // Resolve colorization before anything is printed, so no command can emit + // escape sequences into a pipe. + color::init(cli.color); + let mut tls = TlsOptions::default(); tls.gateway_insecure = cli.gateway_insecure; @@ -2163,7 +2406,11 @@ async fn main() -> Result<()> { _ => "trace", }; + // The fmt formatter defaults to ANSI on with no terminal detection, and it + // writes to stdout, so without this `openshell -v ... | ...` pipes escapes + // to the caller. tracing_subscriber::fmt() + .with_ansi(color::stdout_enabled()) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level)), @@ -2362,8 +2609,23 @@ async fn main() -> Result<()> { ); } } - ForwardCommands::List => { + ForwardCommands::List { output } => { let forwards = run::list_forwards()?; + if openshell_cli::output::print_output_collection( + output.as_str(), + &forwards, + |forward| { + serde_json::json!({ + "sandbox": forward.sandbox_name, + "bind_address": forward.bind_addr, + "port": forward.port, + "pid": forward.pid, + "alive": forward.validated_alive, + }) + }, + )? { + return Ok(()); + } if forwards.is_empty() { eprintln!("No active forwards."); } else { @@ -2491,6 +2753,7 @@ async fn main() -> Result<()> { limit, offset, all_workspaces, + output, } => { run::service_list( &ctx.endpoint, @@ -2499,6 +2762,7 @@ async fn main() -> Result<()> { offset, &cli.workspace, all_workspaces, + output.as_str(), &tls, ) .await?; @@ -2662,14 +2926,28 @@ async fn main() -> Result<()> { name, limit, global, + output, } => { if global { - run::sandbox_policy_list_global(&ctx.endpoint, limit, &cli.workspace, &tls) - .await?; + run::sandbox_policy_list_global( + &ctx.endpoint, + limit, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; - run::sandbox_policy_list(&ctx.endpoint, &name, limit, &cli.workspace, &tls) - .await?; + run::sandbox_policy_list( + &ctx.endpoint, + &name, + limit, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; } } PolicyCommands::Delete { global, yes } => { @@ -2909,6 +3187,7 @@ async fn main() -> Result<()> { match command { SandboxCommands::Create { name, + template, from, upload, no_git_ignore, @@ -2924,10 +3203,12 @@ async fn main() -> Result<()> { forward, tty, no_tty, + detach, auto_providers, no_auto_providers, labels, envs, + no_credential_warnings, approval_mode, output, command, @@ -2965,6 +3246,7 @@ async fn main() -> Result<()> { // Parse --env flags into a HashMap. let env_map = run::parse_env_pairs(&envs)?; + run::warn_credential_env_vars(&env_map, no_credential_warnings); // Parse --upload specs into [(local_path, sandbox_path, git_ignore)]. let upload_specs: Vec<(String, Option, bool)> = upload @@ -2994,11 +3276,12 @@ async fn main() -> Result<()> { let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); - Box::pin(run::sandbox_create( + let exit_code = Box::pin(run::sandbox_create( endpoint, &ctx.name, run::SandboxCreateConfig { name: name.as_deref(), + template: template.as_deref(), from: from.as_deref(), uploads: &upload_specs, keep, @@ -3017,11 +3300,15 @@ async fn main() -> Result<()> { environment: env_map, approval_mode: &approval_mode, output: output.as_str(), + detach, }, &cli.workspace, &tls, )) .await?; + if exit_code != 0 { + std::process::exit(exit_code); + } } SandboxCommands::Upload { name, @@ -3126,6 +3413,14 @@ async fn main() -> Result<()> { ) .await?; } + SandboxCommands::Stop { name } => { + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_stop(endpoint, &name, &cli.workspace, &tls).await?; + } + SandboxCommands::Start { name } => { + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_start(endpoint, &name, &cli.workspace, &tls).await?; + } SandboxCommands::Connect { name, editor } => { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; if let Some(editor) = editor.map(Into::into) { @@ -3139,7 +3434,12 @@ async fn main() -> Result<()> { ) .await?; } else { - run::sandbox_connect(endpoint, &name, &tls, &cli.workspace).await?; + let exit_code = + run::sandbox_connect(endpoint, &name, &tls, &cli.workspace) + .await?; + if exit_code != 0 { + std::process::exit(exit_code); + } } let _ = save_last_sandbox(&ctx.name, &cli.workspace, &name); } @@ -3151,6 +3451,7 @@ async fn main() -> Result<()> { no_tty, envs, command, + no_login_shell, } => { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; // Resolve --tty / --no-tty into an Option override. @@ -3170,6 +3471,7 @@ async fn main() -> Result<()> { timeout, tty_override, &env_map, + no_login_shell, &tls, &cli.workspace, ) @@ -3184,10 +3486,16 @@ async fn main() -> Result<()> { run::print_ssh_config(&ctx.name, &name, &cli.workspace); } SandboxCommands::Provider(command) => match command { - SandboxProviderCommands::List { name } => { + SandboxProviderCommands::List { name, output } => { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; - run::sandbox_provider_list(endpoint, &name, &cli.workspace, &tls) - .await?; + run::sandbox_provider_list( + endpoint, + &name, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; } SandboxProviderCommands::Attach { name, provider } => { run::sandbox_provider_attach( @@ -3210,6 +3518,89 @@ async fn main() -> Result<()> { .await?; } }, + SandboxCommands::Template(command) => match command { + SandboxTemplateCommands::Create { + name, + image, + cpu, + memory, + gpu, + driver_config_json, + ready_within, + max_burst, + labels, + annotations, + envs, + no_credential_warnings, + output, + } => { + let labels = run::parse_key_value_pairs(&labels, "--label")?; + let annotations = + run::parse_key_value_pairs(&annotations, "--annotation")?; + let environment = run::parse_env_pairs(&envs)?; + run::warn_credential_env_vars(&environment, no_credential_warnings); + let gpu_requirements: Option = + gpu.map(Into::into); + run::sandbox_template_create( + endpoint, + &name, + image.as_deref(), + cpu.as_deref(), + memory.as_deref(), + gpu_requirements, + driver_config_json.as_deref(), + ready_within.as_deref(), + max_burst, + labels, + annotations, + environment, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; + } + SandboxTemplateCommands::Get { name, output } => { + run::sandbox_template_get( + endpoint, + &name, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; + } + SandboxTemplateCommands::List { + limit, + offset, + label_selector, + names, + output, + all_workspaces, + } => { + run::sandbox_template_list( + endpoint, + limit, + offset, + label_selector.as_deref(), + names, + output.as_str(), + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; + } + SandboxTemplateCommands::Delete { names } => { + run::sandbox_template_delete( + endpoint, + &names, + &cli.workspace, + &tls, + ) + .await?; + } + }, } } } @@ -3268,9 +3659,17 @@ async fn main() -> Result<()> { workspace, limit, offset, + output, } => { - run::workspace_member_list(endpoint, &workspace, limit, offset, &tls) - .await?; + run::workspace_member_list( + endpoint, + &workspace, + limit, + offset, + output.as_str(), + &tls, + ) + .await?; } }, } @@ -3291,24 +3690,34 @@ async fn main() -> Result<()> { from_existing, credentials, from_gcloud_adc, + from_oidc_token, runtime_credentials, config, global_profile, } => { let profile_ws = if global_profile { "" } else { &cli.workspace }; - run::provider_create_with_options( - endpoint, - &name, - provider_type.as_str(), - from_existing, - &credentials, - from_gcloud_adc, - runtime_credentials, - &config, - &cli.workspace, - profile_ws, - &tls, - ) + let credential_source = if from_existing { + run::ProviderCreateCredentialSource::Existing + } else if from_gcloud_adc { + run::ProviderCreateCredentialSource::GcloudAdc + } else if from_oidc_token { + run::ProviderCreateCredentialSource::OidcToken + } else if runtime_credentials { + run::ProviderCreateCredentialSource::Runtime + } else { + run::ProviderCreateCredentialSource::ExplicitCredentials + }; + run::provider_create_with_options(run::ProviderCreateOptions { + server: endpoint, + name: &name, + provider_type: provider_type.as_str(), + credentials: &credentials, + credential_source, + config: &config, + workspace: &cli.workspace, + profile_workspace: profile_ws, + tls: &tls, + }) .await?; } ProviderCommands::Refresh(command) => match command { @@ -3447,10 +3856,10 @@ async fn main() -> Result<()> { ) .await?; } - ProviderProfileCommands::Delete { id, global } => { + ProviderProfileCommands::Delete { ids, global } => { run::provider_profile_delete( endpoint, - &id, + &ids, profile_workspace(global), &tls, ) @@ -3461,20 +3870,22 @@ async fn main() -> Result<()> { ProviderCommands::Update { name, from_existing, + from_oidc_token, credentials, config, credential_expires_at, } => { - run::provider_update( - endpoint, - &name, + run::provider_update(run::ProviderUpdateOptions { + server: endpoint, + name: &name, from_existing, - &credentials, - &config, - &credential_expires_at, - &cli.workspace, - &tls, - ) + from_oidc_token, + credentials: &credentials, + config: &config, + credential_expires_at: &credential_expires_at, + workspace: &cli.workspace, + tls: &tls, + }) .await?; } ProviderCommands::Delete { names } => { @@ -4396,20 +4807,172 @@ mod tests { }) if id == "custom-api" )); - let delete = - Cli::try_parse_from(["openshell", "provider", "profile", "delete", "custom-api"]) - .expect("provider profile delete should parse"); + let delete = Cli::try_parse_from([ + "openshell", + "provider", + "profile", + "delete", + "custom-api", + "custom-alt", + ]) + .expect("provider profile delete should parse"); assert!(matches!( delete.command, Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - id, + ids, .. })) - }) if id == "custom-api" + }) if ids == vec!["custom-api".to_string(), "custom-alt".to_string()] + )); + } + + #[test] + fn forward_list_default_output_is_table() { + let cli = Cli::try_parse_from(["openshell", "forward", "list"]) + .expect("forward list should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Forward { + command: Some(ForwardCommands::List { + output: OutputFormat::Table, + }) + }) + )); + } + + #[test] + fn forward_list_accepts_structured_output() { + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let cli = Cli::try_parse_from(["openshell", "forward", "list", "-o", format]) + .unwrap_or_else(|error| panic!("forward list -o {format} should parse: {error}")); + + let Some(Commands::Forward { + command: Some(ForwardCommands::List { output }), + }) = cli.command + else { + panic!("expected forward list command"); + }; + assert_eq!(output, expected, "unexpected output format for {format}"); + } + } + + #[test] + fn remaining_list_commands_default_output_to_table() { + let sandbox_provider = Cli::try_parse_from(["openshell", "sandbox", "provider", "list"]) + .expect("sandbox provider list should parse"); + assert!(matches!( + sandbox_provider.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Provider(SandboxProviderCommands::List { + output: OutputFormat::Table, + .. + })), + }) + )); + + let policy = + Cli::try_parse_from(["openshell", "policy", "list"]).expect("policy list should parse"); + assert!(matches!( + policy.command, + Some(Commands::Policy { + command: Some(PolicyCommands::List { + output: OutputFormat::Table, + .. + }), + }) + )); + + let service = Cli::try_parse_from(["openshell", "service", "list"]) + .expect("service list should parse"); + assert!(matches!( + service.command, + Some(Commands::Service { + command: Some(ServiceCommands::List { + output: OutputFormat::Table, + .. + }), + }) + )); + + let workspace_member = Cli::try_parse_from([ + "openshell", + "workspace", + "member", + "list", + "--workspace", + "default", + ]) + .expect("workspace member list should parse"); + assert!(matches!( + workspace_member.command, + Some(Commands::Workspace { + command: Some(WorkspaceCommands::Member(WorkspaceMemberCommands::List { + output: OutputFormat::Table, + .. + })), + }) )); } + #[test] + fn remaining_list_commands_accept_structured_output() { + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let sandbox_provider = + Cli::try_parse_from(["openshell", "sandbox", "provider", "list", "-o", format]) + .unwrap_or_else(|error| panic!("sandbox provider list -o {format}: {error}")); + let Some(Commands::Sandbox { + command: + Some(SandboxCommands::Provider(SandboxProviderCommands::List { output, .. })), + }) = sandbox_provider.command + else { + panic!("expected sandbox provider list command"); + }; + assert_eq!(output, expected); + + let policy = Cli::try_parse_from(["openshell", "policy", "list", "--output", format]) + .unwrap_or_else(|error| panic!("policy list --output {format}: {error}")); + let Some(Commands::Policy { + command: Some(PolicyCommands::List { output, .. }), + }) = policy.command + else { + panic!("expected policy list command"); + }; + assert_eq!(output, expected); + + let service = Cli::try_parse_from(["openshell", "service", "list", "-o", format]) + .unwrap_or_else(|error| panic!("service list -o {format}: {error}")); + let Some(Commands::Service { + command: Some(ServiceCommands::List { output, .. }), + }) = service.command + else { + panic!("expected service list command"); + }; + assert_eq!(output, expected); + + let workspace_member = Cli::try_parse_from([ + "openshell", + "workspace", + "member", + "list", + "--workspace", + "default", + "-o", + format, + ]) + .unwrap_or_else(|error| panic!("workspace member list -o {format}: {error}")); + let Some(Commands::Workspace { + command: + Some(WorkspaceCommands::Member(WorkspaceMemberCommands::List { output, .. })), + }) = workspace_member.command + else { + panic!("expected workspace member list command"); + }; + assert_eq!(output, expected); + } + } + #[test] fn sandbox_list_default_output_is_table() { let cli = Cli::try_parse_from(["openshell", "sandbox", "list"]) @@ -4426,6 +4989,27 @@ mod tests { )); } + #[test] + fn sandbox_stop_and_start_accept_optional_names() { + let stop = Cli::try_parse_from(["openshell", "sandbox", "stop", "demo"]) + .expect("stop command should parse"); + assert!(matches!( + stop.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Stop { name: Some(ref name) }), + }) if name == "demo" + )); + + let start = Cli::try_parse_from(["openshell", "sandbox", "start"]) + .expect("start command should parse"); + assert!(matches!( + start.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Start { name: None }), + }) + )); + } + #[test] fn sandbox_list_accepts_output_json() { let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"]) @@ -4595,8 +5179,8 @@ mod tests { } #[test] - fn provider_create_requires_credential_source() { - let err = Cli::try_parse_from([ + fn provider_create_accepts_no_credential_source() { + let cli = Cli::try_parse_from([ "openshell", "provider", "create", @@ -4605,9 +5189,21 @@ mod tests { "--type", "spiffe-token-demo", ]) - .expect_err("provider create should require a credential source"); + .expect("provider create should allow profiles without static credentials"); - assert!(err.to_string().contains("--runtime-credentials")); + assert!(matches!( + cli.command, + Some(Commands::Provider { + command: Some(ProviderCommands::Create { + from_existing: false, + credentials, + from_gcloud_adc: false, + from_oidc_token: false, + runtime_credentials: false, + .. + }) + }) if credentials.is_empty() + )); } #[test] @@ -5041,6 +5637,55 @@ mod tests { } } + #[test] + fn sandbox_create_detach_parses_with_main_command() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--detach", + "--", + "worker", + "--serve", + ]) + .expect("sandbox create --detach should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Create { + detach, command, .. + }), + .. + }) => { + assert!(detach); + assert_eq!(command, ["worker", "--serve"]); + } + other => panic!("expected SandboxCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_create_detach_rejects_ephemeral_sandbox() { + let result = + Cli::try_parse_from(["openshell", "sandbox", "create", "--detach", "--no-keep"]); + assert!(result.is_err()); + } + + #[test] + fn sandbox_create_rejects_upload_with_main_command() { + let result = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--upload", + ".", + "--", + "./run-uploaded-app", + ]); + assert!(result.is_err()); + } + /// `sandbox create` defaults `--approval-mode` to `"manual"`. The CLI /// always sends an explicit value so the wire form is human-readable /// (the gateway treats `""` as `"manual"` too, but the CLI's job is to @@ -5174,6 +5819,226 @@ mod tests { } } + #[test] + fn sandbox_create_template_flag_parses() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--template", + "gpu-kata", + "--provider", + "github", + ]) + .expect("sandbox create template flag should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Create { + template, + providers, + .. + }), + .. + }) => { + assert_eq!(template.as_deref(), Some("gpu-kata")); + assert_eq!(providers, vec!["github".to_string()]); + } + other => panic!("expected SandboxCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_create_template_conflicts_with_inline_workload_flags() { + for (label, extra_args) in [ + ("--from", &["--from", "python:3.12"][..]), + ("--gpu", &["--gpu"][..]), + ("--cpu", &["--cpu", "1"][..]), + ("--memory", &["--memory", "2Gi"][..]), + ("--env", &["--env", "FOO=bar"][..]), + ( + "--driver-config-json", + &["--driver-config-json", r#"{"kubernetes":{}}"#][..], + ), + ] { + let args = ["openshell", "sandbox", "create", "--template", "base"] + .into_iter() + .chain(extra_args.iter().copied()); + let result = Cli::try_parse_from(args); + assert!(result.is_err(), "--template should conflict with {label}"); + } + } + + #[test] + fn sandbox_template_create_parses_workload_flags() { + let json = r#"{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}"#; + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "create", + "gpu-kata", + "--image", + "registry.example.com/agent:latest", + "--cpu", + "2", + "--memory", + "4Gi", + "--gpu", + "1", + "--driver-config-json", + json, + "--ready-within", + "5m", + "--max-burst", + "3", + "--label", + "team=runtime", + "--annotation", + "owner=platform", + "--env", + "FEATURE_FLAG=on", + "--no-credential-warnings", + "--output", + "json", + ]) + .expect("sandbox template create should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::Create { + name, + image, + cpu, + memory, + gpu, + driver_config_json, + ready_within, + max_burst, + labels, + annotations, + envs, + no_credential_warnings, + output, + })), + .. + }) => { + assert_eq!(name, "gpu-kata"); + assert_eq!(image.as_deref(), Some("registry.example.com/agent:latest")); + assert_eq!(cpu.as_deref(), Some("2")); + assert_eq!(memory.as_deref(), Some("4Gi")); + assert_eq!(gpu, Some(GpuCliRequest::Count(1))); + assert_eq!(driver_config_json.as_deref(), Some(json)); + assert_eq!(ready_within.as_deref(), Some("5m")); + assert_eq!(max_burst, Some(3)); + assert_eq!(labels, vec!["team=runtime".to_string()]); + assert_eq!(annotations, vec!["owner=platform".to_string()]); + assert_eq!(envs, vec!["FEATURE_FLAG=on".to_string()]); + assert!(no_credential_warnings); + assert!(matches!(output, OutputFormat::Json)); + } + other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_template_list_parses_names_and_all_workspaces() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "list", + "--names", + "--all-workspaces", + "--label-selector", + "team=runtime", + "--limit", + "25", + "--offset", + "5", + ]) + .expect("sandbox template list should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::List { + limit, + offset, + label_selector, + names, + all_workspaces, + .. + })), + .. + }) => { + assert_eq!(limit, 25); + assert_eq!(offset, 5); + assert_eq!(label_selector.as_deref(), Some("team=runtime")); + assert!(names); + assert!(all_workspaces); + } + other => panic!("expected SandboxTemplateCommands::List, got: {other:?}"), + } + } + + #[test] + fn sandbox_template_list_names_conflicts_with_output() { + let result = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "list", + "--names", + "--output", + "json", + ]); + assert!(result.is_err()); + } + + #[test] + fn sandbox_template_create_image_is_optional() { + let cli = Cli::try_parse_from(["openshell", "sandbox", "template", "create", "base"]) + .expect("sandbox template create without --image should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::Create { image, .. })), + .. + }) => { + assert_eq!(image, None); + } + other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_template_create_gpu_parses_driver_default() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "template", + "create", + "gpu-kata", + "--gpu", + ]) + .expect("sandbox template create --gpu should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Template(SandboxTemplateCommands::Create { gpu, .. })), + .. + }) => { + assert_eq!(gpu, Some(GpuCliRequest::DriverDefault)); + } + other => panic!("expected SandboxTemplateCommands::Create, got: {other:?}"), + } + } + #[test] fn sandbox_create_gpu_parses_driver_default() { let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu"]) diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 2aacdb0c9c..7fbdb51004 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -3,10 +3,10 @@ //! OIDC authentication flows for CLI gateway login. //! -//! Implements Authorization Code + PKCE (interactive browser flow) and -//! Client Credentials (CI/automation) `OAuth2` grant types against a -//! Keycloak-compatible OIDC provider. - +//! Implements Authorization Code + PKCE (interactive browser flow), +//! Device Authorization Grant (headless flow), and Client Credentials +//! (CI/automation) `OAuth2` grant types against a Keycloak-compatible +//! OIDC provider. use bytes::Bytes; use http_body_util::Full; use hyper::service::service_fn; @@ -14,7 +14,7 @@ use hyper::{Method, Response, StatusCode}; use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::conn::auto::Builder; use miette::{IntoDiagnostic, Result}; -use oauth2::basic::BasicClient; +use oauth2::basic::{BasicClient, BasicTokenResponse}; use oauth2::{ AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, @@ -37,6 +37,31 @@ struct OidcDiscovery { issuer: String, authorization_endpoint: String, token_endpoint: String, + device_authorization_endpoint: Option, +} + +/// Device authorization response from the provider. +#[derive(Debug, Deserialize)] +struct DeviceAuthorizationResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + #[serde(default = "default_interval")] + interval: u64, +} + +fn default_interval() -> u64 { + 5 +} + +/// Device token polling error responses (RFC 8628). +#[derive(Debug, Deserialize)] +struct DeviceTokenErrorResponse { + error: String, + #[serde(default)] + error_description: String, } /// Discover OIDC endpoints from the issuer's well-known configuration. @@ -96,6 +121,55 @@ fn build_ci_scopes(scopes: Option<&str>) -> Vec { .collect() } +fn interactive_authorization_params( + audience: Option<&str>, + force_fresh_login: bool, +) -> Vec<(&'static str, String)> { + let mut params = Vec::new(); + if force_fresh_login { + params.push(("prompt", "login".to_string())); + } + if let Some(aud) = audience { + params.push(("audience", aud.to_string())); + } + params +} + +fn device_authorization_form( + client_id: &str, + scopes: &str, + audience: Option<&str>, + code_challenge: &str, + code_challenge_method: &str, +) -> Vec<(&'static str, String)> { + let mut params = vec![ + ("client_id", client_id.to_string()), + ("scope", scopes.to_string()), + ("code_challenge", code_challenge.to_string()), + ("code_challenge_method", code_challenge_method.to_string()), + ]; + if let Some(audience) = audience { + params.push(("audience", audience.to_string())); + } + params +} + +fn device_token_form( + client_id: &str, + device_code: &str, + code_verifier: &str, +) -> Vec<(&'static str, String)> { + vec![ + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ), + ("device_code", device_code.to_string()), + ("client_id", client_id.to_string()), + ("code_verifier", code_verifier.to_string()), + ] +} + /// Run the OIDC Authorization Code + PKCE browser flow. /// /// Opens the user's browser to the Keycloak login page and waits for @@ -106,6 +180,7 @@ pub async fn oidc_browser_auth_flow( audience: Option<&str>, scopes: Option<&str>, insecure: bool, + force_fresh_login: bool, ) -> Result { let discovery = discover(issuer, insecure).await?; @@ -130,10 +205,14 @@ pub async fn oidc_browser_auth_flow( let (mut auth_url, csrf_token) = auth_request.url(); - // Append audience parameter for providers like Entra ID where the API - // audience differs from the client ID. - if let Some(aud) = audience { - auth_url.query_pairs_mut().append_pair("audience", aud); + // After `gateway logout`, ask the IdP for a fresh login prompt so the user + // can switch browser identity. Ordinary repeated logins may reuse SSO. + let params = interactive_authorization_params(audience, force_fresh_login); + { + let mut query = auth_url.query_pairs_mut(); + for (key, value) in ¶ms { + query.append_pair(key, value); + } } let (tx, rx) = oneshot::channel::(); @@ -226,6 +305,162 @@ pub async fn oidc_client_credentials_flow( )) } +/// Run the OIDC Device Authorization Grant flow (RFC 8628). +/// +/// Prompts the user to visit a verification URL and enter a code on any device +/// with a browser. Polls the token endpoint until the user completes authorization +/// or the device code expires. +pub async fn oidc_device_code_flow( + issuer: &str, + client_id: &str, + audience: Option<&str>, + scopes: Option<&str>, + insecure: bool, +) -> Result { + let discovery = discover(issuer, insecure).await?; + + let device_auth_endpoint = discovery.device_authorization_endpoint.as_deref().ok_or_else(|| { + miette::miette!( + "The OIDC provider does not advertise a device_authorization_endpoint.\n\ + Enable the device authorization grant on this client, or use client credentials for headless automation." + ) + })?; + + // Step 1: Request device and user codes + let http = http_client(insecure); + let scopes_param = build_scopes(scopes) + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(" "); + + // Use PKCE for the device flow as well as the browser flow. Keycloak + // requires these parameters when the public client enforces S256 PKCE. + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let form_params = device_authorization_form( + client_id, + &scopes_param, + audience, + pkce_challenge.as_str(), + pkce_challenge.method().as_str(), + ); + + let device_auth_resp = http + .post(device_auth_endpoint) + .form(&form_params) + .send() + .await + .into_diagnostic()?; + + if !device_auth_resp.status().is_success() { + let status = device_auth_resp.status(); + let body = device_auth_resp.text().await.unwrap_or_default(); + return Err(miette::miette!( + "Device authorization request failed (status {status}): {body}" + )); + } + + let device_auth: DeviceAuthorizationResponse = + device_auth_resp.json().await.into_diagnostic()?; + + // Step 2: Display instructions to the user + eprintln!(); + eprintln!(" To authenticate, visit:"); + if let Some(uri_complete) = &device_auth.verification_uri_complete { + eprintln!(" {uri_complete}"); + } else { + eprintln!(" {}", device_auth.verification_uri); + eprintln!(); + eprintln!(" And enter this code:"); + eprintln!(" {}", device_auth.user_code); + } + eprintln!(); + eprintln!(" Waiting for authorization..."); + + // Step 3: Poll the token endpoint + let start_time = std::time::Instant::now(); + let expires_duration = Duration::from_secs(device_auth.expires_in); + let mut poll_interval = Duration::from_secs(device_auth.interval); + + loop { + if start_time.elapsed() >= expires_duration { + return Err(miette::miette!( + "Device code expired after {} seconds. Please try again.", + device_auth.expires_in + )); + } + + tokio::time::sleep(poll_interval).await; + + let token_params = + device_token_form(client_id, &device_auth.device_code, pkce_verifier.secret()); + + let poll_resp = http + .post(&discovery.token_endpoint) + .form(&token_params) + .send() + .await + .into_diagnostic()?; + + let status = poll_resp.status(); + + if status.is_success() { + // A successful HTTP status is not sufficient: require a valid OAuth + // token response before persisting credentials. + let token_response: BasicTokenResponse = poll_resp + .json() + .await + .map_err(|error| miette::miette!("invalid device token response: {error}"))?; + + return bundle_from_device_token_response(&token_response, issuer, client_id); + } + + // Parse error response + let error_resp: DeviceTokenErrorResponse = match poll_resp.json().await { + Ok(e) => e, + Err(_) => { + return Err(miette::miette!( + "Token polling failed with status {status} and unparseable response" + )); + } + }; + + match error_resp.error.as_str() { + "authorization_pending" => { + // Keep polling + debug!("Device authorization pending, continuing to poll"); + } + "slow_down" => { + // Increase polling interval per RFC 8628 + poll_interval += Duration::from_secs(5); + debug!( + "Received slow_down, increasing interval to {:?}", + poll_interval + ); + } + "access_denied" => { + return Err(miette::miette!( + "Authorization was denied by the user or administrator" + )); + } + "expired_token" => { + return Err(miette::miette!("Device code expired. Please try again.")); + } + _ => { + let desc = if error_resp.error_description.is_empty() { + String::new() + } else { + format!(": {}", error_resp.error_description) + }; + return Err(miette::miette!( + "Device authorization failed: {}{desc}", + error_resp.error + )); + } + } + } +} + /// Refresh an OIDC token using the `refresh_token` grant. /// /// Reuses the configured login scopes when supplied so providers can select @@ -277,10 +512,11 @@ fn bundle_from_refresh_output( } } -/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed. -/// -/// Returns the access token string. -pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Result { +/// Ensure we have a valid OIDC token bundle for the given gateway, refreshing if needed. +pub async fn ensure_valid_oidc_token_bundle( + gateway_name: &str, + insecure: bool, +) -> Result { let bundle = openshell_bootstrap::oidc_token::load_oidc_token(gateway_name).ok_or_else(|| { miette::miette!( @@ -290,7 +526,7 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu })?; if !openshell_bootstrap::oidc_token::is_token_expired(&bundle) { - return Ok(bundle.access_token); + return Ok(bundle); } debug!( @@ -302,13 +538,22 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu .and_then(|metadata| metadata.oidc_scopes); let refreshed = oidc_refresh_token(&bundle, scopes.as_deref(), insecure).await?; openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed)?; - Ok(refreshed.access_token) + Ok(refreshed) +} + +/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed. +/// +/// Returns the access token string. +pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Result { + Ok(ensure_valid_oidc_token_bundle(gateway_name, insecure) + .await? + .access_token) } // ── Helpers ────────────────────────────────────────────────────────── fn bundle_from_oauth2_response( - resp: &oauth2::basic::BasicTokenResponse, + resp: &BasicTokenResponse, issuer: &str, client_id: &str, ) -> OidcTokenBundle { @@ -326,6 +571,20 @@ fn bundle_from_oauth2_response( } } +fn bundle_from_device_token_response( + resp: &BasicTokenResponse, + issuer: &str, + client_id: &str, +) -> Result { + if resp.access_token().secret().trim().is_empty() { + return Err(miette::miette!( + "invalid device token response: access_token is empty" + )); + } + + Ok(bundle_from_oauth2_response(resp, issuer, client_id)) +} + /// Percent-decode a URL query parameter value. fn percent_decode(s: &str) -> String { let mut out = Vec::with_capacity(s.len()); @@ -537,6 +796,82 @@ mod tests { assert!(scopes.is_empty()); } + #[test] + fn interactive_authorization_params_force_fresh_login() { + assert_eq!( + interactive_authorization_params(Some("api://openshell"), true), + vec![ + ("prompt", "login".to_string()), + ("audience", "api://openshell".to_string()), + ] + ); + assert_eq!( + interactive_authorization_params(None, true), + vec![("prompt", "login".to_string())] + ); + assert_eq!( + interactive_authorization_params(Some("api://openshell"), false), + vec![("audience", "api://openshell".to_string())] + ); + assert!(interactive_authorization_params(None, false).is_empty()); + } + + #[test] + fn device_authorization_form_includes_pkce_and_audience() { + let params = device_authorization_form( + "openshell-cli", + "openid profile", + Some("openshell-api"), + "test-challenge", + "S256", + ); + let params: std::collections::HashMap<_, _> = params.into_iter().collect(); + + assert_eq!( + params.get("client_id").map(String::as_str), + Some("openshell-cli") + ); + assert_eq!( + params.get("scope").map(String::as_str), + Some("openid profile") + ); + assert_eq!( + params.get("audience").map(String::as_str), + Some("openshell-api") + ); + assert_eq!( + params.get("code_challenge").map(String::as_str), + Some("test-challenge") + ); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + } + + #[test] + fn device_token_form_includes_pkce_verifier() { + let params = device_token_form("openshell-cli", "device-code", "test-verifier"); + let params: std::collections::HashMap<_, _> = params.into_iter().collect(); + + assert_eq!( + params.get("grant_type").map(String::as_str), + Some("urn:ietf:params:oauth:grant-type:device_code") + ); + assert_eq!( + params.get("device_code").map(String::as_str), + Some("device-code") + ); + assert_eq!( + params.get("client_id").map(String::as_str), + Some("openshell-cli") + ); + assert_eq!( + params.get("code_verifier").map(String::as_str), + Some("test-verifier") + ); + } + #[test] fn bundle_from_response_sets_fields() { use oauth2::basic::BasicTokenResponse; @@ -572,4 +907,130 @@ mod tests { assert_eq!(refreshed.client_id, previous.client_id); assert_eq!(refreshed.expires_at, Some(300)); } + + #[test] + fn discovery_missing_device_endpoint_is_optional() { + let discovery_json = "{\"issuer\":\"https://issuer.example\",\"authorization_endpoint\":\"https://issuer.example/auth\",\"token_endpoint\":\"https://issuer.example/token\"}"; + let discovery: OidcDiscovery = serde_json::from_str(discovery_json).unwrap(); + assert!(discovery.device_authorization_endpoint.is_none()); + assert_eq!(discovery.issuer, "https://issuer.example"); + } + + #[test] + fn discovery_with_device_endpoint_is_captured() { + let discovery_json = "{\"issuer\":\"https://issuer.example\",\"authorization_endpoint\":\"https://issuer.example/auth\",\"token_endpoint\":\"https://issuer.example/token\",\"device_authorization_endpoint\":\"https://issuer.example/device\"}"; + let discovery: OidcDiscovery = serde_json::from_str(discovery_json).unwrap(); + assert_eq!( + discovery.device_authorization_endpoint.as_deref(), + Some("https://issuer.example/device") + ); + } + + #[test] + fn device_auth_response_parses_minimal() { + let json = "{\"device_code\":\"GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS\",\"user_code\":\"WDJB-MJHT\",\"verification_uri\":\"https://example.com/device\",\"expires_in\":1800}"; + let resp: DeviceAuthorizationResponse = serde_json::from_str(json).unwrap(); + assert_eq!( + resp.device_code, + "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS" + ); + assert_eq!(resp.user_code, "WDJB-MJHT"); + assert_eq!(resp.verification_uri, "https://example.com/device"); + assert_eq!(resp.expires_in, 1800); + assert_eq!(resp.interval, 5); + assert!(resp.verification_uri_complete.is_none()); + } + + #[test] + fn device_auth_response_parses_complete() { + let json = "{\"device_code\":\"test-device-code\",\"user_code\":\"TEST-CODE\",\"verification_uri\":\"https://example.com/device\",\"verification_uri_complete\":\"https://example.com/device?user_code=TEST-CODE\",\"expires_in\":900,\"interval\":10}"; + let resp: DeviceAuthorizationResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.interval, 10); + assert_eq!( + resp.verification_uri_complete.as_deref(), + Some("https://example.com/device?user_code=TEST-CODE") + ); + } + + #[test] + fn device_token_error_response_parses() { + let json = "{\"error\":\"authorization_pending\",\"error_description\":\"User has not authorized yet\"}"; + let resp: DeviceTokenErrorResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.error, "authorization_pending"); + assert_eq!(resp.error_description, "User has not authorized yet"); + } + + #[test] + fn device_token_error_response_defaults_empty_description() { + let json = "{\"error\":\"slow_down\"}"; + let resp: DeviceTokenErrorResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.error, "slow_down"); + assert_eq!(resp.error_description, ""); + } + + #[test] + fn device_token_response_complete_is_typed() { + let response: BasicTokenResponse = serde_json::from_value(serde_json::json!({ + "access_token": "device-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "device-refresh-token" + })) + .unwrap(); + let bundle = + bundle_from_device_token_response(&response, "https://issuer.example", "test-client") + .unwrap(); + assert_eq!(bundle.access_token, "device-access-token"); + assert_eq!( + bundle.refresh_token.as_deref(), + Some("device-refresh-token") + ); + assert_eq!(bundle.issuer, "https://issuer.example"); + assert_eq!(bundle.client_id, "test-client"); + assert!(bundle.expires_at.is_some()); + } + + #[test] + fn device_token_response_minimal_is_typed() { + let response: BasicTokenResponse = serde_json::from_value(serde_json::json!({ + "access_token": "device-access-only", + "token_type": "Bearer" + })) + .unwrap(); + let bundle = + bundle_from_device_token_response(&response, "https://issuer.example", "test-client") + .unwrap(); + assert_eq!(bundle.access_token, "device-access-only"); + assert!(bundle.refresh_token.is_none()); + assert!(bundle.expires_at.is_none()); + } + + #[test] + fn device_token_response_requires_access_token() { + let result = serde_json::from_value::(serde_json::json!({ + "token_type": "Bearer" + })); + assert!(result.is_err()); + } + + #[test] + fn device_token_response_requires_token_type() { + let result = serde_json::from_value::(serde_json::json!({ + "access_token": "device-access-token" + })); + assert!(result.is_err()); + } + + #[test] + fn device_token_response_rejects_empty_access_token() { + let response: BasicTokenResponse = serde_json::from_value(serde_json::json!({ + "access_token": "", + "token_type": "Bearer" + })) + .unwrap(); + + let result = + bundle_from_device_token_response(&response, "https://issuer.example", "test-client"); + assert!(result.is_err()); + } } diff --git a/crates/openshell-cli/src/policy_update.rs b/crates/openshell-cli/src/policy_update.rs index 1f1f647506..d6ba02e40c 100644 --- a/crates/openshell-cli/src/policy_update.rs +++ b/crates/openshell-cli/src/policy_update.rs @@ -327,9 +327,14 @@ fn parse_add_endpoint_spec(spec: &str) -> Result { "--add-endpoint access segment must be one of read-only, read-write, or full; got '{access}' in '{spec}'" )); } - if !protocol.is_empty() && !matches!(protocol, "rest" | "websocket" | "sql") { + if !protocol.is_empty() && !matches!(protocol, "tcp" | "rest" | "websocket" | "sql") { return Err(miette!( - "--add-endpoint protocol segment must be 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'" + "--add-endpoint protocol segment must be 'tcp', 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'" + )); + } + if protocol == "tcp" && (!access.is_empty() || !enforcement.is_empty()) { + return Err(miette!( + "--add-endpoint protocol 'tcp' does not support access or enforcement in '{spec}'" )); } if !enforcement.is_empty() && !matches!(enforcement, "enforce" | "audit") { @@ -351,6 +356,8 @@ fn parse_add_endpoint_spec(spec: &str) -> Result { Ok(endpoint) } +const ALLOWED_IP_OPTION_PREFIX: &str = "allowed-ip="; + fn apply_add_endpoint_options( spec: &str, endpoint: &mut NetworkEndpoint, @@ -368,6 +375,9 @@ fn apply_add_endpoint_options( )); } match option { + "allow-uninspected-credentials" => { + endpoint.allow_uninspected_credentials = true; + } "websocket-credential-rewrite" => { ensure_websocket_credential_rewrite_protocol(spec, endpoint)?; endpoint.websocket_credential_rewrite = true; @@ -376,37 +386,40 @@ fn apply_add_endpoint_options( ensure_request_body_credential_rewrite_protocol(spec, endpoint)?; endpoint.request_body_credential_rewrite = true; } - _ => { - let Some(allowed_ip) = option.strip_prefix("allowed-ip=") else { - return Err(miette!( - "--add-endpoint options segment supports only 'websocket-credential-rewrite', 'request-body-credential-rewrite', and 'allowed-ip='; got '{option}' in '{spec}'" - )); - }; - let allowed_ip = allowed_ip.trim(); - if allowed_ip.is_empty() { - return Err(miette!( - "--add-endpoint allowed-ip option must include a CIDR or IP value in '{spec}'" - )); - } - if allowed_ip.contains(char::is_whitespace) { - return Err(miette!( - "--add-endpoint allowed-ip option must not contain whitespace in '{spec}'" - )); - } - if !endpoint - .allowed_ips - .iter() - .any(|existing| existing == allowed_ip) - { - endpoint.allowed_ips.push(allowed_ip.to_string()); + _ if option.starts_with(ALLOWED_IP_OPTION_PREFIX) => { + let allowed_ip = + parse_allowed_ip_value(spec, &option[ALLOWED_IP_OPTION_PREFIX.len()..])?; + if !endpoint.allowed_ips.contains(&allowed_ip) { + endpoint.allowed_ips.push(allowed_ip); } } + _ => { + return Err(miette!( + "--add-endpoint options segment supports only 'allow-uninspected-credentials', 'websocket-credential-rewrite', 'request-body-credential-rewrite', and 'allowed-ip='; got '{option}' in '{spec}'" + )); + } } } Ok(()) } +/// Validate the value part of an `allowed-ip=` endpoint option. +fn parse_allowed_ip_value(spec: &str, value: &str) -> Result { + let allowed_ip = value.trim(); + if allowed_ip.is_empty() { + return Err(miette!( + "--add-endpoint allowed-ip option must include a CIDR or IP value in '{spec}'" + )); + } + if allowed_ip.contains(char::is_whitespace) { + return Err(miette!( + "--add-endpoint allowed-ip option must not contain whitespace in '{spec}'" + )); + } + Ok(allowed_ip.to_string()) +} + fn parse_host(flag: &str, spec: &str, host: &str) -> Result { let host = host.trim(); if host.is_empty() { @@ -454,6 +467,7 @@ fn dedup_strings(values: &[String]) -> Vec { mod tests { use super::{ PolicyUpdatePlan, build_policy_update_plan as build_policy_update_plan_with_options, + parse_allowed_ip_value, }; use openshell_policy::PolicyMergeOp; @@ -538,6 +552,26 @@ mod tests { assert_eq!(endpoint.enforcement, "enforce"); } + #[test] + fn parse_add_endpoint_accepts_explicit_tcp_protocol() { + let plan = build_policy_update_plan( + &["database.example.com:5432::tcp".to_string()], + &[], + &[], + &[], + &[], + &[], + None, + ) + .expect("plan should build"); + + let PolicyMergeOp::AddRule { rule, .. } = &plan.preview_operations[0] else { + panic!("expected add-rule preview"); + }; + assert_eq!(rule.endpoints[0].protocol, "tcp"); + assert!(rule.endpoints[0].access.is_empty()); + } + #[test] fn parse_add_endpoint_enables_websocket_credential_rewrite() { let plan = build_policy_update_plan( @@ -604,6 +638,25 @@ mod tests { assert!(endpoint.request_body_credential_rewrite); } + #[test] + fn parse_add_endpoint_enables_allow_uninspected_credentials() { + let plan = build_policy_update_plan( + &["api.vendor.example:443::::allow-uninspected-credentials".to_string()], + &[], + &[], + &[], + &[], + &[], + None, + ) + .expect("plan should build"); + + let PolicyMergeOp::AddRule { rule, .. } = &plan.preview_operations[0] else { + panic!("expected add-rule preview"); + }; + assert!(rule.endpoints[0].allow_uninspected_credentials); + } + #[test] fn parse_add_endpoint_merges_allowed_ips_with_websocket_options() { let plan = build_policy_update_plan( @@ -665,6 +718,28 @@ mod tests { assert!(error.to_string().contains("allowed-ip option")); } + #[test] + fn parse_allowed_ip_value_accepts_trimmed_cidr_and_ip() { + assert_eq!( + parse_allowed_ip_value("spec", "10.0.0.0/8").expect("CIDR should parse"), + "10.0.0.0/8" + ); + assert_eq!( + parse_allowed_ip_value("spec", " 192.168.1.10 ").expect("IP should parse"), + "192.168.1.10" + ); + } + + #[test] + fn parse_allowed_ip_value_rejects_empty_and_interior_whitespace() { + let empty = parse_allowed_ip_value("spec", " ").expect_err("empty value must fail"); + assert!(empty.to_string().contains("must include a CIDR or IP")); + + let spaced = + parse_allowed_ip_value("spec", "10.0.0.0/8 172.16.0.0/12").expect_err("must fail"); + assert!(spaced.to_string().contains("must not contain whitespace")); + } + #[test] fn websocket_credential_rewrite_rejects_l4_endpoint() { let error = build_policy_update_plan( @@ -787,6 +862,26 @@ mod tests { ); } + #[test] + fn parse_add_endpoint_rejects_l7_fields_with_tcp() { + let error = build_policy_update_plan( + &["database.example.com:5432::tcp:enforce".to_string()], + &[], + &[], + &[], + &[], + &[], + None, + ) + .expect_err("TCP must reject L7 enforcement"); + + assert!( + error + .to_string() + .contains("does not support access or enforcement") + ); + } + #[test] fn parse_remove_endpoint_rejects_out_of_range_port() { let error = build_policy_update_plan( diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 64fd550852..78a794aa30 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5,65 +5,66 @@ pub use crate::commands::common::{ PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs, - parse_secret_material_env_pairs, + parse_secret_material_env_pairs, warn_credential_env_vars, }; use crate::commands::common::{ ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete, - confirm_global_setting_takeover, format_epoch_ms, format_optional_epoch_ms, - format_setting_value, format_timestamp, format_timestamp_ms, handle_platform_progress_event, - is_provisioning_progress_event, non_empty_or, parse_cli_setting_value, - parse_credential_expiry_pairs, parse_credential_pairs, parse_duration_to_ms, phase_name, + confirm_global_setting_takeover, format_epoch_ms, format_setting_value, format_timestamp, + format_timestamp_ms, handle_platform_progress_event, is_provisioning_progress_event, + non_empty_or, parse_cli_setting_value, parse_duration_to_ms, phase_name, print_policy_merge_warnings, print_sandbox_header, print_sandbox_policy, provisioning_timeout_message, ready_false_condition_message, scrub_git_env, short_hash, - truncate_display, truncate_status_field, + truncate_status_field, }; pub use crate::commands::gateway::{ gateway_add, gateway_info, gateway_info_not_configured, gateway_list, gateway_login, gateway_logout, gateway_remove, gateway_select, gateway_status, gateway_use, }; +use crate::commands::provider::inferred_provider_type; +pub use crate::commands::provider::{ + ProviderCreateCredentialSource, ProviderCreateOptions, ProviderRefreshConfigInput, + ProviderUpdateOptions, ensure_required_providers, provider_create, + provider_create_with_options, provider_delete, provider_get, provider_list, + provider_list_profiles, provider_profile_delete, provider_profile_export, + provider_profile_export_text, provider_profile_import, provider_profile_lint, + provider_profile_update, provider_refresh_config, provider_refresh_delete, + provider_refresh_status, provider_rotate, provider_update, sandbox_provider_attach, + sandbox_provider_detach, sandbox_provider_list, +}; + +use crate::color::Colorize; use crate::policy_update::build_policy_update_plan; use crate::tls::{TlsOptions, grpc_client, grpc_inference_client}; -use dialoguer::Confirm; use futures::StreamExt; use indicatif::{ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result, WrapErr, miette}; use openshell_bootstrap::{ GatewayMetadata, clear_last_sandbox_if_matches, get_gateway_metadata, save_last_sandbox, }; -use openshell_core::proto::ProviderProfileCategory; +use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::proto::{ - ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest, - ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, DeleteInferenceRouteRequest, - DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, - DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, - ExposeServiceRequest, GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetInferenceRouteRequest, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, - GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, ImportProviderProfilesRequest, - LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, - ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, - ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, - RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope, - TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, - UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, - setting_value, tcp_forward_init, + ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, + DeleteInferenceRouteRequest, DeleteSandboxRequest, DeleteSandboxTemplateRequest, + DeleteServiceRequest, ExecSandboxRequest, ExposeServiceRequest, GetCurrentUserRequest, + GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + GetInferenceRouteRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, + GetSandboxTemplateRequest, GetServiceRequest, GpuResourceRequirements, + ListSandboxPoliciesRequest, ListSandboxTemplatesRequest, ListSandboxesRequest, + ListServicesRequest, PolicySource, PolicyStatus, RejectDraftChunkRequest, ResourceRequirements, + RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxResources, + SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, + SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, ServiceEndpointResponse, + SetInferenceRouteRequest, SettingScope, StartSandboxRequest, StopSandboxRequest, + TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, WatchSandboxRequest, + exec_sandbox_event, tcp_forward_init, }; use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; -use openshell_providers::{ - ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, - discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, - profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, -}; -use owo_colors::OwoColorize; use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::io::{ErrorKind, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -116,6 +117,20 @@ impl ProgressOutput { } } +fn aggregate_delete_failures(resource: &str, failures: &[String]) -> Result<()> { + if failures.is_empty() { + Ok(()) + } else { + Err(miette!( + "failed to delete {} {}{}: {}", + failures.len(), + resource, + if failures.len() == 1 { "" } else { "s" }, + failures.join(", ") + )) + } +} + #[derive(Debug, Clone)] struct CurrentUserView { subject: String, @@ -221,6 +236,23 @@ fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>) -> bool { keep || forward.is_some() } +fn has_main_process_result(sandbox: &Sandbox) -> bool { + let Some(status) = sandbox.status.as_ref() else { + return false; + }; + if status.exit_code.is_none() { + return false; + } + + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + phase != SandboxPhase::Error + || status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status.eq_ignore_ascii_case("false") + && condition.reason == "MainProcessFailed" + }) +} + fn build_sandbox_resource_limits( cpu: Option<&str>, memory: Option<&str>, @@ -336,19 +368,21 @@ async fn finalize_sandbox_create_session( server: &str, sandbox_name: &str, persist: bool, - session_result: Result<()>, + session_result: Result, workspace: &str, tls: &TlsOptions, gateway: &str, -) -> Result<()> { +) -> Result { if persist { return session_result; } let names = [sandbox_name.to_string()]; if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { - if session_result.is_ok() { - return Err(err); + if let Ok(exit_code) = session_result.as_ref() { + return Err(miette::miette!( + "sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}" + )); } eprintln!("Failed to delete sandbox {sandbox_name}: {err}"); } @@ -364,6 +398,7 @@ async fn finalize_sandbox_create_session( #[derive(Debug)] pub struct SandboxCreateConfig<'a> { pub name: Option<&'a str>, + pub template: Option<&'a str>, pub from: Option<&'a str>, pub uploads: &'a [(String, Option, bool)], pub keep: bool, @@ -382,12 +417,14 @@ pub struct SandboxCreateConfig<'a> { pub environment: HashMap, pub approval_mode: &'a str, pub output: &'a str, + pub detach: bool, } impl Default for SandboxCreateConfig<'_> { fn default() -> Self { Self { name: None, + template: None, from: None, uploads: &[], keep: false, @@ -406,6 +443,7 @@ impl Default for SandboxCreateConfig<'_> { environment: HashMap::new(), approval_mode: "manual", output: "table", + detach: false, } } } @@ -417,9 +455,10 @@ pub async fn sandbox_create( config: SandboxCreateConfig<'_>, workspace: &str, tls: &TlsOptions, -) -> Result<()> { +) -> Result { let SandboxCreateConfig { name, + template, from, uploads, keep, @@ -438,6 +477,7 @@ pub async fn sandbox_create( environment, approval_mode, output, + detach, } = config; if editor.is_some() && !command.is_empty() { @@ -445,6 +485,16 @@ pub async fn sandbox_create( "--editor cannot be used with a trailing command; use `openshell sandbox connect --editor ...` after the sandbox is ready" )); } + if !uploads.is_empty() && !command.is_empty() { + return Err(miette::miette!( + "--upload cannot be combined with a trailing main command yet because uploads complete after the canonical process starts" + )); + } + if output != "table" && !command.is_empty() && !detach { + return Err(miette::miette!( + "structured output cannot be combined with an attached trailing command; use table output to stream the command or add --detach" + )); + } // Check port availability *before* creating the sandbox so we don't // leave an orphaned sandbox behind when the forward would fail. @@ -462,36 +512,44 @@ pub async fn sandbox_create( let effective_server = server.to_string(); let effective_tls = tls.clone(); + if template.is_some() + && (from.is_some() + || gpu_requirements.is_some() + || cpu.is_some() + || memory.is_some() + || driver_config_json.is_some() + || !environment.is_empty()) + { + return Err(miette::miette!( + "--template cannot be combined with inline workload flags" + )); + } + // Resolve the --from flag into a container image reference, building from - // a Dockerfile first if necessary. - let image: Option = match from { - Some(val) => { - let resolved = resolve_from(val)?; - match resolved { - ResolvedSource::Image(img) => Some(img), - ResolvedSource::Dockerfile { - dockerfile, - context, - } => { - let tag = build_from_dockerfile(&dockerfile, &context, gateway_name).await?; - Some(tag) + // a Dockerfile first if necessary. Template creates resolve workload shape + // on the gateway and skip local image handling. + let image: Option = if template.is_some() { + None + } else { + match from { + Some(val) => { + let resolved = resolve_from(val)?; + match resolved { + ResolvedSource::Image(img) => Some(img), + ResolvedSource::Dockerfile { + dockerfile, + context, + } => { + let tag = + build_from_dockerfile(&dockerfile, &context, gateway_name).await?; + Some(tag) + } } } + None => None, } - None => None, - }; - let inferred_provider = inferred_provider_type(command); - let providers_v2_enabled = - if inferred_provider.is_some() && auto_providers_override != Some(false) { - gateway_providers_v2_enabled(&mut client).await? - } else { - false - }; - let inferred_types: Vec = if providers_v2_enabled { - Vec::new() - } else { - inferred_provider.into_iter().collect() }; + let inferred_types: Vec = inferred_provider_type(command).into_iter().collect(); let configured_providers = ensure_required_providers( &mut client, providers, @@ -502,12 +560,21 @@ pub async fn sandbox_create( .await?; let policy = load_sandbox_policy(policy)?; - let resource_limits = build_sandbox_resource_limits(cpu, memory)?; - let driver_config = driver_config_json - .map(parse_driver_config_json) - .transpose()?; + let resource_limits = if template.is_none() { + build_sandbox_resource_limits(cpu, memory)? + } else { + None + }; + let driver_config = if template.is_none() { + driver_config_json + .map(parse_driver_config_json) + .transpose()? + } else { + None + }; - let template = if image.is_some() || resource_limits.is_some() || driver_config.is_some() { + let inline_template = if image.is_some() || resource_limits.is_some() || driver_config.is_some() + { Some(SandboxTemplate { image: image.unwrap_or_default(), resources: resource_limits, @@ -520,19 +587,48 @@ pub async fn sandbox_create( let resource_requirements = gpu_requirements.map(|gpu| ResourceRequirements { gpu: Some(gpu) }); + let main_terminal = tty_override + .unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal()); + // Forward the command as-is. When empty, the gateway persists it empty and + // the supervisor resolves the default login shell against the sandbox image + // (bash when present, otherwise /bin/sh on minimal images like Alpine). + // Baking a shell here would force a shell the image may not ship. + let main_command = command.to_vec(); + let persist = sandbox_should_persist(keep, forward.as_ref()); + let create_detaches = detach + || (persist + && command.is_empty() + && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal())); + let await_main_process_attachment = output == "table" && editor.is_none() && !create_detaches; + let annotations = if persist { + HashMap::new() + } else { + HashMap::from([( + "openshell.nvidia.com/retention".to_string(), + "ephemeral".to_string(), + )]) + }; let request = CreateSandboxRequest { spec: Some(SandboxSpec { resource_requirements, - environment, + environment: if template.is_none() { + environment + } else { + HashMap::new() + }, policy, providers: configured_providers, - template, + template: inline_template, + command: main_command, + tty: main_terminal, ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), labels, - annotations: HashMap::new(), + annotations, workspace: workspace.to_string(), + await_main_process_attachment, + workload_template_name: template.unwrap_or_default().to_string(), }; let response = match client.create_sandbox(request).await { @@ -551,7 +647,6 @@ pub async fn sandbox_create( .ok_or_else(|| miette::miette!("sandbox missing from response"))?; let interactive = std::io::stdout().is_terminal(); - let persist = sandbox_should_persist(keep, forward.as_ref()); let sandbox_name = if sandbox.object_name().is_empty() { "unknown".to_string() } else { @@ -728,8 +823,20 @@ pub async fn sandbox_create( saw_non_ready = true; } - // Capture error reason from conditions only when phase is Error - // to avoid showing stale transient error reasons + let main_process_result = has_main_process_result(&s); + if matches!( + phase, + SandboxPhase::Completed | SandboxPhase::Error | SandboxPhase::Stopped + ) && main_process_result + { + if let Some(d) = display.as_interactive_mut() { + d.clear(); + } + break; + } + + // Capture infrastructure error reasons only after excluding a + // canonical-command result, which must attach and drain output. if phase == SandboxPhase::Error && let Some(status) = &s.status { @@ -765,16 +872,10 @@ pub async fn sandbox_create( // the deadline when applicable. let handled = match &mut display { ProgressOutput::Interactive(d) => { - let mut opt = Some(std::mem::replace(d, ProvisioningDisplay::new())); - let h = handle_platform_progress_event(&ev, &mut opt, provision_start); - if let Some(inner) = opt { - *d = inner; - } - h + handle_platform_progress_event(&ev, Some(d), provision_start) } ProgressOutput::Plain => { - let mut opt: Option = None; - handle_platform_progress_event(&ev, &mut opt, provision_start) + handle_platform_progress_event(&ev, None, provision_start) } ProgressOutput::Silent => false, }; @@ -814,7 +915,11 @@ pub async fn sandbox_create( // If we exited the loop without hitting the Ready break, finish the display. let final_phase = SandboxPhase::try_from(last_phase).unwrap_or(SandboxPhase::Unknown); - if final_phase != SandboxPhase::Ready + let final_has_main_process_result = has_main_process_result(&last_sandbox); + if !(matches!( + final_phase, + SandboxPhase::Ready | SandboxPhase::Completed | SandboxPhase::Stopped + ) || final_phase == SandboxPhase::Error && final_has_main_process_result) && let Some(d) = display.as_interactive_mut() { if final_phase == SandboxPhase::Error { @@ -927,7 +1032,7 @@ pub async fn sandbox_create( if structured_output { crate::output::print_output_single(output, &last_sandbox, sandbox_to_json)?; - return Ok(()); + return Ok(0); } if let Some(editor) = editor { @@ -941,56 +1046,26 @@ pub async fn sandbox_create( workspace, ) .await?; - return Ok(()); + return Ok(0); } - if command.is_empty() { - let connect_result = if persist { - sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace) - .await - } else { - crate::ssh::sandbox_connect_without_exec( - &effective_server, - &sandbox_name, - &effective_tls, - workspace, - ) - .await - }; - - return finalize_sandbox_create_session( - &effective_server, - &sandbox_name, - persist, - connect_result, - workspace, - &effective_tls, - gateway_name, - ) - .await; + // An explicit trailing command is foreground regardless of TTY + // detection. Only --detach opts out. Scratch shells retain the + // non-interactive implicit-detach behavior. + if detach + || (persist + && command.is_empty() + && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal())) + { + return Ok(0); } - // Resolve TTY mode: explicit --tty / --no-tty wins, otherwise - // auto-detect from the local terminal. - let tty = tty_override.unwrap_or_else(|| { - std::io::stdin().is_terminal() && std::io::stdout().is_terminal() - }); - let exec_result = if persist { - sandbox_exec( - &effective_server, - &sandbox_name, - command, - tty, - &effective_tls, - workspace, - ) - .await + let connect_result = if persist { + sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace).await } else { - crate::ssh::sandbox_exec_without_exec( + crate::ssh::sandbox_connect_without_exec( &effective_server, &sandbox_name, - command, - tty, &effective_tls, workspace, ) @@ -1001,7 +1076,33 @@ pub async fn sandbox_create( &effective_server, &sandbox_name, persist, - exec_result, + connect_result, + workspace, + &effective_tls, + gateway_name, + ) + .await + } + SandboxPhase::Completed | SandboxPhase::Stopped | SandboxPhase::Error + if final_has_main_process_result => + { + drop(stream); + drop(client); + if detach { + return Ok(0); + } + let connect_result = crate::ssh::sandbox_connect_terminal_main( + &effective_server, + &sandbox_name, + &effective_tls, + workspace, + ) + .await; + finalize_sandbox_create_session( + &effective_server, + &sandbox_name, + persist, + connect_result, workspace, &effective_tls, gateway_name, @@ -1009,7 +1110,9 @@ pub async fn sandbox_create( .await } SandboxPhase::Error => { - if last_error_reason.is_empty() { + drop(stream); + drop(client); + let create_result = if last_error_reason.is_empty() { Err(miette::miette!( "sandbox entered error phase while provisioning" )) @@ -1018,7 +1121,17 @@ pub async fn sandbox_create( "sandbox entered error phase while provisioning: {}", last_error_reason )) - } + }; + finalize_sandbox_create_session( + &effective_server, + &sandbox_name, + persist, + create_result, + workspace, + &effective_tls, + gateway_name, + ) + .await } _ => Err(miette::miette!( "sandbox provisioning stream ended before reaching terminal phase" @@ -1330,6 +1443,9 @@ pub async fn sandbox_get( println!(" {} {}", "Id:".dimmed(), id); println!(" {} {}", "Name:".dimmed(), name); println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase())); + if let Some(exit_code) = sandbox.status.as_ref().and_then(|status| status.exit_code) { + println!(" {} {}", "Exit Code:".dimmed(), exit_code); + } println!( " {} {}", "Resource version:".dimmed(), @@ -1359,6 +1475,15 @@ pub async fn sandbox_get( } } + if let Some(provenance) = &sandbox.created_from_workload_template { + println!( + " {} {}@{}", + "Workload template:".dimmed(), + provenance.name, + provenance.resource_version + ); + } + let policy_from_global = config.policy_source == PolicySource::Global as i32; println!( " {} {}", @@ -1398,9 +1523,16 @@ pub async fn sandbox_get( /// data into memory before the server rejects an oversized message. const MAX_STDIN_PAYLOAD: usize = 4 * 1024 * 1024; +fn local_terminal_size() -> Option<(u32, u32)> { + crossterm::terminal::size() + .ok() + .map(|(cols, rows)| (u32::from(cols), u32::from(rows))) +} + /// Execute a command in a running sandbox via gRPC, streaming output to the terminal. /// -/// Returns the remote command's exit code. +/// Returns the remote command's exit code, or an error if the event stream +/// closes before the command reports an exit status. #[allow(clippy::too_many_arguments, clippy::implicit_hasher)] pub async fn sandbox_exec_grpc( server: &str, @@ -1410,6 +1542,7 @@ pub async fn sandbox_exec_grpc( timeout_seconds: u32, tty_override: Option, environment: &HashMap, + no_login_shell: bool, tls: &TlsOptions, workspace: &str, ) -> Result { @@ -1465,7 +1598,7 @@ pub async fn sandbox_exec_grpc( let tty = tty_override .unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal()); - if tty_override == Some(true) && std::io::stdin().is_terminal() { + if tty && std::io::stdin().is_terminal() { return sandbox_exec_interactive_grpc( client, &sandbox, @@ -1473,10 +1606,17 @@ pub async fn sandbox_exec_grpc( workdir, timeout_seconds, environment, + no_login_shell, ) .await; } + let (cols, rows) = if tty { + local_terminal_size().unwrap_or_default() + } else { + (0, 0) + }; + // Make the streaming gRPC call. let mut stream = client .exec_sandbox(ExecSandboxRequest { @@ -1487,7 +1627,9 @@ pub async fn sandbox_exec_grpc( timeout_seconds, stdin: stdin_payload, tty, - ..Default::default() + cols, + rows, + no_login_shell, }) .await .into_diagnostic()? @@ -1495,6 +1637,7 @@ pub async fn sandbox_exec_grpc( // Stream output to terminal in real-time. let mut exit_code = 0i32; + let mut exit_seen = false; let stdout = std::io::stdout(); let stderr = std::io::stderr(); @@ -1513,11 +1656,21 @@ pub async fn sandbox_exec_grpc( } Some(exec_sandbox_event::Payload::Exit(exit)) => { exit_code = exit.exit_code; + exit_seen = true; } None => {} } } + // A stream that closes without an Exit event means we never observed the + // command's outcome. The server treats the same condition as a relay + // failure; mirror that here so exit 0 stays meaningful. + if !exit_seen { + return Err(miette::miette!( + "sandbox exec relay closed before the command reported an exit status" + )); + } + Ok(exit_code) } @@ -1570,6 +1723,7 @@ pub async fn service_forward_tcp( let (socket, peer) = accepted .into_diagnostic() .wrap_err("failed to accept local forward connection")?; + set_tcp_nodelay_best_effort(&socket); let mut client = client.clone(); let sandbox_id = sandbox_id.clone(); let target_host = target_host.to_string(); @@ -1818,8 +1972,10 @@ impl Drop for RawModeGuard { } } +#[cfg(unix)] struct TaskGuard(tokio::task::JoinHandle<()>); +#[cfg(unix)] impl Drop for TaskGuard { fn drop(&mut self) { self.0.abort(); @@ -1833,11 +1989,14 @@ async fn sandbox_exec_interactive_grpc( workdir: Option<&str>, timeout_seconds: u32, environment: &HashMap, + no_login_shell: bool, ) -> Result { - use openshell_core::proto::{ExecSandboxInput, ExecSandboxWindowResize, exec_sandbox_input}; + #[cfg(unix)] + use openshell_core::proto::ExecSandboxWindowResize; + use openshell_core::proto::{ExecSandboxInput, exec_sandbox_input}; use tokio_stream::wrappers::ReceiverStream; - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + let (cols, rows) = local_terminal_size().unwrap_or((80, 24)); let (input_tx, input_rx) = tokio::sync::mpsc::channel::(4096); @@ -1849,11 +2008,12 @@ async fn sandbox_exec_interactive_grpc( command: command.to_vec(), workdir: workdir.unwrap_or_default().to_string(), environment: environment.clone(), + no_login_shell, timeout_seconds, stdin: Vec::new(), tty: true, - cols: u32::from(cols), - rows: u32::from(rows), + cols, + rows, })), }) .await @@ -1873,31 +2033,26 @@ async fn sandbox_exec_interactive_grpc( // spawn_blocking) so the tokio runtime shutdown doesn't wait for a // thread blocked on stdin.read(). The thread exits when the channel // closes (blocking_send returns Err) or stdin hits EOF. - #[cfg(unix)] - { - let stdin_tx = input_tx.clone(); - std::thread::spawn(move || { - let mut stdin = std::io::stdin().lock(); - let mut buf = [0u8; 4096]; - loop { - match stdin.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - if stdin_tx - .blocking_send(ExecSandboxInput { - payload: Some(exec_sandbox_input::Payload::Stdin( - buf[..n].to_vec(), - )), - }) - .is_err() - { - break; - } + let stdin_tx = input_tx.clone(); + std::thread::spawn(move || { + let mut stdin = std::io::stdin().lock(); + let mut buf = [0u8; 4096]; + loop { + match stdin.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if stdin_tx + .blocking_send(ExecSandboxInput { + payload: Some(exec_sandbox_input::Payload::Stdin(buf[..n].to_vec())), + }) + .is_err() + { + break; } } } - }); - } + } + }); // SIGWINCH handler: forward terminal resize events. #[cfg(unix)] @@ -1908,13 +2063,10 @@ async fn sandbox_exec_interactive_grpc( tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()) .expect("failed to register SIGWINCH handler"); while sig.recv().await.is_some() { - if let Ok((c, r)) = crossterm::terminal::size() { + if let Some((cols, rows)) = local_terminal_size() { let msg = ExecSandboxInput { payload: Some(exec_sandbox_input::Payload::Resize( - ExecSandboxWindowResize { - cols: u32::from(c), - rows: u32::from(r), - }, + ExecSandboxWindowResize { cols, rows }, )), }; if resize_tx.send(msg).await.is_err() { @@ -1928,6 +2080,7 @@ async fn sandbox_exec_interactive_grpc( let _resize_guard = TaskGuard(resize_task); let mut exit_code = 0i32; + let mut exit_seen = false; let stdout = std::io::stdout(); let stderr = std::io::stderr(); @@ -1946,6 +2099,7 @@ async fn sandbox_exec_interactive_grpc( } Some(exec_sandbox_event::Payload::Exit(exit)) => { exit_code = exit.exit_code; + exit_seen = true; break; } None => {} @@ -1957,6 +2111,15 @@ async fn sandbox_exec_interactive_grpc( // Drop the raw mode guard to restore the terminal before returning. drop(raw_guard); + // A stream that closes without an Exit event means we never observed the + // command's outcome. Treat it as a relay failure rather than reporting a + // successful (0) exit. + if !exit_seen { + return Err(miette::miette!( + "sandbox exec relay closed before the command reported an exit status" + )); + } + Ok(exit_code) } @@ -2063,8 +2226,16 @@ pub async fn sandbox_list( for sandbox in sandboxes { let phase = phase_name(sandbox.phase()); let phase_colored = match SandboxPhase::try_from(sandbox.phase()) { - Ok(SandboxPhase::Ready) => phase.green().to_string(), + Ok(SandboxPhase::Ready | SandboxPhase::Completed) => phase.green().to_string(), Ok(SandboxPhase::Error) => phase.red().to_string(), + Ok(SandboxPhase::Stopped) + if sandbox + .status + .as_ref() + .is_some_and(|status| status.exit_code.is_some()) => + { + phase.red().to_string() + } Ok(SandboxPhase::Provisioning) => phase.yellow().to_string(), Ok(SandboxPhase::Deleting) => phase.dimmed().to_string(), _ => phase.to_string(), @@ -2098,6 +2269,16 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { || serde_json::json!({}), |m| serde_json::json!(m.annotations), ); + let created_from_workload_template = + sandbox + .created_from_workload_template + .as_ref() + .map(|provenance| { + serde_json::json!({ + "name": provenance.name, + "resource_version": provenance.resource_version, + }) + }); serde_json::json!({ "id": sandbox.object_id(), "name": sandbox.object_name(), @@ -2108,6 +2289,8 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { "created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)), "phase": phase_name(sandbox.phase()), "current_policy_version": sandbox.current_policy_version(), + "exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code), + "created_from_workload_template": created_from_workload_template, }) } @@ -2153,201 +2336,581 @@ fn sandbox_detail_to_json( Ok(value) } -pub async fn sandbox_provider_list( +#[allow(clippy::too_many_arguments, clippy::implicit_hasher)] +pub async fn sandbox_template_create( server: &str, name: &str, + image: Option<&str>, + cpu: Option<&str>, + memory: Option<&str>, + gpu_requirements: Option, + driver_config_json: Option<&str>, + ready_within: Option<&str>, + max_burst: Option, + labels: HashMap, + annotations: HashMap, + environment: HashMap, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { + let resources = if cpu.is_some() || memory.is_some() || gpu_requirements.is_some() { + Some(SandboxResources { + cpu: cpu + .map(validate_cpu_quantity) + .transpose()? + .unwrap_or_default(), + memory: memory + .map(validate_memory_quantity) + .transpose()? + .unwrap_or_default(), + gpu: gpu_requirements, + }) + } else { + None + }; + let driver_config = driver_config_json + .map(parse_driver_config_json) + .transpose()?; + let desired_service_level = build_template_service_level(ready_within, max_burst)?; + let mut client = grpc_client(server, tls).await?; let response = client - .list_sandbox_providers(ListSandboxProvidersRequest { - sandbox_name: name.to_string(), + .create_sandbox_template(CreateSandboxTemplateRequest { + template: Some(SandboxWorkloadTemplate { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels, + resource_version: 0, + annotations, + workspace: String::new(), + deletion_timestamp_ms: 0, + }), + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + image: image.unwrap_or_default().to_string(), + environment, + resources, + }), + driver_config, + desired_service_level, + }), + }), workspace: workspace.to_string(), }) .await .into_diagnostic()?; - let providers = response.into_inner().providers; - if providers.is_empty() { - println!("No providers attached to sandbox {name}."); + let template = response + .into_inner() + .template + .ok_or_else(|| miette!("sandbox template missing from response"))?; + if crate::output::print_output_single(output, &template, sandbox_template_to_json)? { return Ok(()); } - - print_provider_attachment_table(&providers); + println!( + "{} Created sandbox template {}", + "✓".green().bold(), + template.object_name().bold() + ); Ok(()) } -pub async fn sandbox_provider_attach( +fn build_template_service_level( + ready_within: Option<&str>, + max_burst: Option, +) -> Result> { + if ready_within.is_none() && max_burst.is_none() { + return Ok(None); + } + let ready_within = ready_within + .map(parse_duration_to_ms) + .transpose()? + .map(|ms| { + if ms <= 0 { + Err(miette!("--ready-within must be greater than zero")) + } else { + Ok(duration_ms_to_proto(ms)) + } + }) + .transpose()?; + Ok(Some(SandboxServiceLevel { + startup: Some(SandboxStartup { + ready_within, + max_burst: max_burst.unwrap_or_default(), + }), + })) +} + +fn duration_ms_to_proto(ms: i64) -> prost_types::Duration { + prost_types::Duration { + seconds: ms / 1_000, + nanos: i32::try_from((ms % 1_000) * 1_000_000) + .expect("duration millisecond remainder fits in protobuf nanos"), + } +} + +pub async fn sandbox_template_get( server: &str, name: &str, - provider: &str, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; - - // Fetch current sandbox to get resource_version for CAS - let sandbox = client - .get_sandbox(GetSandboxRequest { + let response = client + .get_sandbox_template(GetSandboxTemplateRequest { name: name.to_string(), workspace: workspace.to_string(), }) .await - .into_diagnostic()? + .into_diagnostic()?; + let template = response .into_inner() - .sandbox - .ok_or_else(|| miette::miette!("sandbox not found"))?; - - let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); - - let response = match client - .attach_sandbox_provider(AttachSandboxProviderRequest { - sandbox_name: name.to_string(), - provider_name: provider.to_string(), - expected_resource_version: resource_version, - workspace: workspace.to_string(), - }) - .await - { - Ok(response) => response.into_inner(), - Err(status) if status.code() == Code::Aborted => { - return Err(miette::miette!( - "Failed to attach provider: sandbox was modified by another operation.\n\ - Please retry the command." - ) - .with_source_code(status.message().to_string())); - } - Err(e) => return Err(e).into_diagnostic(), - }; + .template + .ok_or_else(|| miette!("sandbox template missing from response"))?; - if response.attached { - println!( - "{} Attached provider {} to sandbox {}", - "✓".green().bold(), - provider, - name - ); - } else { - println!("Provider {provider} is already attached to sandbox {name}."); + if crate::output::print_output_single(output, &template, sandbox_template_to_json)? { + return Ok(()); } + + print_sandbox_template_detail(&template); Ok(()) } -pub async fn sandbox_provider_detach( +#[allow(clippy::too_many_arguments)] +pub async fn sandbox_template_list( server: &str, - name: &str, - provider: &str, + limit: u32, + offset: u32, + label_selector: Option<&str>, + names_only: bool, + output: &str, workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; - - // Fetch current sandbox to get resource_version for CAS - let sandbox = client - .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + let response = client + .list_sandbox_templates(ListSandboxTemplatesRequest { + limit, + offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, + label_selector: label_selector.unwrap_or_default().to_string(), }) .await - .into_diagnostic()? - .into_inner() - .sandbox - .ok_or_else(|| miette::miette!("sandbox not found"))?; + .into_diagnostic()?; + let templates = response.into_inner().templates; - let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); + if crate::output::print_output_collection(output, &templates, sandbox_template_to_json)? { + return Ok(()); + } - let response = match client - .detach_sandbox_provider(DetachSandboxProviderRequest { - sandbox_name: name.to_string(), - provider_name: provider.to_string(), - expected_resource_version: resource_version, - workspace: workspace.to_string(), - }) - .await - { - Ok(response) => response.into_inner(), - Err(status) if status.code() == Code::Aborted => { - return Err(miette::miette!( - "Failed to detach provider: sandbox was modified by another operation.\n\ - Please retry the command." - ) - .with_source_code(status.message().to_string())); + if templates.is_empty() { + if !names_only { + println!("No sandbox templates found."); } - Err(e) => return Err(e).into_diagnostic(), - }; + return Ok(()); + } - if response.detached { - println!( - "{} Detached provider {} from sandbox {}", - "✓".green().bold(), - provider, - name - ); - } else { - println!("Provider {provider} was not attached to sandbox {name}."); + if names_only { + for template in &templates { + if all_workspaces { + println!("{}/{}", template.object_workspace(), template.object_name()); + } else { + println!("{}", template.object_name()); + } + } + return Ok(()); + } + + print_sandbox_template_table(&templates, all_workspaces); + Ok(()) +} + +pub async fn sandbox_template_delete( + server: &str, + names: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + for name in names { + let response = client + .delete_sandbox_template(DeleteSandboxTemplateRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + if response.into_inner().deleted { + println!("{} Deleted sandbox template {name}", "✓".green().bold()); + } else { + println!("Sandbox template {name} not found."); + } } Ok(()) } -fn print_provider_attachment_table(providers: &[Provider]) { - print!("{}", format_provider_attachment_table(providers, true)); +fn sandbox_template_to_json(template: &SandboxWorkloadTemplate) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + obj.insert("id".to_string(), serde_json::json!(template.object_id())); + obj.insert( + "name".to_string(), + serde_json::json!(template.object_name()), + ); + obj.insert( + "workspace".to_string(), + serde_json::json!(template.object_workspace()), + ); + + if let Some(metadata) = &template.metadata { + if metadata.resource_version != 0 { + obj.insert( + "resource_version".to_string(), + serde_json::json!(metadata.resource_version), + ); + } + if metadata.created_at_ms != 0 { + obj.insert( + "created_at".to_string(), + serde_json::json!(format_epoch_ms(metadata.created_at_ms)), + ); + } + if !metadata.labels.is_empty() { + obj.insert("labels".to_string(), serde_json::json!(metadata.labels)); + } + if !metadata.annotations.is_empty() { + obj.insert( + "annotations".to_string(), + serde_json::json!(metadata.annotations), + ); + } + } + + if let Some(spec) = &template.spec { + if let Some(workload) = &spec.workload { + obj.insert("image".to_string(), serde_json::json!(workload.image)); + if !workload.environment.is_empty() { + obj.insert( + "environment".to_string(), + serde_json::json!(workload.environment), + ); + } + if let Some(resources) = &workload.resources { + let mut resources_json = serde_json::Map::new(); + if !resources.cpu.is_empty() { + resources_json.insert("cpu".to_string(), serde_json::json!(resources.cpu)); + } + if !resources.memory.is_empty() { + resources_json + .insert("memory".to_string(), serde_json::json!(resources.memory)); + } + if let Some(gpu) = &resources.gpu { + let value = gpu + .count + .map_or_else(|| serde_json::json!("default"), serde_json::Value::from); + resources_json.insert("gpu".to_string(), value); + } + if !resources_json.is_empty() { + obj.insert( + "resources".to_string(), + serde_json::Value::Object(resources_json), + ); + } + } + } + if let Some(driver_config) = &spec.driver_config { + obj.insert( + "driver_config".to_string(), + openshell_core::proto_struct::struct_to_json_value(driver_config), + ); + } + if let Some(service_level) = &spec.desired_service_level + && let Some(startup) = &service_level.startup + { + let mut startup_json = serde_json::Map::new(); + if let Some(ready_within) = &startup.ready_within { + startup_json.insert( + "ready_within_ms".to_string(), + serde_json::json!(duration_to_ms(ready_within)), + ); + } + if startup.max_burst != 0 { + startup_json.insert( + "max_burst".to_string(), + serde_json::json!(startup.max_burst), + ); + } + if !startup_json.is_empty() { + obj.insert( + "startup".to_string(), + serde_json::Value::Object(startup_json), + ); + } + } + } + + serde_json::Value::Object(obj) } -fn format_provider_attachment_table(providers: &[Provider], color: bool) -> String { - use std::fmt::Write as _; +fn print_sandbox_template_detail(template: &SandboxWorkloadTemplate) { + println!("{}", "Sandbox template:".cyan().bold()); + println!(); + println!(" {} {}", "Name:".dimmed(), template.object_name()); + println!( + " {} {}", + "Workspace:".dimmed(), + template.object_workspace() + ); + if let Some(metadata) = &template.metadata { + println!(" {} {}", "Id:".dimmed(), metadata.id); + println!( + " {} {}", + "Resource version:".dimmed(), + metadata.resource_version + ); + if metadata.created_at_ms != 0 { + println!( + " {} {}", + "Created:".dimmed(), + format_epoch_ms(metadata.created_at_ms) + ); + } + let labels = labels_display(&metadata.labels); + println!( + " {} {}", + "Labels:".dimmed(), + non_empty_or(&labels, "") + ); + } + if let Some(spec) = &template.spec + && let Some(workload) = &spec.workload + { + println!( + " {} {}", + "Image:".dimmed(), + non_empty_or(&workload.image, "") + ); + println!( + " {} {}", + "Environment:".dimmed(), + workload.environment.len() + ); + if let Some(resources) = &workload.resources { + println!( + " {} {}", + "CPU:".dimmed(), + non_empty_or(&resources.cpu, "") + ); + println!( + " {} {}", + "Memory:".dimmed(), + non_empty_or(&resources.memory, "") + ); + println!( + " {} {}", + "GPU:".dimmed(), + template_resources_gpu_display(resources).unwrap_or_else(|| "".to_string()) + ); + } + } + if let Some(startup) = template_startup(template) { + println!( + " {} {}", + "Ready within:".dimmed(), + startup + .ready_within + .as_ref() + .map_or_else(|| "".to_string(), duration_display) + ); + println!( + " {} {}", + "Max burst:".dimmed(), + if startup.max_burst == 0 { + "".to_string() + } else { + startup.max_burst.to_string() + } + ); + } +} - let name_width = providers +fn print_sandbox_template_table(templates: &[SandboxWorkloadTemplate], show_workspace: bool) { + let name_width = templates .iter() - .map(|provider| provider.object_name().len()) + .map(|template| template.object_name().len()) .max() .unwrap_or(4) .max(4); - let type_width = providers + let workspace_width = if show_workspace { + templates + .iter() + .map(|template| template.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; + let image_width = templates .iter() - .map(|provider| provider.r#type.len()) + .map(|template| template_image(template).len()) .max() - .unwrap_or(4) - .max(4); + .unwrap_or(5) + .clamp(5, 48); - let name_header = if color { - "NAME".bold().to_string() - } else { - "NAME".to_string() - }; - let type_header = if color { - "TYPE".bold().to_string() - } else { - "TYPE".to_string() - }; - let credential_keys_header = if color { - "CREDENTIAL_KEYS".bold().to_string() - } else { - "CREDENTIAL_KEYS".to_string() - }; - let config_keys_header = if color { - "CONFIG_KEYS".bold().to_string() + if show_workspace { + println!( + "{: String { + template + .spec + .as_ref() + .and_then(|spec| spec.workload.as_ref()) + .map_or_else( + || "".to_string(), + |workload| non_empty_or(&workload.image, "").to_string(), + ) +} - for provider in providers { - let provider_name = provider.object_name(); - let provider_type = &provider.r#type; - let credential_keys = provider.credentials.len(); - let config_keys = provider.config.len(); - let _ = writeln!( - output, - "{provider_name: Option<&SandboxResources> { + template + .spec + .as_ref() + .and_then(|spec| spec.workload.as_ref()) + .and_then(|workload| workload.resources.as_ref()) +} + +fn template_resources_gpu_display(resources: &SandboxResources) -> Option { + if let Some(gpu) = &resources.gpu { + return Some( + gpu.count + .map_or_else(|| "default".to_string(), |count| count.to_string()), ); } - output + None +} + +fn template_startup(template: &SandboxWorkloadTemplate) -> Option<&SandboxStartup> { + template + .spec + .as_ref() + .and_then(|spec| spec.desired_service_level.as_ref()) + .and_then(|service_level| service_level.startup.as_ref()) +} + +fn duration_to_ms(duration: &prost_types::Duration) -> i64 { + duration.seconds.saturating_mul(1_000) + i64::from(duration.nanos / 1_000_000) +} + +fn duration_display(duration: &prost_types::Duration) -> String { + let total_ms = duration_to_ms(duration); + if total_ms % 3_600_000 == 0 { + format!("{}h", total_ms / 3_600_000) + } else if total_ms % 60_000 == 0 { + format!("{}m", total_ms / 60_000) + } else if total_ms % 1_000 == 0 { + format!("{}s", total_ms / 1_000) + } else { + format!("{total_ms}ms") + } +} + +fn labels_display(labels: &HashMap) -> String { + let mut pairs = labels + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>(); + pairs.sort(); + pairs.join(", ") } /// Delete a sandbox by name, or all sandboxes when `all` is true. @@ -2386,6 +2949,7 @@ pub async fn sandbox_delete( names.to_vec() }; + let mut failures = Vec::new(); for name in &names_to_delete { // Stop any background port forwards for this sandbox before deleting. if let Ok(stopped) = stop_forwards_for_sandbox(name) { @@ -2397,13 +2961,28 @@ pub async fn sandbox_delete( } } - let response = client + let response = match client .delete_sandbox(DeleteSandboxRequest { name: name.clone(), workspace: workspace.to_string(), }) .await - .into_diagnostic()?; + { + Ok(response) => response, + Err(status) if status.code() == Code::NotFound => { + clear_last_sandbox_if_matches(gateway, workspace, name); + println!("{} Sandbox {name} already deleted", "✓".green().bold()); + continue; + } + Err(status) => { + eprintln!( + "{} Failed to delete sandbox {name}: {status}", + "!".red().bold() + ); + failures.push(name.clone()); + continue; + } + }; let deleted = response.into_inner().deleted; if deleted { @@ -2414,313 +2993,136 @@ pub async fn sandbox_delete( } } + aggregate_delete_failures("sandbox", &failures) +} + +/// Stop a sandbox while retaining its persistent workspace. +pub async fn sandbox_stop( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + if let Ok(stopped) = stop_forwards_for_sandbox(name) { + for port in stopped { + eprintln!( + "{} Stopped forward of port {port} for sandbox {name}", + "✓".green().bold(), + ); + } + } + + let mut client = grpc_client(server, tls).await?; + let sandbox = client + .stop_sandbox(StopSandboxRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette!("gateway returned no sandbox after stop"))?; + wait_for_lifecycle_phase(&mut client, sandbox, SandboxPhase::Stopped).await?; + println!("{} Stopped sandbox {name}", "✓".green().bold()); Ok(()) } -/// Return the provider type inferred from the trailing command, if any. -fn inferred_provider_type(command: &[String]) -> Option { - detect_provider_from_command(command).map(str::to_string) +/// Start a stopped sandbox and wait until it is ready. +pub async fn sandbox_start( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let sandbox = client + .start_sandbox(StartSandboxRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette!("gateway returned no sandbox after start"))?; + wait_for_lifecycle_phase(&mut client, sandbox, SandboxPhase::Ready).await?; + println!("{} Started sandbox {name}", "✓".green().bold()); + Ok(()) } -/// Ensure all required providers exist. -/// -/// `explicit_names` are provider **names** supplied via `--provider`. They are -/// passed through directly; the server validates they exist at sandbox creation. -/// -/// `inferred_types` are provider **types** inferred from the trailing command -/// (e.g. `claude` -> type `"claude-code"`). These are resolved to provider names via -/// a type→name lookup, and missing types may be auto-created interactively. -/// -/// Returns a deduplicated list of provider **names** suitable for -/// `SandboxSpec.providers`. -pub async fn ensure_required_providers( +async fn wait_for_lifecycle_phase( client: &mut crate::tls::GrpcClient, - explicit_names: &[String], - inferred_types: &[String], - auto_providers_override: Option, - workspace: &str, -) -> Result> { - if explicit_names.is_empty() && inferred_types.is_empty() { - return Ok(Vec::new()); + sandbox: Sandbox, + target: SandboxPhase, +) -> Result { + let current = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + if current == target { + return Ok(sandbox); } - - let mut configured_names: Vec = Vec::new(); - let mut seen_names: HashSet = HashSet::new(); - - // ── Fetch all existing providers ───────────────────────────────────── - // Build both a name set (for explicit --provider lookups) and a - // type-to-name map (for inferred provider resolution). - let mut known_names: HashSet = HashSet::new(); - let mut type_to_name: HashMap = HashMap::new(); - { - let mut offset = 0_u32; - let limit = 100_u32; - loop { - let response = client - .list_providers(ListProvidersRequest { - limit, - offset, - workspace: workspace.to_string(), - all_workspaces: false, - }) - .await - .into_diagnostic()?; - let providers = response.into_inner().providers; - for provider in &providers { - known_names.insert(provider.object_name().to_string()); - if !provider.r#type.is_empty() { - let type_lower = provider.r#type.to_ascii_lowercase(); - type_to_name - .entry(type_lower) - .or_insert_with(|| provider.object_name().to_string()); - } - } - if providers.len() < limit as usize { - break; - } - offset = offset.saturating_add(limit); - } + if current == SandboxPhase::Error { + let detail = ready_false_condition_message(sandbox.status.as_ref()) + .unwrap_or_else(|| "sandbox entered Error".to_string()); + return Err(miette!("{detail} while waiting for {target:?}")); } - // ── Explicit provider names ────────────────────────────────────────── - // If the name exists on the server, use it directly. Otherwise, if the - // name matches a known provider type, auto-create a provider of that - // type with the requested name. - for name in explicit_names { - if known_names.contains(name) { - if seen_names.insert(name.clone()) { - configured_names.push(name.clone()); - } - } else if let Some(provider_type) = normalize_provider_type(name) { - auto_create_provider( - client, - provider_type, - Some(name), - auto_providers_override, - &mut seen_names, - &mut configured_names, - workspace, - ) - .await?; - // Record the type mapping so the inferred-types pass below - // doesn't attempt to create a duplicate provider. - type_to_name - .entry(provider_type.to_ascii_lowercase()) - .or_insert_with(|| name.clone()); - } else { - return Err(miette::miette!( - "provider '{name}' not found and '{name}' is not a recognized provider type. \ - Create it first with `openshell provider create --type --name {name}`" - )); - } - } - - // ── Resolve inferred provider types ────────────────────────────────── - if !inferred_types.is_empty() { - // Collect resolved names for types that already have a provider. - for t in inferred_types { - if let Some(name) = type_to_name.get(&t.to_ascii_lowercase()) - && seen_names.insert(name.clone()) - { - configured_names.push(name.clone()); - } - } - - let missing = inferred_types - .iter() - .filter(|t| !type_to_name.contains_key(&t.to_ascii_lowercase())) - .cloned() - .collect::>(); - - for provider_type in missing { - auto_create_provider( - client, - &provider_type, - None, - auto_providers_override, - &mut seen_names, - &mut configured_names, - workspace, - ) - .await?; - } - } - - Ok(configured_names) -} - -/// Prompt for (or auto-confirm) creation of a provider from local credentials. -/// -/// When `preferred_name` is `Some`, the provider is created with that exact -/// name (used for explicit `--provider ` values). When `None`, the name -/// defaults to the type and retries with suffixes on conflict (used for -/// inferred provider types). -async fn auto_create_provider( - client: &mut crate::tls::GrpcClient, - provider_type: &str, - preferred_name: Option<&str>, - auto_providers_override: Option, - seen_names: &mut HashSet, - configured_names: &mut Vec, - workspace: &str, -) -> Result<()> { - eprintln!("Missing provider: {provider_type}"); - - // --no-auto-providers: skip silently. - if auto_providers_override == Some(false) { - eprintln!( - "{} Skipping provider '{provider_type}' (--no-auto-providers)", - "!".yellow(), - ); - eprintln!(); - return Ok(()); - } - - // No override and non-interactive: error. - if auto_providers_override.is_none() && !std::io::stdin().is_terminal() { - return Err(miette::miette!( - "missing required provider '{provider_type}'. Create it first with \ - `openshell provider create --type {provider_type} --name {provider_type} --from-existing`, \ - pass --auto-providers to auto-create, or set it up manually from inside the sandbox" - )); - } - - // --auto-providers: auto-confirm; otherwise prompt. - let should_create = if auto_providers_override == Some(true) { - true - } else { - Confirm::new() - .with_prompt("Create from local credentials?") - .default(true) - .interact() - .into_diagnostic()? - }; - - if !should_create { - eprintln!("{} Skipping provider '{provider_type}'", "!".yellow()); - eprintln!(); - return Ok(()); - } - - let discovered = discover_existing_provider_data(client, provider_type, workspace) + let timeout = Duration::from_secs( + std::env::var("OPENSHELL_LIFECYCLE_TIMEOUT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(300), + ); + let sandbox_id = sandbox.object_id().to_string(); + let mut stream = client + .watch_sandbox(WatchSandboxRequest { + id: sandbox_id, + follow_status: true, + follow_logs: false, + follow_events: false, + log_tail_lines: 0, + event_tail: 0, + stop_on_terminal: false, + log_since_ms: 0, + log_sources: Vec::new(), + log_min_level: String::new(), + }) .await - .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; - let Some(discovered) = discovered else { - eprintln!( - "{} No existing local credentials/config found for '{}'. You can configure it from inside the sandbox.", - "!".yellow(), - provider_type - ); - eprintln!(); - return Ok(()); - }; - - if let Some(exact_name) = preferred_name { - // Explicit name: create with exactly that name, no retries. - let request = CreateProviderRequest { - provider: Some(Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: exact_name.to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: workspace.to_string(), - deletion_timestamp_ms: 0, - }), - r#type: provider_type.to_string(), - credentials: discovered.credentials.clone(), - config: discovered.config.clone(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: workspace.to_string(), - }), - workspace: workspace.to_string(), - }; + .into_diagnostic()? + .into_inner(); - let response = client.create_provider(request).await.map_err(|status| { - miette::miette!("failed to create provider '{exact_name}': {status}") - })?; - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - eprintln!( - "{} Created provider {} ({}) from existing local state", - "✓".green().bold(), - provider.object_name(), - provider.r#type - ); - if seen_names.insert(provider.object_name().to_string()) { - configured_names.push(provider.object_name().to_string()); + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(miette!( + "timed out after {}s waiting for sandbox to reach {target:?}", + timeout.as_secs() + )); } - } else { - // Inferred type: try type as name, then suffixed variants. - let mut created = false; - for attempt in 0..5 { - let name = if attempt == 0 { - provider_type.to_string() - } else { - format!("{provider_type}-{attempt}") - }; - - let request = CreateProviderRequest { - provider: Some(Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: name.clone(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: workspace.to_string(), - deletion_timestamp_ms: 0, - }), - r#type: provider_type.to_string(), - credentials: discovered.credentials.clone(), - config: discovered.config.clone(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: workspace.to_string(), - }), - workspace: workspace.to_string(), - }; - - match client.create_provider(request).await { - Ok(response) => { - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - eprintln!( - "{} Created provider {} ({}) from existing local state", - "✓".green().bold(), - provider.object_name(), - provider.r#type - ); - if seen_names.insert(provider.object_name().to_string()) { - configured_names.push(provider.object_name().to_string()); - } - created = true; - break; - } - Err(status) if status.code() == Code::AlreadyExists => {} - Err(status) => { - return Err(miette::miette!( - "failed to create provider for type '{provider_type}': {status}" - )); - } + let event = tokio::time::timeout(remaining, stream.next()) + .await + .map_err(|_| { + miette!( + "timed out after {}s waiting for sandbox to reach {target:?}", + timeout.as_secs() + ) + })? + .ok_or_else(|| miette!("sandbox watch ended before reaching {target:?}"))? + .into_diagnostic()?; + if let Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(sandbox)) = + event.payload + { + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + if phase == target { + return Ok(sandbox); + } + if phase == SandboxPhase::Error { + let detail = ready_false_condition_message(sandbox.status.as_ref()) + .unwrap_or_else(|| "sandbox entered Error".to_string()); + return Err(miette!(detail)); } - } - - if !created { - return Err(miette::miette!( - "failed to create provider for type '{provider_type}' after name retries" - )); } } - - eprintln!(); - Ok(()) } pub async fn service_expose( @@ -2771,6 +3173,7 @@ 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, sandbox: Option<&str>, @@ -2778,6 +3181,7 @@ pub async fn service_list( offset: u32, workspace: &str, all_workspaces: bool, + output: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -2797,6 +3201,15 @@ pub async fn service_list( .map_err(|status| service_status_error("list services", "sandbox:read", status))? .into_inner(); + let services = response + .services + .iter() + .filter_map(|response| service_endpoint_to_json(response, server)) + .collect::>(); + if crate::output::print_output_collection(output, &services, Clone::clone)? { + return Ok(()); + } + if response.services.is_empty() { if let Some(sandbox) = sandbox { println!("No services exposed for sandbox {sandbox}."); @@ -2962,1613 +3375,80 @@ fn print_service_endpoint_table( "TARGET".bold(), "URL".bold(), ); - } else { - println!( - "{: &str { - if service.is_empty() { "-" } else { service } -} - -/// Read gcloud Application Default Credentials from disk. -/// -/// Returns `(client_id, client_secret, refresh_token)`. -/// -/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to -/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to -/// `~/.config/gcloud/application_default_credentials.json`. -fn read_gcloud_adc() -> Result<(String, String, String)> { - let path = if let Some(env_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") - .ok() - .filter(|v| !v.is_empty()) - { - PathBuf::from(env_path) - } else if let Some(config_dir) = std::env::var("CLOUDSDK_CONFIG") - .ok() - .filter(|v| !v.is_empty()) - { - PathBuf::from(config_dir).join("application_default_credentials.json") - } else { - let home = std::env::var("HOME") - .map_err(|_| miette::miette!("HOME is not set; cannot locate gcloud ADC file"))?; - PathBuf::from(home) - .join(".config") - .join("gcloud") - .join("application_default_credentials.json") - }; - - let content = std::fs::read_to_string(&path).map_err(|err| { - miette::miette!( - "failed to read gcloud ADC file at {}: {}. \ - Run: gcloud auth application-default login", - path.display(), - err - ) - })?; - - let json: serde_json::Value = serde_json::from_str(&content) - .map_err(|err| miette::miette!("failed to parse gcloud ADC file: {err}"))?; - - let cred_type = json.get("type").and_then(|v| v.as_str()); - match cred_type { - Some("service_account") => { - return Err(miette::miette!( - "Application Default Credentials are a service account key, not user credentials. \ - To use a service account, create the provider with the service account JSON key \ - and configure gateway-managed refresh for 'GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN'. \ - See: openshell provider create --help" - )); - } - Some("authorized_user") => {} - Some(other) => { - return Err(miette::miette!( - "Application Default Credentials have unsupported type '{other}' \ - (expected 'authorized_user'). \ - Run: gcloud auth application-default login" - )); - } - None => { - return Err(miette::miette!( - "gcloud ADC file is missing the 'type' field. \ - The file may be malformed. \ - Run: gcloud auth application-default login" - )); - } - } - - let client_id = json - .get("client_id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_id'"))? - .to_string(); - - let client_secret = json - .get("client_secret") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_secret'"))? - .to_string(); - - let refresh_token = json - .get("refresh_token") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'refresh_token'"))? - .to_string(); - - Ok((client_id, client_secret, refresh_token)) -} - -async fn rollback_provider_create_after_gcloud_adc_failure( - client: &mut crate::tls::GrpcClient, - provider_name: &str, - stage: &str, - source: &Status, - workspace: &str, -) -> Result<()> { - match client - .delete_provider(DeleteProviderRequest { - name: provider_name.to_string(), - workspace: workspace.to_string(), - }) - .await - { - Ok(_) => Err(miette!( - "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ - The provider was rolled back successfully." - )), - Err(cleanup_err) => { - eprintln!( - "{} Failed to clean up provider '{}' after {} failed: {}. \ - Run 'openshell provider delete {}' to remove it manually.", - "⚠".yellow(), - provider_name, - stage, - cleanup_err, - provider_name - ); - Err(miette!( - "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ - Cleanup also failed, so the provider may still exist. \ - Run 'openshell provider delete {provider_name}' to remove it manually." - )) - } - } -} - -fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String { - let (Ok(mut service_url), Ok(gateway_endpoint)) = ( - url::Url::parse(service_url), - url::Url::parse(gateway_endpoint), - ) else { - return service_url.to_string(); - }; - - if service_url - .set_port(gateway_endpoint.port_or_known_default()) - .is_err() - { - return service_url.to_string(); - } - - service_url.to_string() -} - -async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Result { - let response = client - .get_gateway_config(GetGatewayConfigRequest {}) - .await - .into_diagnostic()? - .into_inner(); - let Some(setting) = response.settings.get(settings::PROVIDERS_V2_ENABLED_KEY) else { - return Ok(false); - }; - match setting.value.as_ref() { - Some(setting_value::Value::BoolValue(enabled)) => Ok(*enabled), - None => Ok(false), - Some(_) => Err(miette::miette!( - "gateway setting '{}' has invalid value type; expected bool", - settings::PROVIDERS_V2_ENABLED_KEY - )), - } -} - -async fn fetch_provider_profile( - client: &mut crate::tls::GrpcClient, - provider_type: &str, - workspace: &str, -) -> Result { - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: provider_type.to_string(), - workspace: workspace.to_string(), - }) - .await - .map_err(|status| { - if status.code() == Code::NotFound { - miette::miette!( - "provider profile '{provider_type}' not found; providers v2 discovery requires a provider profile" - ) - } else { - miette::miette!(status.to_string()) - } - })?; - - response - .into_inner() - .profile - .ok_or_else(|| miette::miette!("provider profile '{provider_type}' missing from response")) -} - -async fn discover_existing_provider_data( - client: &mut crate::tls::GrpcClient, - provider_type: &str, - workspace: &str, -) -> Result> { - if gateway_providers_v2_enabled(client).await? { - let profile = fetch_provider_profile(client, provider_type, workspace).await?; - let profile = ProviderTypeProfile::from_proto(&profile); - let mut discovered = - discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { - miette::miette!("failed to discover existing provider data from profile: {err}") - })?; - - // Vertex AI config keys (project ID, region, base URL, publisher) are not - // declared in the profile's discovery.credentials list, so discover_from_profile - // does not scan them. Scan them directly here so --from-existing captures them. - if provider_type == VERTEX_AI_PROVIDER_TYPE { - let discovered = discovered.get_or_insert_with(Default::default); - for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { - if let Ok(val) = std::env::var(key) { - let val = val.trim().to_string(); - if !val.is_empty() { - discovered.config.entry(key.to_string()).or_insert(val); - } - } - } - } - - Ok(discovered) - } else { - let registry = ProviderRegistry::new(); - registry - .discover_existing(provider_type) - .map_err(|err| miette::miette!("failed to discover existing provider data: {err}")) - } -} - -/// Canonical provider type string for Google Vertex AI. -const VERTEX_AI_PROVIDER_TYPE: &str = "google-vertex-ai"; - -/// Canonical provider type string for Google Cloud (GCP APIs). -const GOOGLE_CLOUD_PROVIDER_TYPE: &str = "google-cloud"; - -fn missing_credentials_error(provider_type: &str) -> miette::Report { - if provider_type == VERTEX_AI_PROVIDER_TYPE { - return miette::miette!( - "no credentials resolved for provider type '{provider_type}'. \ - Set GOOGLE_VERTEX_AI_TOKEN, VERTEX_AI_TOKEN, \ - GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN, or VERTEX_AI_SERVICE_ACCOUNT_TOKEN; \ - or use --from-gcloud-adc / --from-existing with those env vars set." - ); - } - - if provider_type == GOOGLE_CLOUD_PROVIDER_TYPE { - return miette::miette!( - "no credentials resolved for provider type '{provider_type}'. \ - Set GCP_ADC_ACCESS_TOKEN or GCP_SA_ACCESS_TOKEN; \ - or use --from-gcloud-adc / --from-existing with those env vars set." - ); - } - - miette::miette!( - "no credentials resolved for provider type '{provider_type}'. \ - Use --credential KEY[=VALUE], --runtime-credentials for runtime-resolved profile credentials, \ - or --from-existing with the appropriate env vars set." - ) -} - -#[allow(clippy::too_many_arguments)] -pub async fn provider_create( - server: &str, - name: &str, - provider_type: &str, - from_existing: bool, - credentials: &[String], - from_gcloud_adc: bool, - config: &[String], - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - provider_create_with_options( - server, - name, - provider_type, - from_existing, - credentials, - from_gcloud_adc, - false, - config, - workspace, - workspace, - tls, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -pub async fn provider_create_with_options( - server: &str, - name: &str, - provider_type: &str, - from_existing: bool, - credentials: &[String], - from_gcloud_adc: bool, - runtime_credentials: bool, - config: &[String], - workspace: &str, - profile_workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { - return Err(miette::miette!( - "--from-gcloud-adc cannot be combined with --from-existing or --credential; it also cannot be combined with --runtime-credentials" - )); - } - if from_existing && (!credentials.is_empty() || runtime_credentials) { - return Err(miette::miette!( - "--from-existing cannot be combined with --credential or --runtime-credentials" - )); - } - if runtime_credentials && !credentials.is_empty() { - return Err(miette::miette!( - "--runtime-credentials cannot be combined with --credential" - )); - } - - let mut client = grpc_client(server, tls).await?; - - let provider_type = if let Some(provider_type) = normalize_provider_type(provider_type) { - provider_type.to_string() - } else { - let profile_id = provider_type.trim(); - if profile_id.is_empty() { - return Err(miette::miette!("provider type is required")); - } - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: profile_id.to_string(), - workspace: profile_workspace.to_string(), - }) - .await; - match response { - Ok(response) => response - .into_inner() - .profile - .map(|profile| profile.id) - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(|| profile_id.to_string()), - Err(status) if status.code() == Code::NotFound => { - return Err(miette::miette!( - "unsupported provider type or profile: {provider_type}" - )); - } - Err(status) => return Err(status).into_diagnostic(), - } - }; - - let adc_credential_key = if from_gcloud_adc { - let profile = fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .map_err(|err| { - miette::miette!( - "--from-gcloud-adc is not supported for '{provider_type}' providers ({err})" - ) - })?; - let profile = ProviderTypeProfile::from_proto(&profile); - let adc_cred = profile.adc_credential().ok_or_else(|| { - miette::miette!( - "--from-gcloud-adc is not supported for '{provider_type}' providers \ - (no ADC-compatible credential in the provider profile)" - ) - })?; - Some( - adc_cred - .env_vars - .first() - .ok_or_else(|| { - miette::miette!( - "ADC credential in '{provider_type}' profile has no env_vars declared" - ) - })? - .clone(), - ) - } else { - None - }; - - let mut credential_map = parse_credential_pairs(credentials)?; - let mut config_map = parse_key_value_pairs(config, "--config")?; - - if from_existing { - let discovered = - discover_existing_provider_data(&mut client, &provider_type, profile_workspace).await?; - let Some(discovered) = discovered else { - return Err(miette::miette!( - "no existing local credentials/config found for provider type '{provider_type}'" - )); - }; - - for (key, value) in discovered.credentials { - credential_map.entry(key).or_insert(value); - } - for (key, value) in discovered.config { - config_map.entry(key).or_insert(value); - } - } - - if credential_map.is_empty() { - if from_existing { - return Err(missing_credentials_error(&provider_type)); - } - if !from_gcloud_adc && !runtime_credentials { - return Err(missing_credentials_error(&provider_type)); - } - let allows_empty_credentials = if runtime_credentials { - provider_profile_allows_empty_credentials( - &fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?, - ) - } else { - fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .ok() - .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) - }; - if !allows_empty_credentials { - if runtime_credentials { - return Err(miette::miette!( - "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" - )); - } - return Err(missing_credentials_error(&provider_type)); - } - } - - // Validate and read the ADC file BEFORE creating the provider so that - // a bad/missing ADC does not leave an orphan provider behind. Bundle the - // credential key with the material so they stay coupled. - let gcloud_adc_bootstrap = if from_gcloud_adc { - let (client_id, client_secret, refresh_token) = read_gcloud_adc()?; - let key = adc_credential_key.expect("set when from_gcloud_adc is true"); - Some((key, client_id, client_secret, refresh_token)) - } else { - None - }; - - let response = client - .create_provider(CreateProviderRequest { - provider: Some(Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: name.to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: workspace.to_string(), - deletion_timestamp_ms: 0, - }), - r#type: provider_type.clone(), - credentials: credential_map, - config: config_map, - credential_expires_at_ms: HashMap::new(), - profile_workspace: profile_workspace.to_string(), - }), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - let provider_name = provider.object_name().to_string(); - - if let Some((adc_credential_key, client_id, client_secret, refresh_token)) = - gcloud_adc_bootstrap - { - let mut material = HashMap::new(); - material.insert("client_id".to_string(), client_id); - material.insert("client_secret".to_string(), client_secret); - material.insert("refresh_token".to_string(), refresh_token); - - if let Err(configure_err) = client - .configure_provider_refresh(ConfigureProviderRefreshRequest { - provider: provider_name.clone(), - credential_key: adc_credential_key.clone(), - strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, - material, - secret_material_keys: vec![ - "client_secret".to_string(), - "refresh_token".to_string(), - ], - expires_at_ms: None, - workspace: workspace.to_string(), - }) - .await - { - return rollback_provider_create_after_gcloud_adc_failure( - &mut client, - &provider_name, - "configure", - &configure_err, - workspace, - ) - .await; - } - - if let Err(rotate_err) = client - .rotate_provider_credential(RotateProviderCredentialRequest { - provider: provider_name.clone(), - credential_key: adc_credential_key, - workspace: workspace.to_string(), - }) - .await - { - return rollback_provider_create_after_gcloud_adc_failure( - &mut client, - &provider_name, - "mint the initial access token for", - &rotate_err, - workspace, - ) - .await; - } - - println!("{} Created provider {}", "✓".green().bold(), provider_name); - println!("Configured GCP credentials from gcloud ADC and minted the initial access token"); - return Ok(()); - } - - println!("{} Created provider {}", "✓".green().bold(), provider_name); - Ok(()) -} - -fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool { - ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() -} - -pub async fn provider_get( - server: &str, - name: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .get_provider(GetProviderRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - - let credential_keys = provider.credentials.keys().cloned().collect::>(); - let config_keys = provider.config.keys().cloned().collect::>(); - - println!("{}", "Provider:".cyan().bold()); - println!(); - println!(" {} {}", "Id:".dimmed(), provider.object_id()); - println!(" {} {}", "Name:".dimmed(), provider.object_name()); - println!(" {} {}", "Type:".dimmed(), provider.r#type); - println!( - " {} {}", - "Resource version:".dimmed(), - provider.metadata.as_ref().map_or(0, |m| m.resource_version) - ); - println!( - " {} {}", - "Credential keys:".dimmed(), - if credential_keys.is_empty() { - "".to_string() - } else { - credential_keys.join(", ") - } - ); - println!( - " {} {}", - "Config keys:".dimmed(), - if config_keys.is_empty() { - "".to_string() - } else { - config_keys.join(", ") - } - ); - - Ok(()) -} - -fn provider_to_json(provider: &Provider) -> serde_json::Value { - let mut obj = serde_json::Map::new(); - - // Core fields - obj.insert("id".to_string(), serde_json::json!(provider.object_id())); - obj.insert( - "name".to_string(), - serde_json::json!(provider.object_name()), - ); - obj.insert( - "workspace".to_string(), - serde_json::json!(provider.object_workspace()), - ); - obj.insert("type".to_string(), serde_json::json!(provider.r#type)); - - // Credential keys (NEVER values - security) - let credential_keys: Vec = provider.credentials.keys().cloned().collect(); - obj.insert( - "credential_keys".to_string(), - serde_json::json!(credential_keys), - ); - - // Config keys (keys only, not values) - if !provider.config.is_empty() { - let config_keys: Vec = provider.config.keys().cloned().collect(); - obj.insert("config_keys".to_string(), serde_json::json!(config_keys)); - } - - // Metadata fields (only if metadata exists) - if let Some(meta) = &provider.metadata { - if !meta.labels.is_empty() { - obj.insert("labels".to_string(), serde_json::json!(meta.labels)); - } - if meta.resource_version != 0 { - obj.insert( - "resource_version".to_string(), - serde_json::json!(meta.resource_version), - ); - } - if meta.created_at_ms != 0 { - obj.insert( - "created_at".to_string(), - serde_json::json!(format_epoch_ms(meta.created_at_ms)), - ); - } - } - - // Credential expiration times (only if present) - if !provider.credential_expires_at_ms.is_empty() { - obj.insert( - "credential_expires_at_ms".to_string(), - serde_json::json!(provider.credential_expires_at_ms), - ); - } - - serde_json::Value::Object(obj) -} - -#[allow(clippy::too_many_arguments)] -pub async fn provider_list( - server: &str, - limit: u32, - offset: u32, - names_only: bool, - output: &str, - workspace: &str, - all_workspaces: bool, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .list_providers(ListProvidersRequest { - limit, - offset, - workspace: if all_workspaces { - String::new() - } else { - workspace.to_string() - }, - all_workspaces, - }) - .await - .into_diagnostic()?; - let providers = response.into_inner().providers; - - // Handle structured output formats (json, yaml) - if crate::output::print_output_collection(output, &providers, provider_to_json)? { - return Ok(()); - } - - if providers.is_empty() { - if !names_only { - println!("No providers found."); - } - return Ok(()); - } - - if names_only { - for provider in &providers { - if all_workspaces { - println!("{}/{}", provider.object_workspace(), provider.object_name()); - } else { - println!("{}", provider.object_name()); - } - } - return Ok(()); - } - - let ws_width = if all_workspaces { - providers - .iter() - .map(|p| p.object_workspace().len()) - .max() - .unwrap_or(9) - .max(9) - } else { - 0 - }; - let name_width = providers - .iter() - .map(|provider| provider.object_name().len()) - .max() - .unwrap_or(4) - .max(4); - let type_width = providers - .iter() - .map(|provider| provider.r#type.len()) - .max() - .unwrap_or(4) - .max(4); - - if all_workspaces { - println!( - "{: Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .list_provider_profiles(ListProviderProfilesRequest { - limit: 100, - offset: 0, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - let mut profiles = response.into_inner().profiles; - profiles.sort_by(|left, right| { - left.category - .cmp(&right.category) - .then_with(|| left.id.cmp(&right.id)) - }); - let dto_profiles = profiles - .iter() - .map(ProviderTypeProfile::from_proto) - .collect::>(); - - if crate::output::print_output_direct( - output, - || profiles_to_json(&dto_profiles).into_diagnostic(), - || profiles_to_yaml(&dto_profiles).into_diagnostic(), - )? { - return Ok(()); - } - - if profiles.is_empty() { - println!("No provider profiles found."); - return Ok(()); - } - - println!("{}", "Available Provider Profiles:".cyan().bold()); - let id_width = provider_profile_id_width(&profiles); - let display_width = provider_profile_display_width(&profiles); - let source_width = provider_profile_source_width(&profiles); - let scope_width = provider_profile_scope_width(&profiles); - let mut current_category = i32::MIN; - for profile in &profiles { - if profile.category != current_category { - current_category = profile.category; - println!(); - println!(" {}", display_provider_category(current_category).bold()); - print_provider_type_header(id_width, scope_width, source_width, display_width); - } - print_provider_type_row(profile, id_width, scope_width, source_width, display_width); - } - - Ok(()) -} - -pub async fn provider_profile_export( - server: &str, - id: &str, - output: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; - if output == "json" { - println!("{rendered}"); - } else { - print!("{rendered}"); - } - Ok(()) -} - -pub async fn provider_profile_export_text( - server: &str, - id: &str, - output: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result { - let mut client = grpc_client(server, tls).await?; - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: id.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - let profile = response - .into_inner() - .profile - .ok_or_else(|| miette!("provider profile '{id}' not found"))?; - let profile = ProviderTypeProfile::from_proto(&profile); - - match output { - "json" => profile_to_json(&profile).into_diagnostic(), - "yaml" => profile_to_yaml(&profile).into_diagnostic(), - "table" => Err(miette!( - "profile export supports '-o yaml' and '-o json'; table output is not supported" - )), - _ => Err(miette!("unsupported output format: {output}")), - } -} - -pub async fn provider_profile_import( - server: &str, - file: Option<&Path>, - from: Option<&Path>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let (items, mut diagnostics) = load_profile_import_items(file, from)?; - if items.is_empty() && diagnostics.is_empty() { - return Err(miette!("no provider profile files found")); - } - if profile_diagnostics_have_errors(&diagnostics) { - print_profile_diagnostics(&diagnostics); - return Err(miette!("provider profile import failed")); - } - - let mut client = grpc_client(server, tls).await?; - if !items.is_empty() { - let response = client - .import_provider_profiles(ImportProviderProfilesRequest { - profiles: items, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - diagnostics.extend(response.diagnostics); - if response.imported { - println!( - "Imported {} provider profile{}.", - response.profiles.len(), - if response.profiles.len() == 1 { - "" - } else { - "s" - } - ); - return Ok(()); - } - } - - print_profile_diagnostics(&diagnostics); - Err(miette!("provider profile import failed")) -} - -pub async fn provider_profile_update( - server: &str, - id: &str, - file: &Path, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?; - if items.is_empty() && diagnostics.is_empty() { - return Err(miette!("no provider profile files found")); - } - if profile_diagnostics_have_errors(&diagnostics) { - print_profile_diagnostics(&diagnostics); - return Err(miette!("provider profile update failed")); - } - - let mut client = grpc_client(server, tls).await?; - if let Some(item) = items.pop() { - let expected_resource_version = item - .profile - .as_ref() - .map_or(0, |profile| profile.resource_version); - let response = client - .update_provider_profiles(UpdateProviderProfilesRequest { - profile: Some(item), - expected_resource_version, - id: id.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - diagnostics.extend(response.diagnostics); - if response.updated { - println!("Updated provider profile."); - return Ok(()); - } - } - - print_profile_diagnostics(&diagnostics); - Err(miette!("provider profile update failed")) -} - -pub async fn provider_profile_lint( - server: &str, - file: Option<&Path>, - from: Option<&Path>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let (items, mut diagnostics) = load_profile_import_items(file, from)?; - if items.is_empty() && diagnostics.is_empty() { - return Err(miette!("no provider profile files found")); - } - - if !items.is_empty() { - let mut client = grpc_client(server, tls).await?; - let response = client - .lint_provider_profiles(LintProviderProfilesRequest { - profiles: items, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - diagnostics.extend(response.diagnostics); - } - - if profile_diagnostics_have_errors(&diagnostics) { - print_profile_diagnostics(&diagnostics); - return Err(miette!("provider profile lint failed")); - } - - println!("Provider profile lint passed."); - Ok(()) -} - -pub async fn provider_profile_delete( - server: &str, - id: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .delete_provider_profile(DeleteProviderProfileRequest { - id: id.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - if response.deleted { - println!("Deleted provider profile '{id}'."); - } else { - println!("Provider profile '{id}' was not deleted."); - } - Ok(()) -} - -pub async fn provider_refresh_status( - server: &str, - name: &str, - credential_key: Option<&str>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .get_provider_refresh_status(GetProviderRefreshStatusRequest { - provider: name.to_string(), - credential_key: credential_key.unwrap_or_default().to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - - if response.credentials.is_empty() { - if let Some(credential_key) = credential_key { - println!( - "No refresh configuration found for provider '{name}' credential '{credential_key}'." - ); - } else { - println!("No refresh configurations found for provider '{name}'."); - } - return Ok(()); - } - - println!("{}", refresh_status_header()); - for status in response.credentials { - print_refresh_status_row(&status); - } - Ok(()) -} - -fn refresh_status_header() -> String { - format!( - "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", - "PROVIDER".bold(), - "CREDENTIAL_KEY".bold(), - "STRATEGY".bold(), - "STATUS".bold(), - "EXPIRES_AT".bold(), - "NEXT_REFRESH".bold(), - "LAST_REFRESH".bold(), - "LAST_ERROR".bold(), - ) -} - -pub struct ProviderRefreshConfigInput<'a> { - pub name: &'a str, - pub credential_key: &'a str, - pub strategy: &'a str, - pub material: &'a [String], - pub secret_material_env: &'a [String], - pub secret_material_keys: &'a [String], - pub credential_expires_at_ms: Option, -} - -pub async fn provider_refresh_config( - server: &str, - input: ProviderRefreshConfigInput<'_>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let strategy = provider_refresh_strategy(input.strategy)?; - let mut material = parse_key_value_pairs(input.material, "--material")?; - let mut secret_material_keys = input.secret_material_keys.to_vec(); - // Env-resolved secrets are auto-marked secret; duplicate keys are an - // error rather than a precedence order. - for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? { - if material.contains_key(&key) { - return Err(miette!( - "duplicate material key '{key}': supplied via both --material and --secret-material-env" - )); - } - if !secret_material_keys.contains(&key) { - secret_material_keys.push(key.clone()); - } - material.insert(key, value); - } - let mut client = grpc_client(server, tls).await?; - let status = client - .configure_provider_refresh(ConfigureProviderRefreshRequest { - provider: input.name.to_string(), - credential_key: input.credential_key.to_string(), - strategy: strategy as i32, - material, - secret_material_keys, - expires_at_ms: input.credential_expires_at_ms, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .status - .ok_or_else(|| miette!("provider refresh status missing from response"))?; - - println!( - "{} Configured refresh for {} {}", - "✓".green().bold(), - status.provider_name, - status.credential_key - ); - Ok(()) -} - -pub async fn provider_rotate( - server: &str, - name: &str, - credential_key: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let status = client - .rotate_provider_credential(RotateProviderCredentialRequest { - provider: name.to_string(), - credential_key: credential_key.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .status - .ok_or_else(|| miette!("provider refresh status missing from response"))?; - - if status.last_error.is_empty() { - println!( - "{} Rotation requested for {} {} ({})", - "✓".green().bold(), - status.provider_name, - status.credential_key, - status.status - ); - } else { - println!( - "Rotation request recorded for {} {} ({}): {}", - status.provider_name, status.credential_key, status.status, status.last_error - ); - } - Ok(()) -} - -pub async fn provider_refresh_delete( - server: &str, - name: &str, - credential_key: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .delete_provider_refresh(DeleteProviderRefreshRequest { - provider: name.to_string(), - credential_key: credential_key.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - - if response.deleted { - println!( - "{} Deleted refresh config for {} {}", - "✓".green().bold(), - name, - credential_key - ); - } else { - println!("No refresh config found for provider '{name}' credential '{credential_key}'."); - } - Ok(()) -} - -fn provider_refresh_strategy(strategy: &str) -> Result { - match strategy { - "oauth2_refresh_token" => Ok(ProviderCredentialRefreshStrategy::Oauth2RefreshToken), - "oauth2_client_credentials" => { - Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) - } - "google_service_account_jwt" => { - Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) - } - "aws_sts_assume_role" => Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), - _ => Err(miette!("unsupported provider refresh strategy: {strategy}")), - } -} - -fn print_refresh_status_row(status: &ProviderCredentialRefreshStatus) { - println!("{}", refresh_status_row(status)); -} - -fn refresh_status_row(status: &ProviderCredentialRefreshStatus) -> String { - let strategy = ProviderCredentialRefreshStrategy::try_from(status.strategy) - .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); - format!( - "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", - status.provider_name, - status.credential_key, - provider_refresh_strategy_name(strategy), - status.status, - format_optional_epoch_ms(status.expires_at_ms), - format_optional_epoch_ms(status.next_refresh_at_ms), - format_optional_epoch_ms(status.last_refresh_at_ms), - truncate_status_field(&status.last_error, 72), - ) -} - -fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -> &'static str { - match strategy { - ProviderCredentialRefreshStrategy::Static => "static", - ProviderCredentialRefreshStrategy::External => "external", - ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", - ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", - ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", - ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", - ProviderCredentialRefreshStrategy::Unspecified => "unspecified", - } -} - -fn load_profile_import_items( - file: Option<&Path>, - from: Option<&Path>, -) -> Result<( - Vec, - Vec, -)> { - let paths = profile_source_paths(file, from)?; - let mut items = Vec::new(); - let mut diagnostics = Vec::new(); - for path in paths { - match load_profile_import_item(&path) { - Ok(item) => items.push(item), - Err(diagnostic) => diagnostics.push(diagnostic), - } - } - Ok((items, diagnostics)) -} - -fn profile_source_paths(file: Option<&Path>, from: Option<&Path>) -> Result> { - if let Some(file) = file { - return Ok(vec![file.to_path_buf()]); - } - let Some(from) = from else { - return Ok(Vec::new()); - }; - let mut paths = Vec::new(); - for entry in std::fs::read_dir(from) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read profile directory {}", from.display()))? - { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - if path.is_file() && profile_extension_supported(&path) { - paths.push(path); - } - } - paths.sort(); - Ok(paths) -} - -fn profile_extension_supported(path: &Path) -> bool { - matches!( - path.extension().and_then(|ext| ext.to_str()), - Some("yaml" | "yml" | "json") - ) -} - -fn load_profile_import_item( - path: &Path, -) -> Result { - let source = path.display().to_string(); - let input = std::fs::read_to_string(path).map_err(|err| { - profile_file_diagnostic( - &source, - format!("failed to read provider profile file: {err}"), - ) - })?; - let profile = match path.extension().and_then(|ext| ext.to_str()) { - Some("yaml" | "yml") => parse_profile_yaml(&input), - Some("json") => parse_profile_json(&input), - _ => { - return Err(profile_file_diagnostic( - &source, - "unsupported provider profile file format".to_string(), - )); - } - } - .map_err(|err| profile_file_diagnostic(&source, err.to_string()))?; - - let pre_lower = profile.validate_before_lowering(&source); - if let Some(diag) = pre_lower.into_iter().find(|d| d.severity == "error") { - return Err(ProviderProfileDiagnostic { - source: diag.source, - profile_id: diag.profile_id, - field: diag.field, - message: diag.message, - severity: diag.severity, - }); - } - - Ok(ProviderProfileImportItem { - profile: Some(profile.to_proto()), - source, - }) -} - -fn profile_file_diagnostic(source: &str, message: String) -> ProviderProfileDiagnostic { - ProviderProfileDiagnostic { - source: source.to_string(), - profile_id: String::new(), - field: "file".to_string(), - message, - severity: "error".to_string(), - } -} - -fn print_profile_diagnostics(diagnostics: &[ProviderProfileDiagnostic]) { - if diagnostics.is_empty() { - return; - } - eprintln!("{}", "Provider profile diagnostics:".red().bold()); - for diagnostic in diagnostics { - let source = if diagnostic.source.is_empty() { - "" - } else { - &diagnostic.source - }; - let profile = if diagnostic.profile_id.is_empty() { - "-".to_string() - } else { - diagnostic.profile_id.clone() - }; - eprintln!( - " {} {} profile={} field={} {}", - diagnostic.severity.as_str().red(), - source, - profile, - diagnostic.field, - diagnostic.message - ); - } -} - -fn profile_diagnostics_have_errors(diagnostics: &[ProviderProfileDiagnostic]) -> bool { - diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == "error") -} - -fn display_provider_category(category: i32) -> &'static str { - match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) { - ProviderProfileCategory::Inference => "INFERENCE", - ProviderProfileCategory::Agent => "AGENT", - ProviderProfileCategory::SourceControl => "SOURCE CONTROL", - ProviderProfileCategory::Messaging => "MESSAGING", - ProviderProfileCategory::Data => "DATA", - ProviderProfileCategory::Knowledge => "KNOWLEDGE", - ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER", - } -} - -const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32; -const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40; -const PROVIDER_PROFILE_SOURCE_MAX_WIDTH: usize = 24; - -fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| { - profile - .id - .chars() - .count() - .min(PROVIDER_PROFILE_ID_MAX_WIDTH) - }) - .max() - .unwrap_or(2) - .max(2) -} - -fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| { - profile - .display_name - .chars() - .count() - .min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH) - }) - .max() - .unwrap_or(4) - .max(4) -} - -fn provider_profile_scope_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| profile.scope.chars().count()) - .max() - .unwrap_or(5) - .max(5) -} - -fn provider_profile_source_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| { - profile - .source - .chars() - .count() - .min(PROVIDER_PROFILE_SOURCE_MAX_WIDTH) - }) - .max() - .unwrap_or(6) - .max(6) -} - -fn print_provider_type_header( - id_width: usize, - scope_width: usize, - source_width: usize, - display_width: usize, -) { - let endpoints = "ENDPOINTS"; - println!( - " {: Result<()> { - if from_existing && !credentials.is_empty() { - return Err(miette::miette!( - "--from-existing cannot be combined with --credential" - )); + } else { + println!( + "{: Option { + let endpoint = response.endpoint.as_ref()?; + let workspace = endpoint + .metadata + .as_ref() + .map_or("", |metadata| metadata.workspace.as_str()); + let url = if response.url.is_empty() { + String::new() + } else { + service_url_for_gateway(&response.url, gateway_endpoint) + }; - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; + Some(serde_json::json!({ + "workspace": workspace, + "sandbox": endpoint.sandbox_name, + "service": endpoint.service_name, + "target_port": endpoint.target_port, + "url": url, + })) +} - println!( - "{} Updated provider {}", - "✓".green().bold(), - provider.object_name() - ); - Ok(()) +fn service_display_name(service: &str) -> &str { + if service.is_empty() { "-" } else { service } } -pub async fn provider_delete( - server: &str, - names: &[String], - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - for name in names { - let response = client - .delete_provider(DeleteProviderRequest { - name: name.clone(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - if response.into_inner().deleted { - println!("{} Deleted provider {name}", "✓".green().bold()); - } else { - println!("{} Provider {name} not found", "!".yellow()); - } +/// Read gcloud Application Default Credentials from disk. +/// +/// Returns `(client_id, client_secret, refresh_token)`. +/// +/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to +/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to +/// `~/.config/gcloud/application_default_credentials.json`. +fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String { + let (Ok(mut service_url), Ok(gateway_endpoint)) = ( + url::Url::parse(service_url), + url::Url::parse(gateway_endpoint), + ) else { + return service_url.to_string(); + }; + + if service_url + .set_port(gateway_endpoint.port_or_known_default()) + .is_err() + { + return service_url.to_string(); } - Ok(()) + + service_url.to_string() } // --------------------------------------------------------------------------- @@ -4838,9 +3718,10 @@ pub async fn workspace_member_list( workspace: &str, limit: u32, offset: u32, + output: &str, tls: &TlsOptions, ) -> Result<()> { - use openshell_core::proto::{ListWorkspaceMembersRequest, WorkspaceRole}; + use openshell_core::proto::ListWorkspaceMembersRequest; let mut client = grpc_client(server, tls).await?; let response = client @@ -4853,6 +3734,10 @@ pub async fn workspace_member_list( .into_diagnostic()?; let members = response.into_inner().members; + if crate::output::print_output_collection(output, &members, workspace_member_to_json)? { + return Ok(()); + } + if members.is_empty() { println!("No members found in workspace {workspace}."); return Ok(()); @@ -4868,17 +3753,30 @@ pub async fn workspace_member_list( println!("{: "admin", - Ok(WorkspaceRole::User) => "user", - _ => "unknown", - }; + let role_str = workspace_member_role_name(member.role); println!("{: &'static str { + use openshell_core::proto::WorkspaceRole; + + match WorkspaceRole::try_from(role) { + Ok(WorkspaceRole::Admin) => "admin", + Ok(WorkspaceRole::User) => "user", + _ => "unknown", + } +} + +fn workspace_member_to_json(member: &openshell_core::proto::WorkspaceMember) -> serde_json::Value { + serde_json::json!({ + "subject": member.principal_subject, + "role": workspace_member_role_name(member.role), + }) +} + fn workspace_phase_str(workspace: &openshell_core::proto::Workspace) -> &'static str { use openshell_core::proto::datamodel::v1::WorkspacePhase; let phase = workspace @@ -6445,6 +5343,7 @@ pub async fn sandbox_policy_list( server: &str, name: &str, limit: u32, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { @@ -6462,6 +5361,11 @@ pub async fn sandbox_policy_list( .into_diagnostic()?; let revisions = resp.into_inner().revisions; + let structured = policy_revision_list_json("sandbox", Some(name), &revisions)?; + if crate::output::print_output_collection(output, &structured, Clone::clone)? { + return Ok(()); + } + if revisions.is_empty() { eprintln!("No policy history found for sandbox '{name}'"); return Ok(()); @@ -6474,6 +5378,7 @@ pub async fn sandbox_policy_list( pub async fn sandbox_policy_list_global( server: &str, limit: u32, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { @@ -6491,6 +5396,11 @@ pub async fn sandbox_policy_list_global( .into_diagnostic()?; let revisions = resp.into_inner().revisions; + let structured = policy_revision_list_json("global", None, &revisions)?; + if crate::output::print_output_collection(output, &structured, Clone::clone)? { + return Ok(()); + } + if revisions.is_empty() { eprintln!("No global policy history found"); return Ok(()); @@ -6500,6 +5410,28 @@ pub async fn sandbox_policy_list_global( Ok(()) } +fn policy_revision_list_json( + scope: &str, + sandbox: Option<&str>, + revisions: &[openshell_core::proto::SandboxPolicyRevision], +) -> Result> { + revisions + .iter() + .map(|revision| { + let status = + PolicyStatus::try_from(revision.status).unwrap_or(PolicyStatus::Unspecified); + policy_revision_to_json( + scope, + sandbox, + None, + revision, + status, + PolicyGetView::Metadata, + ) + }) + .collect() +} + fn print_policy_revision_table(revisions: &[openshell_core::proto::SandboxPolicyRevision]) { println!( "{:<8} {:<14} {:<12} {:<24} ERROR", @@ -6638,6 +5570,10 @@ pub async fn sandbox_logs( } fn print_log_line(log: &openshell_core::proto::SandboxLogLine) { + println!("{}", format_log_line(log)); +} + +fn format_log_line(log: &openshell_core::proto::SandboxLogLine) -> String { let source = if log.source.is_empty() { "gateway" } else { @@ -6646,10 +5582,10 @@ fn print_log_line(log: &openshell_core::proto::SandboxLogLine) { let secs = log.timestamp_ms / 1000; let millis = log.timestamp_ms % 1000; if log.fields.is_empty() { - println!( + format!( "[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {}", log.level, log.target, log.message - ); + ) } else { let mut fields_str = String::new(); let mut entries: Vec<_> = log.fields.iter().collect(); @@ -6662,10 +5598,10 @@ fn print_log_line(log: &openshell_core::proto::SandboxLogLine) { fields_str.push('='); fields_str.push_str(v); } - println!( + format!( "[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {} {}", log.level, log.target, log.message, fields_str - ); + ) } } @@ -6739,10 +5675,25 @@ pub async fn sandbox_draft_get( if !chunk.validation_result.is_empty() { println!( " {} {}", - "Validation:".dimmed(), + "Prover:".dimmed(), chunk.validation_result.cyan() ); } + if !chunk.application_error.is_empty() { + println!( + " {} {}", + "Application:".dimmed(), + chunk.application_error.red() + ); + } + if !chunk.candidate_effective_policy_hash.is_empty() { + println!( + " {} {}", + "Candidate:".dimmed(), + &chunk.candidate_effective_policy_hash + [..12.min(chunk.candidate_effective_policy_hash.len())] + ); + } if let Some(ref rule) = chunk.proposed_rule { println!(" {} {}", "Endpoints:".dimmed(), format_endpoints(rule)); @@ -6776,12 +5727,27 @@ pub async fn sandbox_draft_approve( tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; + let review_token = client + .get_draft_policy(GetDraftPolicyRequest { + name: name.to_string(), + status_filter: String::new(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .chunks + .into_iter() + .find(|chunk| chunk.id == chunk_id) + .ok_or_else(|| miette::miette!("draft chunk '{chunk_id}' not found"))? + .review_token; let response = client .approve_draft_chunk(ApproveDraftChunkRequest { name: name.to_string(), chunk_id: chunk_id.to_string(), workspace: workspace.to_string(), + review_token, }) .await .into_diagnostic()?; @@ -6832,12 +5798,29 @@ pub async fn sandbox_draft_approve_all( tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; + let approvals = client + .get_draft_policy(GetDraftPolicyRequest { + name: name.to_string(), + status_filter: "pending".to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .chunks + .into_iter() + .map(|chunk| openshell_core::proto::DraftChunkApproval { + chunk_id: chunk.id, + review_token: chunk.review_token, + }) + .collect(); let response = client .approve_all_draft_chunks(ApproveAllDraftChunksRequest { name: name.to_string(), include_security_flagged, workspace: workspace.to_string(), + approvals, }) .await .into_diagnostic()?; @@ -6983,20 +5966,19 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, - dockerfile_sources_supported_for_gateway, format_endpoint, - format_provider_attachment_table, git_sync_files, inferred_provider_type, - parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, - parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, - policy_revision_to_json, provider_profile_allows_empty_credentials, - provisioning_timeout_message, ready_false_condition_message, refresh_status_header, - refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan, - service_expose_status_error, service_url_for_gateway, + dockerfile_sources_supported_for_gateway, format_endpoint, format_log_line, git_sync_files, + has_main_process_result, parse_cli_setting_value, parse_credential_expiry_cli_value, + parse_driver_config_json, parse_secret_material_env_pairs, policy_revision_list_json, + policy_revision_to_json, provisioning_timeout_message, ready_false_condition_message, + resolve_from, sandbox_should_persist, sandbox_upload_plan, service_endpoint_to_json, + service_expose_status_error, service_url_for_gateway, workspace_member_to_json, }; use crate::TEST_ENV_LOCK; - use crate::commands::common::progress_step_from_metadata; + use crate::commands::common::{ + parse_credential_expiry_pairs, parse_credential_pairs, progress_step_from_metadata, + }; use crate::test_utils::EnvVarGuard; use std::fs; - use std::io::Write; use std::path::Path; use std::process::Command; use tonic::Status; @@ -7007,11 +5989,12 @@ mod tests { PROGRESS_STEP_STARTING_SANDBOX, }; use openshell_core::proto::{ - GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, Provider, - ProviderCredentialRefresh, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, - ProviderProfileCredential, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, - SandboxPolicyRevision, SandboxStatus, datamodel::v1::ObjectMeta, + GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, + ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy, + SandboxPolicyRevision, SandboxResources, SandboxStatus, SandboxWorkloadConfig, + SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, + ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, + datamodel::v1::ObjectMeta, }; #[test] @@ -7042,6 +6025,112 @@ mod tests { ); } + #[test] + fn policy_list_json_reuses_metadata_contract() { + let load_error = "policy failed after checking café.example/非常に長いパス"; + let revisions = vec![SandboxPolicyRevision { + version: 7, + policy_hash: "0123456789abcdef".to_string(), + status: PolicyStatus::Failed as i32, + load_error: load_error.to_string(), + created_at_ms: 100, + loaded_at_ms: 200, + policy: Some(SandboxPolicy::default()), + provenance: std::collections::HashMap::from([( + "source".to_string(), + "provider-composition".to_string(), + )]), + }]; + + let values = policy_revision_list_json("sandbox", Some("dev"), &revisions) + .expect("policy list JSON"); + + assert_eq!( + values[0], + serde_json::json!({ + "scope": "sandbox", + "sandbox": "dev", + "version": 7, + "hash": "0123456789abcdef", + "status": "failed", + "created_at_ms": 100, + "loaded_at_ms": 200, + "load_error": load_error, + "provenance": {"source": "provider-composition"}, + }) + ); + assert!(values[0].get("policy").is_none()); + assert!(values[0].get("active_version").is_none()); + + let unknown = policy_revision_list_json( + "global", + None, + &[SandboxPolicyRevision { + version: 8, + status: 999, + ..Default::default() + }], + ) + .expect("global policy list JSON"); + assert_eq!(unknown[0]["scope"], "global"); + assert_eq!(unknown[0]["status"], "unspecified"); + assert!(unknown[0].get("sandbox").is_none()); + } + + #[test] + fn service_endpoint_json_has_raw_fields_and_normalized_url() { + let response = ServiceEndpointResponse { + endpoint: Some(ServiceEndpoint { + metadata: Some(ObjectMeta { + workspace: "team-a".to_string(), + ..Default::default() + }), + sandbox_name: "api".to_string(), + service_name: String::new(), + target_port: 8080, + ..Default::default() + }), + url: "https://api.openshell.localhost:3000/".to_string(), + }; + + let value = service_endpoint_to_json(&response, "https://gateway.example:17670") + .expect("service endpoint JSON"); + assert_eq!( + value, + serde_json::json!({ + "workspace": "team-a", + "sandbox": "api", + "service": "", + "target_port": 8080, + "url": "https://api.openshell.localhost:17670/", + }) + ); + assert!(service_endpoint_to_json(&ServiceEndpointResponse::default(), "unused").is_none()); + } + + #[test] + fn workspace_member_json_uses_stable_role_names() { + for (role, expected) in [ + (WorkspaceRole::Admin as i32, "admin"), + (WorkspaceRole::User as i32, "user"), + (999, "unknown"), + ] { + let value = workspace_member_to_json(&WorkspaceMember { + metadata: Some(ObjectMeta { + id: "internal-id".to_string(), + ..Default::default() + }), + principal_subject: "oidc-subject".to_string(), + role, + }); + assert_eq!( + value, + serde_json::json!({"subject": "oidc-subject", "role": expected}) + ); + assert!(!value.to_string().contains("internal-id")); + } + } + #[test] fn parse_credential_pairs_accepts_key_value_form() { let parsed = parse_credential_pairs(&["API_KEY=abc123".to_string()]).expect("parse"); @@ -7181,170 +6270,31 @@ mod tests { err.to_string() .contains("must be a Unix epoch millisecond timestamp or RFC3339 timestamp") ); - } - - #[test] - fn parse_credential_expiry_cli_value_accepts_rfc3339_offsets() { - let parsed = parse_credential_expiry_cli_value("2026-01-01T01:00:00+01:00") - .expect("parse RFC3339 with offset"); - - assert_eq!(parsed, 1_767_225_600_000); - } - - #[test] - fn provider_attachment_table_formats_provider_counts() { - let output = format_provider_attachment_table( - &[Provider { - metadata: Some(ObjectMeta { - name: "work-custom".to_string(), - ..Default::default() - }), - r#type: "custom-api".to_string(), - credentials: [ - ("CUSTOM_API_KEY".to_string(), "REDACTED".to_string()), - ("CUSTOM_API_SECRET".to_string(), "REDACTED".to_string()), - ] - .into_iter() - .collect(), - config: std::iter::once(( - "BASE_URL".to_string(), - "https://api.custom.example".to_string(), - )) - .collect(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }], - false, - ); - - assert!(output.contains("NAME")); - assert!(output.contains("TYPE")); - assert!(output.contains("CREDENTIAL_KEYS")); - assert!(output.contains("CONFIG_KEYS")); - assert!(output.contains("work-custom")); - assert!(output.contains("custom-api")); - assert!(output.contains('2')); - assert!(output.contains('1')); - } - - #[test] - fn progress_step_metadata_values_map_to_cli_steps() { - assert_eq!( - progress_step_from_metadata(PROGRESS_STEP_REQUESTING_SANDBOX), - Some(ProvisioningStep::RequestingSandbox) - ); - assert_eq!( - progress_step_from_metadata(PROGRESS_STEP_PULLING_IMAGE), - Some(ProvisioningStep::PullingSandboxImage) - ); - assert_eq!( - progress_step_from_metadata(PROGRESS_STEP_STARTING_SANDBOX), - Some(ProvisioningStep::StartingSandbox) - ); - assert_eq!(progress_step_from_metadata("driver-private-step"), None); - } - - #[test] - fn refresh_status_table_includes_operational_fields() { - let header = refresh_status_header(); - assert!(header.contains("NEXT_REFRESH")); - assert!(header.contains("LAST_REFRESH")); - assert!(header.contains("LAST_ERROR")); - - let row = refresh_status_row(&ProviderCredentialRefreshStatus { - provider_name: "my-graph".to_string(), - provider_id: "provider-id".to_string(), - credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), - strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, - status: "error".to_string(), - expires_at_ms: 1_767_225_600_000, - next_refresh_at_ms: 1_767_225_660_000, - last_refresh_at_ms: 1_767_225_000_000, - last_error: "token endpoint returned a very long error message that should be truncated for table readability" - .to_string(), - }); - - assert!(row.contains("my-graph")); - assert!(row.contains("MS_GRAPH_ACCESS_TOKEN")); - assert!(row.contains("oauth2_client_credentials")); - assert!(row.contains("error")); - assert!(row.contains("2026-01-01 00:00:00")); - assert!(row.contains("...")); - } - - #[test] - fn empty_provider_credentials_require_all_required_credentials_to_be_runtime_resolvable() { - let refresh_token_profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - name: "MS_GRAPH_ACCESS_TOKEN".to_string(), - required: true, - refresh: Some(ProviderCredentialRefresh { - strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }; - assert!(provider_profile_allows_empty_credentials( - &refresh_token_profile - )); - - let token_grant_profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - name: "ACCESS_TOKEN".to_string(), - required: true, - token_grant: Some(ProviderCredentialTokenGrant { - token_endpoint: "https://auth.example.com/token".to_string(), - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }; - assert!(provider_profile_allows_empty_credentials( - &token_grant_profile - )); + } - let mixed_static_profile = ProviderProfile { - credentials: vec![ - ProviderProfileCredential { - name: "ACCESS_TOKEN".to_string(), - required: true, - refresh: Some(ProviderCredentialRefresh { - strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, - ..Default::default() - }), - ..Default::default() - }, - ProviderProfileCredential { - name: "STATIC_API_KEY".to_string(), - required: true, - refresh: None, - ..Default::default() - }, - ], - ..Default::default() - }; - assert!(!provider_profile_allows_empty_credentials( - &mixed_static_profile - )); + #[test] + fn parse_credential_expiry_cli_value_accepts_rfc3339_offsets() { + let parsed = parse_credential_expiry_cli_value("2026-01-01T01:00:00+01:00") + .expect("parse RFC3339 with offset"); - let optional_refresh_profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - name: "OPTIONAL_TOKEN".to_string(), - required: false, - refresh: Some(ProviderCredentialRefresh { - strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32, - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }; - assert!(provider_profile_allows_empty_credentials( - &optional_refresh_profile - )); + assert_eq!(parsed, 1_767_225_600_000); + } + + #[test] + fn progress_step_metadata_values_map_to_cli_steps() { + assert_eq!( + progress_step_from_metadata(PROGRESS_STEP_REQUESTING_SANDBOX), + Some(ProvisioningStep::RequestingSandbox) + ); + assert_eq!( + progress_step_from_metadata(PROGRESS_STEP_PULLING_IMAGE), + Some(ProvisioningStep::PullingSandboxImage) + ); + assert_eq!( + progress_step_from_metadata(PROGRESS_STEP_STARTING_SANDBOX), + Some(ProvisioningStep::StartingSandbox) + ); + assert_eq!(progress_step_from_metadata("driver-private-step"), None); } #[test] @@ -7478,54 +6428,61 @@ mod tests { } #[test] - fn inferred_provider_type_returns_type_for_known_command() { - let result = inferred_provider_type(&["claude".to_string(), "--help".to_string()]); - assert_eq!(result, Some("claude-code".to_string())); - } - - #[test] - fn inferred_provider_type_returns_none_for_unknown_command() { - let result = inferred_provider_type(&["bash".to_string()]); - assert_eq!(result, None); + fn sandbox_should_persist_defaults_to_persistent() { + assert!(sandbox_should_persist(true, None)); } #[test] - fn inferred_provider_type_returns_none_for_empty_command() { - let result = inferred_provider_type(&[]); - assert_eq!(result, None); + fn sandbox_should_not_persist_when_no_keep_is_set() { + assert!(!sandbox_should_persist(false, None)); } #[test] - fn inferred_provider_type_normalizes_aliases() { - // `glab` should resolve to `gitlab` - let result = inferred_provider_type(&["glab".to_string()]); - assert_eq!(result, Some("gitlab".to_string())); - - // `gh` should resolve to `github` - let result = inferred_provider_type(&["gh".to_string()]); - assert_eq!(result, Some("github".to_string())); + fn sandbox_should_persist_when_forward_is_requested() { + let spec = openshell_core::forward::ForwardSpec::new(8080); + assert!(sandbox_should_persist(false, Some(&spec))); } #[test] - fn inferred_provider_type_handles_full_path() { - let result = inferred_provider_type(&["/usr/local/bin/claude".to_string()]); - assert_eq!(result, Some("claude-code".to_string())); - } + fn infrastructure_error_with_observed_exit_is_not_a_main_process_result() { + let mut sandbox = Sandbox { + status: Some(SandboxStatus { + exit_code: Some(137), + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "ComputeResourceMissing".to_string(), + message: "sandbox runtime disappeared".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Error as i32); - #[test] - fn sandbox_should_persist_defaults_to_persistent() { - assert!(sandbox_should_persist(true, None)); + assert!(!has_main_process_result(&sandbox)); } #[test] - fn sandbox_should_not_persist_when_no_keep_is_set() { - assert!(!sandbox_should_persist(false, None)); - } + fn main_process_failed_condition_identifies_command_result() { + let mut sandbox = Sandbox { + status: Some(SandboxStatus { + exit_code: Some(7), + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "MainProcessFailed".to_string(), + message: "canonical main process exited with status 7".to_string(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Error as i32); - #[test] - fn sandbox_should_persist_when_forward_is_requested() { - let spec = openshell_core::forward::ForwardSpec::new(8080); - assert!(sandbox_should_persist(false, Some(&spec))); + assert!(has_main_process_result(&sandbox)); } #[test] @@ -7954,383 +6911,71 @@ mod tests { } #[test] - fn read_gcloud_adc_missing_file_errors() { - 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 = super::read_gcloud_adc().expect_err("missing file should error"); - assert!( - err.to_string().contains("failed to read gcloud ADC file"), - "unexpected error: {err}" - ); - } - - #[test] - fn read_gcloud_adc_wrong_type_errors() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let tmp = tempfile::NamedTempFile::new().expect("tempfile"); - let json = serde_json::json!({ - "type": "service_account", - "project_id": "my-project", - "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 = super::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] - fn read_gcloud_adc_parses_user_creds() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let tmp = tempfile::NamedTempFile::new().expect("tempfile"); - let json = serde_json::json!({ - "type": "authorized_user", - "client_id": "test-client-id.apps.googleusercontent.com", - "client_secret": "test-client-secret", - "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) = - super::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] - fn read_gcloud_adc_uses_cloudsdk_config_fallback() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let dir = tempfile::tempdir().expect("tempdir"); - let adc_path = dir.path().join("application_default_credentials.json"); - let json = serde_json::json!({ - "type": "authorized_user", - "client_id": "cloudsdk-client-id.apps.googleusercontent.com", - "client_secret": "cloudsdk-client-secret", - "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) = - super::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] - fn read_gcloud_adc_malformed_json_errors() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - 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 = super::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] - fn empty_provider_credentials_allow_oauth2_refresh_token() { - use openshell_core::proto::{ - ProviderCredentialRefresh, ProviderCredentialRefreshStrategy, ProviderProfile, - ProviderProfileCredential, - }; - - let strategy = ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32; - let profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - required: true, - refresh: Some(ProviderCredentialRefresh { - strategy, - ..Default::default() - }), + fn sandbox_template_to_json_includes_metadata_labels_and_annotations() { + let template = SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: "template-123".to_string(), + name: "gpu-kata".to_string(), + labels: std::collections::HashMap::from([( + "team".to_string(), + "runtime".to_string(), + )]), + annotations: std::collections::HashMap::from([( + "owner".to_string(), + "platform".to_string(), + )]), + workspace: "default".to_string(), ..Default::default() - }], - ..Default::default() - }; - assert!( - provider_profile_allows_empty_credentials(&profile), - "Oauth2RefreshToken should be allowed for refresh bootstrap" - ); - } - - #[test] - fn provider_to_json_includes_core_fields() { - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), + }), ..Default::default() }; - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); - - assert_eq!(json["id"], "prov-123"); - assert_eq!(json["name"], "test-provider"); - assert_eq!(json["workspace"], ""); - assert_eq!(json["type"], "anthropic"); - } - - #[test] - fn provider_to_json_exposes_credential_keys_not_values() { - let mut credentials = std::collections::HashMap::new(); - credentials.insert("ANTHROPIC_API_KEY".to_string(), "secret-value".to_string()); - credentials.insert("OTHER_KEY".to_string(), "other-secret".to_string()); - - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "anthropic".to_string(), - credentials, - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); - let json_str = json.to_string(); - - // Assert credential keys are present - let keys = json["credential_keys"].as_array().unwrap(); - assert_eq!(keys.len(), 2); - assert!(keys.iter().any(|k| k.as_str() == Some("ANTHROPIC_API_KEY"))); - assert!(keys.iter().any(|k| k.as_str() == Some("OTHER_KEY"))); - - // Assert credential values are NOT in the output (SECURITY) - assert!( - !json_str.contains("secret-value"), - "credential values must not be exposed" - ); - assert!( - !json_str.contains("other-secret"), - "credential values must not be exposed" - ); - } - - #[test] - fn provider_to_json_exposes_config_keys_not_values() { - let mut config = std::collections::HashMap::new(); - config.insert("region".to_string(), "us-west".to_string()); - config.insert( - "endpoint".to_string(), - "https://api.example.com".to_string(), - ); - - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "custom".to_string(), - credentials: std::collections::HashMap::new(), - config, - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); - let json_str = json.to_string(); - - // Assert config keys are present - let keys = json["config_keys"].as_array().unwrap(); - assert_eq!(keys.len(), 2); - assert!(keys.iter().any(|k| k.as_str() == Some("region"))); - assert!(keys.iter().any(|k| k.as_str() == Some("endpoint"))); - - // Assert config values are NOT in the output (SECURITY) - assert!( - !json_str.contains("us-west"), - "config values must not be exposed" - ); - assert!( - !json_str.contains("https://api.example.com"), - "config values must not be exposed" - ); - } - - #[test] - fn provider_to_json_omits_empty_config() { - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), // Empty config - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); - - assert!( - json.get("config_keys").is_none(), - "empty config_keys should be omitted" - ); - } - - #[test] - fn provider_to_json_includes_metadata_fields_when_present() { - let mut labels = std::collections::HashMap::new(); - labels.insert("env".to_string(), "prod".to_string()); - - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), - resource_version: 42, - created_at_ms: 1_234_567_890_000, - labels, - annotations: std::collections::HashMap::new(), - workspace: String::new(), - deletion_timestamp_ms: 0, - }; - - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); + let json = super::sandbox_template_to_json(&template); - assert_eq!(json["resource_version"], 42); - assert_eq!(json["created_at"], "2009-02-13 23:31:30"); - assert_eq!(json["labels"]["env"], "prod"); + assert_eq!(json["labels"]["team"], "runtime"); + assert_eq!(json["annotations"]["owner"], "platform"); } #[test] - fn provider_to_json_omits_zero_metadata_fields() { - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), - // resource_version and created_at_ms are 0 - // labels is empty + fn sandbox_template_to_json_formats_default_gpu_like_display_output() { + let template = SandboxWorkloadTemplate { + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + resources: Some(SandboxResources { + gpu: Some(GpuResourceRequirements { count: None }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), ..Default::default() }; - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); - - assert!( - json.get("resource_version").is_none(), - "zero resource_version should be omitted" - ); - assert!( - json.get("created_at").is_none(), - "zero created_at should be omitted" - ); - assert!( - json.get("labels").is_none(), - "empty labels should be omitted" - ); - } - - #[test] - fn provider_to_json_includes_credential_expiration() { - let mut credential_expires_at_ms = std::collections::HashMap::new(); - credential_expires_at_ms.insert("ACCESS_TOKEN".to_string(), 1_234_567_890); - - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "oauth".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms, - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); + let json = super::sandbox_template_to_json(&template); - assert_eq!( - json["credential_expires_at_ms"]["ACCESS_TOKEN"], - 1_234_567_890 - ); + assert_eq!(json["resources"]["gpu"], "default"); } #[test] - fn provider_to_json_formats_created_at_as_human_readable() { - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), - created_at_ms: 1_609_459_200_000, // 2021-01-01 00:00:00 + fn sandbox_template_to_json_preserves_explicit_gpu_count_as_number() { + let template = SandboxWorkloadTemplate { + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + resources: Some(SandboxResources { + gpu: Some(GpuResourceRequirements { count: Some(2) }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), ..Default::default() }; - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - }; - - let json = super::provider_to_json(&provider); + let json = super::sandbox_template_to_json(&template); - // Should format as human-readable datetime, not raw milliseconds - assert_eq!(json["created_at"], "2021-01-01 00:00:00"); - assert!( - json.get("created_at_ms").is_none(), - "raw milliseconds field should not exist" - ); + assert_eq!(json["resources"]["gpu"], 2); } #[test] @@ -8343,6 +6988,10 @@ mod tests { created_at_ms: 1_609_459_200_000, ..Default::default() }), + created_from_workload_template: Some(SandboxWorkloadTemplateProvenance { + name: "gpu-kata".to_string(), + resource_version: "7".to_string(), + }), ..Default::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); @@ -8362,6 +7011,11 @@ mod tests { assert_eq!(json["policy_source"], "global"); assert_eq!(json["revision"], 3); assert!(json["policy"].is_null()); + assert_eq!(json["created_from_workload_template"]["name"], "gpu-kata"); + assert_eq!( + json["created_from_workload_template"]["resource_version"], + "7" + ); } #[test] @@ -8386,4 +7040,106 @@ mod tests { assert!(json["revision"].is_null()); assert!(json["policy"].is_null()); } + + fn log_line( + level: &str, + target: &str, + message: &str, + source: &str, + fields: &[(&str, &str)], + ) -> openshell_core::proto::SandboxLogLine { + openshell_core::proto::SandboxLogLine { + sandbox_id: "sb-1".to_string(), + timestamp_ms: 1_234_567, + level: level.to_string(), + target: target.to_string(), + message: message.to_string(), + source: source.to_string(), + fields: fields + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + } + } + + #[test] + fn format_log_line_without_fields() { + let log = log_line("INFO", "openshell_server", "hello world", "sandbox", &[]); + assert_eq!( + format_log_line(&log), + "[1234.567] [sandbox] [INFO ] [openshell_server] hello world" + ); + } + + #[test] + fn format_log_line_empty_source_defaults_to_gateway() { + let log = log_line("WARN", "t", "msg", "", &[]); + assert_eq!( + format_log_line(&log), + "[1234.567] [gateway] [WARN ] [t] msg" + ); + } + + #[test] + fn format_log_line_pads_source_and_level() { + let log = log_line("OCSF", "ocsf", "NET:OPEN", "gateway", &[]); + assert_eq!( + format_log_line(&log), + "[1234.567] [gateway] [OCSF ] [ocsf] NET:OPEN" + ); + + let short_source = log_line("ERROR", "t", "m", "vm", &[]); + assert_eq!( + format_log_line(&short_source), + "[1234.567] [vm ] [ERROR] [t] m" + ); + } + + #[test] + fn format_log_line_sorts_fields_alphabetically() { + let log = log_line( + "INFO", + "ocsf", + "CONNECT", + "sandbox", + &[("dst_port", "443"), ("action", "allow"), ("dst_host", "x")], + ); + assert_eq!( + format_log_line(&log), + "[1234.567] [sandbox] [INFO ] [ocsf] CONNECT action=allow dst_host=x dst_port=443" + ); + } + + #[test] + fn format_log_line_keeps_empty_field_values() { + let log = log_line("INFO", "t", "m", "sandbox", &[("a", ""), ("b", "1")]); + assert_eq!( + format_log_line(&log), + "[1234.567] [sandbox] [INFO ] [t] m a= b=1" + ); + } + + #[test] + fn format_log_line_renders_empty_target_as_empty_brackets() { + let log = log_line("INFO", "", "m", "sandbox", &[]); + assert_eq!(format_log_line(&log), "[1234.567] [sandbox] [INFO ] [] m"); + } + + #[test] + fn format_log_line_zero_pads_millis() { + let mut log = log_line("INFO", "t", "m", "sandbox", &[]); + log.timestamp_ms = 1_000_007; + assert_eq!(format_log_line(&log), "[1000.007] [sandbox] [INFO ] [t] m"); + + log.timestamp_ms = 0; + assert_eq!(format_log_line(&log), "[0.000] [sandbox] [INFO ] [t] m"); + } + + #[test] + fn format_log_line_preserves_message_verbatim() { + // OCSF shorthand must pass through unchanged. + let message = "NET:OPEN [MED] DENIED /usr/bin/curl(4711) -> api.example.com:443"; + let log = log_line("OCSF", "ocsf", message, "sandbox", &[]); + assert!(format_log_line(&log).ends_with(message)); + } } diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index f8de99a692..126cea652d 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -3,11 +3,11 @@ //! SSH connection and proxy utilities. +use crate::color::Colorize; use crate::tls::{TlsOptions, grpc_client}; use miette::{IntoDiagnostic, Result, WrapErr}; #[cfg(unix)] use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; -use openshell_core::ObjectId; use openshell_core::forward::{ ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape, validate_ssh_session_response, write_forward_pid, @@ -16,7 +16,7 @@ use openshell_core::proto::{ CreateSshSessionRequest, GetSandboxRequest, SshRelayTarget, TcpForwardFrame, TcpForwardInit, tcp_forward_init, }; -use owo_colors::OwoColorize; +use openshell_core::{ObjectId, driver_mounts}; use std::fs; use std::future::Future; use std::io::{IsTerminal, Write}; @@ -29,6 +29,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::process::{Child, Command as TokioCommand}; use tokio_stream::wrappers::ReceiverStream; +use tonic::Code; /// Time budget for the local listener to become reachable after `ssh` starts. /// This is a user-visible readiness deadline for both foreground and background @@ -39,6 +40,10 @@ const FORWARD_LISTENER_PROBE_INTERVAL: Duration = Duration::from_millis(50); /// Per-attempt connect timeout, so one hung probe cannot consume the whole /// grace period. const FORWARD_LISTENER_CONNECT_TIMEOUT: Duration = Duration::from_millis(200); +/// Time budget for the supervisor relay to register after a fast canonical +/// command has already reported its terminal result. +const TERMINAL_RELAY_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(5); +const TERMINAL_RELAY_REGISTRATION_INTERVAL: Duration = Duration::from_millis(50); #[derive(Clone, Copy, Debug)] pub enum Editor { @@ -71,6 +76,7 @@ struct SshSessionConfig { sandbox_id: String, gateway_url: String, token: String, + main_terminal: bool, } async fn ssh_session_config( @@ -78,6 +84,7 @@ async fn ssh_session_config( name: &str, tls: &TlsOptions, workspace: &str, + terminal_relay_registration_timeout: Option, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -93,12 +100,26 @@ async fn ssh_session_config( .sandbox .ok_or_else(|| miette::miette!("sandbox not found"))?; - let response = client - .create_ssh_session(CreateSshSessionRequest { - sandbox_id: sandbox.object_id().to_string(), - }) - .await - .into_diagnostic()?; + let relay_registration_deadline = + terminal_relay_registration_timeout.map(|timeout| tokio::time::Instant::now() + timeout); + let response = loop { + match client + .create_ssh_session(CreateSshSessionRequest { + sandbox_id: sandbox.object_id().to_string(), + }) + .await + { + Ok(response) => break response, + Err(status) + if status.code() == Code::FailedPrecondition + && relay_registration_deadline + .is_some_and(|deadline| tokio::time::Instant::now() < deadline) => + { + tokio::time::sleep(TERMINAL_RELAY_REGISTRATION_INTERVAL).await; + } + Err(status) => return Err(status).into_diagnostic(), + } + }; let session = response.into_inner(); validate_ssh_session_response(&session) .map_err(|err| miette::miette!("gateway returned invalid SSH session response: {err}"))?; @@ -139,6 +160,7 @@ async fn ssh_session_config( sandbox_id: session.sandbox_id.clone(), gateway_url, token: session.token, + main_terminal: sandbox.spec.as_ref().is_none_or(|spec| spec.tty), }) } @@ -227,7 +249,7 @@ fn reset_transient_tty_signals(command: &mut Command) { } } -fn exec_or_wait(mut command: Command, replace_process: bool) -> Result<()> { +fn exec_or_wait(mut command: Command, replace_process: bool) -> Result { if replace_process && std::io::stdin().is_terminal() { #[cfg(unix)] { @@ -246,11 +268,7 @@ fn exec_or_wait(mut command: Command, replace_process: bool) -> Result<()> { let status = command.status().into_diagnostic()?; - if !status.success() { - return Err(miette::miette!("ssh exited with status {status}")); - } - - Ok(()) + Ok(status.code().unwrap_or(1)) } async fn sandbox_connect_with_mode( @@ -259,26 +277,38 @@ async fn sandbox_connect_with_mode( tls: &TlsOptions, replace_process: bool, workspace: &str, -) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + terminal_relay_registration_timeout: Option, +) -> Result { + let session = ssh_session_config( + server, + name, + tls, + workspace, + terminal_relay_registration_timeout, + ) + .await?; let mut command = ssh_base_command(&session.proxy_command); + if session.main_terminal { + command.arg("-tt").arg("-o").arg("RequestTTY=force"); + } else { + command.arg("-T"); + } command - .arg("-tt") - .arg("-o") - .arg("RequestTTY=force") .arg("-o") .arg("SetEnv=TERM=xterm-256color") + .arg("-s") .arg("sandbox") + .arg("openshell-main") .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); - tokio::task::spawn_blocking(move || exec_or_wait(command, replace_process)) + let exit_code = tokio::task::spawn_blocking(move || exec_or_wait(command, replace_process)) .await .into_diagnostic()??; - Ok(()) + Ok(exit_code) } /// Connect to a sandbox via SSH. @@ -287,8 +317,8 @@ pub async fn sandbox_connect( name: &str, tls: &TlsOptions, workspace: &str, -) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, true, workspace).await +) -> Result { + sandbox_connect_with_mode(server, name, tls, true, workspace, None).await } pub(crate) async fn sandbox_connect_without_exec( @@ -296,8 +326,25 @@ pub(crate) async fn sandbox_connect_without_exec( name: &str, tls: &TlsOptions, workspace: &str, -) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, false, workspace).await +) -> Result { + sandbox_connect_with_mode(server, name, tls, false, workspace, None).await +} + +pub(crate) async fn sandbox_connect_terminal_main( + server: &str, + name: &str, + tls: &TlsOptions, + workspace: &str, +) -> Result { + sandbox_connect_with_mode( + server, + name, + tls, + false, + workspace, + Some(TERMINAL_RELAY_REGISTRATION_TIMEOUT), + ) + .await } pub async fn sandbox_connect_editor( @@ -308,22 +355,12 @@ pub async fn sandbox_connect_editor( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - // Verify the sandbox exists before writing SSH config / launching the editor. - let mut client = grpc_client(server, tls).await?; - client - .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .sandbox - .ok_or_else(|| miette::miette!("sandbox not found: {name}"))?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; + let workspace_root = discover_workspace_root(&session).await?; let host_alias = host_alias(name, workspace); install_ssh_config(gateway, name, workspace)?; - launch_editor(editor, &host_alias)?; + launch_editor(editor, &host_alias, &workspace_root)?; eprintln!( "{} Opened {} for sandbox {}", "✓".green().bold(), @@ -347,7 +384,7 @@ pub async fn sandbox_forward( ) -> Result<()> { openshell_core::forward::check_port_available(spec)?; - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command)); command @@ -562,7 +599,7 @@ async fn sandbox_exec_with_mode( return Err(miette::miette!("no command provided")); } - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; let mut ssh = ssh_base_command(&session.proxy_command); if tty { @@ -606,17 +643,6 @@ pub async fn sandbox_exec( sandbox_exec_with_mode(server, name, command, tty, tls, true, workspace).await } -pub(crate) async fn sandbox_exec_without_exec( - server: &str, - name: &str, - command: &[String], - tty: bool, - tls: &TlsOptions, - workspace: &str, -) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, false, workspace).await -} - /// What to pack into the tar archive streamed to the sandbox. enum UploadSource { /// A single local file or directory. `tar_name` controls the entry name @@ -776,9 +802,8 @@ fn local_upload_path_is_file_like(path: &Path) -> bool { /// sandbox. Callers are responsible for splitting the destination path so /// that `dest_dir` is always a directory. /// -/// When `dest_dir` is `None`, the sandbox user's home directory (`$HOME`) is -/// used as the extraction target. This avoids hard-coding any particular -/// path and works for custom container images with non-default `WORKDIR`. +/// When `dest_dir` is `None`, tar extracts relative to the SSH session's +/// working directory. async fn ssh_tar_upload( server: &str, name: &str, @@ -787,11 +812,10 @@ async fn ssh_tar_upload( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; - // When no explicit destination is given, use the unescaped `$HOME` shell - // variable so the remote shell resolves it at runtime. - let escaped_dest = dest_dir.map_or_else(|| "$HOME".to_string(), shell_escape); + let dest_dir = dest_dir.unwrap_or("."); + let escaped_dest = shell_escape(dest_dir); let mut ssh = ssh_base_command(&session.proxy_command); ssh.arg("-T") @@ -844,10 +868,6 @@ fn split_sandbox_path(path: &str) -> (&str, &str) { } } -/// Writable root inside every sandbox. Used as the boundary for path-traversal -/// checks on sandbox-side source paths in download flows. -const SANDBOX_WORKSPACE_ROOT: &str = "/sandbox"; - /// Lexically clean a POSIX-style absolute path by resolving `.` and `..` /// components, collapsing repeated separators, and stripping any trailing /// slash. Returns `None` if the input is empty or relative — the caller is @@ -883,64 +903,79 @@ fn lexical_clean_absolute_path(path: &str) -> Option { Some(out) } -/// Validate that a sandbox-side source path passed to `sandbox download` -/// resolves under the sandbox writable root. +/// Resolve a sandbox-side source path passed to `sandbox download` under the +/// sandbox writable root. /// /// Returns the cleaned, traversal-resolved path on success. Refuses any -/// path that lexically escapes `/sandbox` (e.g. `/etc/passwd`, -/// `/sandbox/../etc/passwd`) with a user-facing error. +/// path that lexically escapes the discovered workspace root with a user-facing +/// error. Relative paths are interpreted from the workspace root. /// /// This is a lexical guard only — it does not follow symlinks. Call /// `resolve_sandbox_source_path` after this on any path that will be passed -/// to a subsequent SSH I/O operation, so a symlink such as -/// `/sandbox/etc-link -> /etc` cannot leak files outside the workspace. -fn validate_sandbox_source_path(path: &str) -> Result { +/// to a subsequent SSH I/O operation, so a workspace symlink to `/etc` cannot +/// leak files outside the workspace. +fn validate_sandbox_source_path(workspace_root: &str, path: &str) -> Result { if path.is_empty() { return Err(miette::miette!("sandbox source path is empty")); } - let cleaned = lexical_clean_absolute_path(path) - .ok_or_else(|| miette::miette!("sandbox source path must be absolute (got '{path}')"))?; - if !is_under_sandbox_workspace(&cleaned) { + let candidate = if path.starts_with('/') { + path.to_string() + } else { + format!("{workspace_root}/{path}") + }; + let cleaned = lexical_clean_absolute_path(&candidate) + .ok_or_else(|| miette::miette!("sandbox source path is invalid (got '{path}')"))?; + if !driver_mounts::path_is_or_under(Path::new(&cleaned), Path::new(workspace_root)) { return Err(miette::miette!( - "sandbox source path '{path}' is outside the sandbox workspace ({SANDBOX_WORKSPACE_ROOT})" + "sandbox source path '{path}' is outside the sandbox workspace ({workspace_root})" )); } Ok(cleaned) } -/// Pure helper: is `path` equal to `/sandbox` or a descendant of it? -fn is_under_sandbox_workspace(path: &str) -> bool { - path == SANDBOX_WORKSPACE_ROOT || path.starts_with(&format!("{SANDBOX_WORKSPACE_ROOT}/")) -} - -/// Resolve every symlink in `sandbox_path` on the sandbox side and refuse the -/// result if it lands outside `/sandbox`. +/// Discover the workspace root and resolve every symlink in `sandbox_path` in +/// one SSH probe, then refuse the result if it lands outside the workspace. /// /// The lexical guard in `validate_sandbox_source_path` cannot see symlinks; a -/// path such as `/sandbox/etc-link/passwd` (where `etc-link -> /etc`) clears -/// the lexical check but would still leak `/etc/passwd` once `tar -C` follows -/// the link. Resolving symlinks on the remote side and re-validating closes -/// that gap. The returned fully-resolved path is what the caller should hand -/// to probe and tar invocations. +/// workspace path through `etc-link -> /etc` clears the lexical check but +/// would still leak `/etc/passwd` once `tar -C` follows the link. Resolving +/// symlinks on the remote side and re-validating closes that gap. The returned +/// fully-resolved path is what the caller should hand to probe and tar +/// invocations. Combining discovery and resolution also keeps downloads within +/// the gateway's three-connection limit: this probe, the type probe, and tar. async fn resolve_sandbox_source_path( session: &SshSessionConfig, sandbox_path: &str, ) -> Result { - let resolve_cmd = format!("realpath -e -- {path}", path = shell_escape(sandbox_path)); - let resolved = ssh_run_capture_stdout(session, &resolve_cmd) + let resolve_cmd = format!( + "pwd -P && realpath -e -- {path}", + path = shell_escape(sandbox_path) + ); + let output = ssh_run_capture_stdout(session, &resolve_cmd) .await .wrap_err_with(|| format!("failed to resolve sandbox source path '{sandbox_path}'"))?; + let (workspace_root, resolved) = output.split_once('\n').ok_or_else(|| { + miette::miette!("unexpected response while resolving sandbox source path '{sandbox_path}'") + })?; + if resolved.contains('\n') { + return Err(miette::miette!( + "unexpected response while resolving sandbox source path '{sandbox_path}'" + )); + } + + let workspace_root = validate_discovered_workspace_root(workspace_root)?; + validate_sandbox_source_path(&workspace_root, sandbox_path)?; if resolved.is_empty() { return Err(miette::miette!( "sandbox source path '{sandbox_path}' does not exist" )); } - if !is_under_sandbox_workspace(&resolved) { + if !driver_mounts::path_is_or_under(Path::new(resolved), Path::new(&workspace_root)) { return Err(miette::miette!( - "sandbox source path '{sandbox_path}' resolves to '{resolved}', outside the sandbox workspace ({SANDBOX_WORKSPACE_ROOT})" + "sandbox source path '{sandbox_path}' resolves to '{resolved}', outside the sandbox workspace ({workspace_root})" )); } - Ok(resolved) + Ok(resolved.to_string()) } /// Resolve the host-side target path for a downloaded *file*, following @@ -971,7 +1006,7 @@ fn resolve_file_download_target( /// /// Files are streamed as a tar archive to `ssh ... tar xf - -C ` on /// the sandbox side. When `dest` is `None`, files are uploaded to the -/// sandbox user's home directory. +/// SSH session's working directory. #[allow(clippy::too_many_arguments)] pub async fn sandbox_sync_up_files( server: &str, @@ -1003,11 +1038,11 @@ pub async fn sandbox_sync_up_files( /// Push a local path (file or directory) into a sandbox using tar-over-SSH. /// -/// When `sandbox_path` is `None`, files are uploaded to the sandbox user's -/// home directory. When uploading a single file to an explicit destination -/// that does not end with `/`, the destination is treated as a file path: -/// the parent directory is created and the file is written with the -/// destination's basename. This matches `cp` / `scp` semantics. +/// When `sandbox_path` is `None`, files are uploaded to the SSH session's +/// working directory. When uploading a single file to an explicit destination +/// that does not end with `/`, the destination is treated as a file path: the +/// parent directory is created and the file is written with the destination's +/// basename. This matches `cp` / `scp` semantics. pub async fn sandbox_sync_up( server: &str, name: &str, @@ -1021,10 +1056,10 @@ pub async fn sandbox_sync_up( // `mkdir -p` creates the parent and tar extracts the file with the right // name. // - // Exception: if splitting would yield "/" as the parent (e.g. the user - // passed "/sandbox"), fall through to directory semantics instead. The - // sandbox user cannot write to "/" and the intent is almost certainly - // "put the file inside /sandbox", not "create a file named sandbox in /". + // Exception: if splitting would yield "/" as the parent, fall through to + // directory semantics instead. The sandbox user cannot write to "/" and + // the intent is almost certainly to place the file inside the named + // top-level directory. let local_path_is_file_like = local_upload_path_is_file_like(local_path); if let Some(path) = sandbox_path && local_path_is_file_like @@ -1124,7 +1159,38 @@ async fn ssh_run_capture_stdout(session: &SshSessionConfig, command: &str) -> Re output.status )); } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + decode_ssh_probe_stdout(output.stdout) +} + +fn decode_ssh_probe_stdout(stdout: Vec) -> Result { + let stdout = String::from_utf8(stdout) + .map_err(|error| miette::miette!("ssh probe returned non-UTF-8 output: {error}"))?; + let stdout = stdout.strip_suffix('\n').unwrap_or(&stdout); + let stdout = stdout.strip_suffix('\r').unwrap_or(stdout); + Ok(stdout.to_string()) +} + +fn validate_discovered_workspace_root(root: &str) -> Result { + let cleaned = lexical_clean_absolute_path(root) + .ok_or_else(|| miette::miette!("remote workspace must be an absolute path"))?; + if cleaned == "/" { + return Err(miette::miette!( + "remote workspace resolved to the container root" + )); + } + if cleaned != root { + return Err(miette::miette!( + "remote workspace '{root}' is not a canonical absolute path" + )); + } + Ok(cleaned) +} + +async fn discover_workspace_root(session: &SshSessionConfig) -> Result { + let root = ssh_run_capture_stdout(session, "pwd -P") + .await + .wrap_err("failed to discover remote workspace")?; + validate_discovered_workspace_root(&root) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1169,8 +1235,8 @@ async fn probe_sandbox_source_kind( /// behaviour for the directory-source case. /// /// The sandbox source path is also subjected to a workspace-boundary check -/// before any SSH command is issued; paths that lexically resolve outside -/// `/sandbox` are refused. +/// before any file probe or archive command is issued; paths that resolve +/// outside the discovered workspace root are refused. pub async fn sandbox_sync_down( server: &str, name: &str, @@ -1179,9 +1245,8 @@ pub async fn sandbox_sync_down( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - let sandbox_path = validate_sandbox_source_path(sandbox_path)?; - let session = ssh_session_config(server, name, tls, workspace).await?; - let sandbox_path = resolve_sandbox_source_path(&session, &sandbox_path).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; + let sandbox_path = resolve_sandbox_source_path(&session, sandbox_path).await?; let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?; match kind { @@ -1462,7 +1527,7 @@ pub async fn sandbox_ssh_proxy_by_name( tls: &TlsOptions, workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls, workspace).await?; + let session = ssh_session_config(server, name, tls, workspace, None).await?; sandbox_ssh_proxy( &session.gateway_url, &session.sandbox_id, @@ -1631,19 +1696,25 @@ pub fn install_ssh_config(gateway: &str, name: &str, workspace: &str) -> Result< Ok(managed_config) } -fn launch_editor(editor: Editor, host_alias: &str) -> Result<()> { +fn launch_editor(editor: Editor, host_alias: &str, workspace_root: &str) -> Result<()> { launch_editor_command( editor.binary(), editor.label(), &Editor::remote_target(host_alias), + workspace_root, ) } -fn launch_editor_command(binary: &str, label: &str, remote_target: &str) -> Result<()> { +fn launch_editor_command( + binary: &str, + label: &str, + remote_target: &str, + workspace_root: &str, +) -> Result<()> { let status = Command::new(binary) .arg("--remote") .arg(remote_target) - .arg("/sandbox") + .arg(workspace_root) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -1776,6 +1847,7 @@ mod tests { "openshell-test-missing-binary", "Test Editor", "ssh-remote+openshell-demo", + "/workspace/project", ) .unwrap_err(); let text = format!("{err}"); @@ -1968,68 +2040,99 @@ mod tests { #[test] fn validate_sandbox_source_path_accepts_workspace_paths() { + let workspace_root = "/workspace/project"; assert_eq!( - validate_sandbox_source_path("/sandbox/file.txt").unwrap(), - "/sandbox/file.txt" + validate_sandbox_source_path(workspace_root, "/workspace/project/file.txt").unwrap(), + "/workspace/project/file.txt" ); assert_eq!( - validate_sandbox_source_path("/sandbox/.agent/workspace/hello.txt").unwrap(), - "/sandbox/.agent/workspace/hello.txt" + validate_sandbox_source_path( + workspace_root, + "/workspace/project/.agent/workspace/hello.txt" + ) + .unwrap(), + "/workspace/project/.agent/workspace/hello.txt" ); assert_eq!( - validate_sandbox_source_path("/sandbox").unwrap(), - "/sandbox" + validate_sandbox_source_path(workspace_root, "/workspace/project").unwrap(), + "/workspace/project" ); assert_eq!( - validate_sandbox_source_path("/sandbox/").unwrap(), - "/sandbox" + validate_sandbox_source_path(workspace_root, "/workspace/project/").unwrap(), + "/workspace/project" ); assert_eq!( - validate_sandbox_source_path("/sandbox/sub/../file").unwrap(), - "/sandbox/file" + validate_sandbox_source_path(workspace_root, "/workspace/project/sub/../file").unwrap(), + "/workspace/project/file" + ); + assert_eq!( + validate_sandbox_source_path(workspace_root, "output/file.txt").unwrap(), + "/workspace/project/output/file.txt" + ); + assert_eq!( + validate_sandbox_source_path(workspace_root, "./output/../file.txt").unwrap(), + "/workspace/project/file.txt" ); } #[test] fn validate_sandbox_source_path_rejects_traversal_and_escapes() { - let traversal = validate_sandbox_source_path("/etc/passwd").unwrap_err(); + let workspace_root = "/workspace/project"; + let traversal = validate_sandbox_source_path(workspace_root, "/etc/passwd").unwrap_err(); assert!( format!("{traversal}").contains("outside the sandbox workspace"), "unexpected error: {traversal}" ); - let parent_escape = validate_sandbox_source_path("/sandbox/../etc/passwd").unwrap_err(); + let parent_escape = + validate_sandbox_source_path(workspace_root, "/workspace/project/../../etc/passwd") + .unwrap_err(); assert!( format!("{parent_escape}").contains("outside the sandbox workspace"), "unexpected error: {parent_escape}" ); - let prefix_only = validate_sandbox_source_path("/sandboxed/secrets").unwrap_err(); + let prefix_only = + validate_sandbox_source_path(workspace_root, "/workspace/projected/secrets") + .unwrap_err(); assert!( format!("{prefix_only}").contains("outside the sandbox workspace"), "unexpected error: {prefix_only}" ); - let empty = validate_sandbox_source_path("").unwrap_err(); + let empty = validate_sandbox_source_path(workspace_root, "").unwrap_err(); assert!(format!("{empty}").contains("empty")); - let relative = validate_sandbox_source_path("sandbox/file").unwrap_err(); - assert!(format!("{relative}").contains("must be absolute")); + let relative_escape = + validate_sandbox_source_path(workspace_root, "../../etc/passwd").unwrap_err(); + assert!(format!("{relative_escape}").contains("outside the sandbox workspace")); } #[test] - fn is_under_sandbox_workspace_accepts_root_and_descendants() { - assert!(is_under_sandbox_workspace("/sandbox")); - assert!(is_under_sandbox_workspace("/sandbox/file")); - assert!(is_under_sandbox_workspace("/sandbox/sub/nested")); + fn discovered_workspace_root_must_be_canonical_absolute_non_root() { + assert_eq!( + validate_discovered_workspace_root("/workspace/project").unwrap(), + "/workspace/project" + ); + for invalid in ["", "workspace", "/", "/workspace/../etc", "/workspace/"] { + assert!( + validate_discovered_workspace_root(invalid).is_err(), + "expected '{invalid}' to be rejected" + ); + } } #[test] - fn is_under_sandbox_workspace_rejects_outside_paths_and_prefix_collisions() { - assert!(!is_under_sandbox_workspace("/etc/passwd")); - assert!(!is_under_sandbox_workspace("/sandboxed/secrets")); - assert!(!is_under_sandbox_workspace("/")); - assert!(!is_under_sandbox_workspace("")); + fn ssh_probe_output_only_removes_the_protocol_line_ending() { + assert_eq!( + decode_ssh_probe_stdout(b"/workspace/project \n".to_vec()).unwrap(), + "/workspace/project " + ); + assert_eq!( + decode_ssh_probe_stdout(b"/workspace/project\r\n".to_vec()).unwrap(), + "/workspace/project" + ); + assert!(decode_ssh_probe_stdout(vec![0xff]).is_err()); } #[test] @@ -2233,7 +2336,9 @@ mod tests { #[derive(Debug)] struct UploadArchiveEntry { path: String, + #[cfg_attr(not(unix), allow(dead_code))] entry_type: tar::EntryType, + #[cfg_attr(not(unix), allow(dead_code))] link_name: Option, } diff --git a/crates/openshell-cli/src/tls.rs b/crates/openshell-cli/src/tls.rs index 10df401a5b..c24b84c7dc 100644 --- a/crates/openshell-cli/src/tls.rs +++ b/crates/openshell-cli/src/tls.rs @@ -3,6 +3,7 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::auth::EdgeAuthInterceptor; +use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::proto::inference_client::InferenceClient; use openshell_core::proto::open_shell_client::OpenShellClient; use rustls::{ @@ -21,6 +22,7 @@ use tokio::sync::Mutex; use tonic::service::interceptor::InterceptedService; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tracing::debug; +use url::{Host, Url}; /// Concrete gRPC client type used by all commands. pub type GrpcClient = OpenShellClient>; @@ -222,12 +224,33 @@ pub fn build_rustls_config(materials: &TlsMaterials) -> Result ClientTlsConfig { +pub fn build_tonic_tls_config(server: &str, materials: &TlsMaterials) -> Result { let ca_cert = Certificate::from_pem(materials.ca.clone()); let identity = Identity::from_pem(materials.cert.clone(), materials.key.clone()); - ClientTlsConfig::new() - .ca_certificate(ca_cert) - .identity(identity) + tls_config_with_server_name( + server, + ClientTlsConfig::new() + .ca_certificate(ca_cert) + .identity(identity), + ) +} + +fn tls_config_with_server_name(server: &str, config: ClientTlsConfig) -> Result { + Ok(config.domain_name(tls_server_name(server)?)) +} + +fn tls_server_name(server: &str) -> Result { + let endpoint = Url::parse(server) + .into_diagnostic() + .wrap_err_with(|| format!("invalid gateway endpoint '{server}'"))?; + let host = endpoint + .host() + .ok_or_else(|| miette::miette!("gateway endpoint '{server}' has no host"))?; + Ok(match host { + Host::Domain(domain) => domain.to_string(), + Host::Ipv4(address) => address.to_string(), + Host::Ipv6(address) => address.to_string(), + }) } #[derive(Debug)] @@ -295,6 +318,7 @@ impl tower::Service for InsecureTlsConnector { let port = uri.port_u16().unwrap_or(443); let addr = format!("{host}:{port}"); let tcp = tokio::net::TcpStream::connect(addr).await?; + set_tcp_nodelay_best_effort(&tcp); let server_name = ServerName::try_from(host)?; let tls_stream = tls_connector.connect(server_name, tcp).await?; Ok(hyper_util::rt::TokioIo::new(tls_stream)) @@ -400,22 +424,20 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { // Bearer auth over HTTPS: use mTLS certs for the transport layer when // available (server may still require client certs), and layer the // Bearer token on top via the interceptor. - require_tls_materials(server, tls).map_or_else( - |_| { - let resolved = tls.with_default_paths(server); - resolved - .ca - .as_ref() - .and_then(|ca_path| std::fs::read(ca_path).ok()) - .map_or_else( - || ClientTlsConfig::new().with_enabled_roots(), - |ca_pem| { - ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem)) - }, - ) - }, - |materials| build_tonic_tls_config(&materials), - ) + if let Ok(materials) = require_tls_materials(server, tls) { + build_tonic_tls_config(server, &materials)? + } else { + let resolved = tls.with_default_paths(server); + let config = resolved + .ca + .as_ref() + .and_then(|ca_path| std::fs::read(ca_path).ok()) + .map_or_else( + || ClientTlsConfig::new().with_enabled_roots(), + |ca_pem| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem)), + ); + tls_config_with_server_name(server, config)? + } } else if tls.edge_token.is_some() { // Edge bearer mode — routed through tunnel above; if we reach here // the server is not HTTPS so connect plaintext. @@ -423,7 +445,7 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { } else { // Standard mTLS: private CA + client cert. let materials = require_tls_materials(server, tls)?; - build_tonic_tls_config(&materials) + build_tonic_tls_config(server, &materials)? }; endpoint = endpoint.tls_config(tls_config).into_diagnostic()?; endpoint.connect().await.into_diagnostic() @@ -449,3 +471,25 @@ pub async fn grpc_inference_client(server: &str, tls: &TlsOptions) -> Result\t\t + "4000000\tsbx-test\t0.0.0.0", + ) + .expect("write forward pid file"); +} + +fn forward_list(config_dir: &Path, args: &[&str], no_color: Option<&str>) -> String { + let mut command = Command::new(env!("CARGO_BIN_EXE_openshell")); + command + .args(["forward", "list"]) + .args(args) + .env("XDG_CONFIG_HOME", config_dir) + .env_remove("NO_COLOR") + .env_remove("FORCE_COLOR") + .env_remove("OPENSHELL_COLOR"); + if let Some(value) = no_color { + command.env("NO_COLOR", value); + } + + let output = command.output().expect("run openshell forward list"); + let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8"); + assert!( + stdout.contains(SANDBOX), + "expected the seeded forward in the table, got: {stdout:?}" + ); + stdout +} + +#[test] +fn piped_output_is_free_of_escape_sequences_by_default() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + config_dir_with_forward(tmpdir.path()); + + let stdout = forward_list(tmpdir.path(), &[], None); + + assert!( + !stdout.contains(ESC), + "piped `forward list` must not emit ANSI escapes, got: {stdout:?}" + ); +} + +#[test] +fn no_color_environment_variable_is_honored() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + config_dir_with_forward(tmpdir.path()); + + let stdout = forward_list(tmpdir.path(), &[], Some("1")); + + assert!(!stdout.contains(ESC), "NO_COLOR ignored, got: {stdout:?}"); +} + +#[test] +fn color_never_suppresses_escape_sequences() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + config_dir_with_forward(tmpdir.path()); + + let stdout = forward_list(tmpdir.path(), &["--color", "never"], None); + + assert!( + !stdout.contains(ESC), + "--color never ignored, got: {stdout:?}" + ); +} + +#[test] +fn color_always_emits_escape_sequences_into_a_pipe() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + config_dir_with_forward(tmpdir.path()); + + // The opt-in escape hatch for callers who pipe into a pager and still want + // styling. Also proves the other cases are suppressing real output rather + // than rendering an empty table. + let stdout = forward_list(tmpdir.path(), &["--color", "always"], None); + + assert!( + stdout.contains(ESC), + "--color always must still colorize, got: {stdout:?}" + ); +} + +#[test] +fn explicit_color_flag_overrides_no_color() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + config_dir_with_forward(tmpdir.path()); + + let stdout = forward_list(tmpdir.path(), &["--color", "always"], Some("1")); + + assert!( + stdout.contains(ESC), + "--color always must outrank NO_COLOR, got: {stdout:?}" + ); +} + +/// Run a command that fails to connect, with logging turned up so the `tracing` +/// formatter produces output. +/// +/// `tracing_subscriber::fmt` writes to stdout with ANSI enabled and does no +/// terminal detection of its own, so this is a second, independent way for +/// escapes to reach a pipe. +fn failing_connect(args: &[&str]) -> (String, String) { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let output = Command::new(env!("CARGO_BIN_EXE_openshell")) + .args([ + "sandbox", + "list", + "--gateway", + "test-gateway", + // Nothing listens on port 1; the connection is refused immediately. + "--gateway-endpoint", + "http://127.0.0.1:1", + ]) + .args(args) + .env("XDG_CONFIG_HOME", tmpdir.path()) + .env("RUST_LOG", "debug") + .env_remove("NO_COLOR") + .env_remove("FORCE_COLOR") + .env_remove("OPENSHELL_COLOR") + .output() + .expect("run openshell sandbox list"); + + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +fn verbose_log_output(args: &[&str]) -> String { + failing_connect(args).0 +} + +#[test] +fn tracing_output_is_free_of_escape_sequences_when_piped() { + let plain = verbose_log_output(&[]); + let styled = verbose_log_output(&["--color", "always"]); + + // Positive control. If this fails, the command stopped emitting logs at + // debug level — fix the invocation rather than deleting the assertion + // below, which would otherwise pass vacuously. + assert!( + styled.contains(ESC), + "expected `--color always` to style log output; got: {styled:?}" + ); + assert!( + !plain.contains(ESC), + "piped log output must not emit ANSI escapes, got: {plain:?}" + ); +} + +/// Run a command with stdout attached to a pseudo-terminal and stderr on a +/// pipe, returning what each stream received. +#[cfg(target_os = "linux")] +fn run_with_stdout_tty(mut command: Command) -> (String, String) { + use std::io::Read; + use std::os::fd::{AsRawFd, OwnedFd}; + + let pty = nix::pty::openpty(None, None).expect("openpty"); + let controller: OwnedFd = pty.master; + let follower: OwnedFd = pty.slave; + + let mut child = command + .stdout(follower.try_clone().expect("dup pty follower")) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn openshell"); + // `Command` retains its configured stdio handles after spawning. Drop it so + // the controller sees EIO once the child exits. + drop(command); + + // Drop every follower handle in this process, or reading the controller + // blocks forever instead of returning EIO once the child exits. + drop(follower); + + let mut stderr_buf = String::new(); + child + .stderr + .take() + .expect("stderr pipe") + .read_to_string(&mut stderr_buf) + .expect("read stderr"); + child.wait().expect("wait for openshell"); + + // The pty read ends with EIO rather than a clean EOF; treat that as done. + let mut stdout_buf = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + match nix::unistd::read(controller.as_raw_fd(), &mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => stdout_buf.extend_from_slice(&chunk[..n]), + } + } + + ( + String::from_utf8_lossy(&stdout_buf).into_owned(), + stderr_buf, + ) +} + +/// Run a failing command with stdout attached to a pseudo-terminal and stderr +/// on a pipe, returning what each stream received. +/// +/// `Command::output` gives both streams pipes, so it cannot distinguish a +/// per-stream decision from a single one resolved off stdout. This asymmetric +/// setup is the only way to catch a stream being handed the other stream's +/// answer. +#[cfg(target_os = "linux")] +fn split_streams_stdout_tty(args: &[&str]) -> (String, String) { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let mut command = Command::new(env!("CARGO_BIN_EXE_openshell")); + command + .args([ + "sandbox", + "list", + "--gateway", + "test-gateway", + "--gateway-endpoint", + "http://127.0.0.1:1", + ]) + .args(args) + .env("XDG_CONFIG_HOME", tmpdir.path()) + .env("RUST_LOG", "debug") + // Pin TERM: `auto` now requires a capable terminal, and CI runners + // often leave TERM unset, which would make this test's outcome depend + // on the ambient environment. + .env("TERM", "xterm-256color") + .env_remove("NO_COLOR") + .env_remove("FORCE_COLOR") + .env_remove("OPENSHELL_COLOR"); + + run_with_stdout_tty(command) +} + +/// Run `forward list` with stdout on a pseudo-terminal, under the given `TERM`, +/// and return everything stdout received. +/// +/// When `stderr_on_tty` is true, both streams share the terminal so the +/// `owo-colors` table is styled too. Otherwise, stderr is redirected to +/// `/dev/null`, which verifies the conservative table behavior. +#[cfg(target_os = "linux")] +fn forward_list_on_pty(term: &str, args: &[&str], stderr_on_tty: bool) -> String { + use std::os::fd::{AsRawFd, OwnedFd}; + + let pty = nix::pty::openpty(None, None).expect("openpty"); + let controller: OwnedFd = pty.master; + let follower: OwnedFd = pty.slave; + + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + config_dir_with_forward(tmpdir.path()); + + let mut command = Command::new(env!("CARGO_BIN_EXE_openshell")); + command + .args(["forward", "list"]) + .args(args) + .env("XDG_CONFIG_HOME", tmpdir.path()) + .env("TERM", term) + .env_remove("NO_COLOR") + .env_remove("FORCE_COLOR") + .env_remove("OPENSHELL_COLOR") + .stdout(follower.try_clone().expect("dup pty follower")); + if stderr_on_tty { + command.stderr(follower.try_clone().expect("dup pty follower")); + } else { + command.stderr(std::process::Stdio::null()); + } + let mut child = command.spawn().expect("spawn openshell"); + // `Command` retains its configured stdio handles after spawning. Drop it so + // the controller sees EIO once the child exits. + drop(command); + + // Drop every follower handle here, or the controller read never sees EIO. + drop(follower); + + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + match nix::unistd::read(controller.as_raw_fd(), &mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + } + child.wait().expect("wait for openshell"); + + let out = String::from_utf8_lossy(&buf).into_owned(); + assert!( + out.contains(SANDBOX), + "expected the seeded forward in the table, got: {out:?}" + ); + out +} + +/// A terminal that does not render ANSI must not be styled under `auto`. +/// +/// `TERM=dumb` is still a terminal, so an `is_terminal()` check alone reports it +/// as styleable. `console` and `miette` apply their own `TERM` checks, but the +/// color switch overrides both, so the check has to live here. +#[cfg(target_os = "linux")] +#[test] +fn dumb_terminal_is_not_styled_under_auto() { + let dumb = forward_list_on_pty("dumb", &[], true); + // Positive control: the same session on a capable terminal is styled, so a + // plain result below means capability was consulted, not that the pty setup + // silently produced nothing. + let capable = forward_list_on_pty("xterm-256color", &[], true); + + assert!( + capable.contains(ESC), + "expected styling on a capable terminal; got: {capable:?}" + ); + assert!( + !dumb.contains(ESC), + "TERM=dumb must not be styled, got: {dumb:?}" + ); +} + +/// An explicit request outranks the capability check, for callers who know +/// their terminal better than `TERM` does. +#[cfg(target_os = "linux")] +#[test] +fn color_always_overrides_a_dumb_terminal() { + let forced = forward_list_on_pty("dumb", &["--color", "always"], true); + + assert!( + forced.contains(ESC), + "--color always must style even a dumb terminal, got: {forced:?}" + ); +} + +/// Regression test for a redirected stream inheriting the other stream's +/// terminal check. +/// +/// With stdout on a terminal and stderr redirected, `openshell ... 2> build.log` +/// must leave the log free of escapes while stdout stays styled. +#[cfg(target_os = "linux")] +#[test] +fn redirected_stderr_stays_plain_while_stdout_is_a_terminal() { + let (stdout, stderr) = split_streams_stdout_tty(&[]); + + // Positive control: stdout really is a terminal here, so something must be + // styled. Otherwise the stderr assertion could pass for the wrong reason. + assert!( + stdout.contains(ESC), + "expected styled stdout on a pty; got: {stdout:?}" + ); + assert!( + !stderr.contains(ESC), + "redirected stderr must not receive escapes, got: {stderr:?}" + ); +} + +/// `Painted` cannot identify its destination stream, so table styling is +/// deliberately disabled when either stream is redirected. +#[cfg(target_os = "linux")] +#[test] +fn status_table_is_plain_when_stderr_is_redirected() { + let stdout = forward_list_on_pty("xterm-256color", &[], false); + + assert!( + stdout.contains(SANDBOX), + "expected the seeded forward in the table, got: {stdout:?}" + ); + assert!( + !stdout.contains(ESC), + "STATUS table must stay plain when stderr is redirected, got: {stdout:?}" + ); +} + +#[test] +fn error_output_follows_the_color_setting() { + // miette renders errors to stderr through its own handler. It already + // suppresses color when stderr is redirected, but it has no way to learn + // about `--color`, so `init` installs a handler built from the setting. + let (_, plain) = failing_connect(&[]); + let (_, styled) = failing_connect(&["--color", "always"]); + + assert!( + styled.contains(ESC), + "expected `--color always` to style error output; got: {styled:?}" + ); + assert!( + !plain.contains(ESC), + "piped error output must not emit ANSI escapes, got: {plain:?}" + ); +} + +#[test] +fn status_column_is_matchable_by_a_whitespace_anchored_pattern() { + // The regression this whole change exists for: a supervisor deciding whether + // a forward is alive by matching the STATUS column. Colorized output put an + // escape byte immediately before the status word, so a pattern requiring + // whitespace ahead of it could never match. + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + config_dir_with_forward(tmpdir.path()); + + let stdout = forward_list(tmpdir.path(), &[], None); + let row = stdout + .lines() + .find(|line| line.starts_with(SANDBOX)) + .unwrap_or_else(|| panic!("no row for {SANDBOX} in: {stdout:?}")); + + let status = row + .split_whitespace() + .next_back() + .expect("row has a status column"); + assert_eq!(status, "dead"); + + // Whitespace, not an escape byte, separates the PID from the status. + let boundary = row + .rfind(status) + .and_then(|index| row[..index].chars().next_back()) + .expect("status is preceded by the rest of the row"); + assert!( + boundary.is_whitespace(), + "expected whitespace before the status column, found {boundary:?} in {row:?}" + ); +} diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 0f3115ba7f..2a4801b143 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -17,7 +17,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, @@ -72,6 +73,7 @@ impl TestOpenShell { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ); } @@ -79,6 +81,20 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -110,6 +126,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(SandboxResponse::default())) } + async fn stop_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn start_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_sandbox( &self, _request: tonic::Request, @@ -124,6 +154,8 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, _request: tonic::Request, @@ -219,6 +251,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, request: tonic::Request, @@ -277,14 +316,30 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let profiles = openshell_providers::builtin_profiles() + .iter() + .map(openshell_providers::ProviderTypeProfile::to_proto) + .collect(); + Ok(Response::new( + openshell_core::proto::ListProviderProfilesResponse { profiles }, + )) } async fn get_provider_profile( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let id = request.into_inner().id; + let profile = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == id) + .ok_or_else(|| Status::not_found("provider profile not found"))? + .to_proto(); + Ok(Response::new( + openshell_core::proto::ProviderProfileResponse { + profile: Some(profile), + }, + )) } async fn import_provider_profiles( @@ -379,6 +434,11 @@ impl OpenShell for TestOpenShell { provider.credential_expires_at_ms, ), profile_workspace: existing.profile_workspace, + credential_handles: if provider.credential_handles.is_empty() { + existing.credential_handles + } else { + provider.credential_handles + }, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); @@ -808,7 +868,7 @@ async fn explicit_provider_name_errors_for_unrecognised_name() { "error should mention the name: {msg}" ); assert!( - msg.contains("not a recognized provider type"), + msg.contains("no provider profile"), "error should explain why it failed: {msg}" ); } diff --git a/crates/openshell-cli/tests/forward_list_integration.rs b/crates/openshell-cli/tests/forward_list_integration.rs new file mode 100644 index 0000000000..50abfec37d --- /dev/null +++ b/crates/openshell-cli/tests/forward_list_integration.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +const DEAD_PID: u32 = u32::MAX; + +fn run_forward_list(config_dir: &Path, args: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_openshell")); + for (key, _) in std::env::vars().filter(|(key, _)| key.starts_with("OPENSHELL_")) { + command.env_remove(key); + } + command + .args(["forward", "list"]) + .args(args) + .env("XDG_CONFIG_HOME", config_dir) + .env("HOME", config_dir) + .output() + .expect("run openshell forward list") +} + +fn write_dead_forward(config_dir: &Path) { + let forward_dir = config_dir.join("openshell/forwards"); + fs::create_dir_all(&forward_dir).expect("create forward directory"); + fs::write( + forward_dir.join("my-sandbox-8443.pid"), + format!("{DEAD_PID}\tsandbox-id\t0.0.0.0"), + ) + .expect("write forward PID record"); +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "forward list failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn forward_list_json_emits_machine_readable_records() { + let config_dir = tempfile::tempdir().expect("create config directory"); + write_dead_forward(config_dir.path()); + + let output = run_forward_list(config_dir.path(), &["--output", "json"]); + assert_success(&output); + assert!( + output.stderr.is_empty(), + "structured output wrote to stderr" + ); + assert!( + !output.stdout.contains(&0x1b), + "JSON contained ANSI escapes" + ); + + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should contain only JSON"); + assert_eq!( + value, + serde_json::json!([{ + "sandbox": "my-sandbox", + "bind_address": "0.0.0.0", + "port": 8443, + "pid": DEAD_PID, + "alive": false, + }]) + ); +} + +#[test] +fn forward_list_yaml_emits_equivalent_records() { + let config_dir = tempfile::tempdir().expect("create config directory"); + write_dead_forward(config_dir.path()); + + let output = run_forward_list(config_dir.path(), &["-o", "yaml"]); + assert_success(&output); + assert!( + output.stderr.is_empty(), + "structured output wrote to stderr" + ); + assert!( + !output.stdout.contains(&0x1b), + "YAML contained ANSI escapes" + ); + + let value: serde_json::Value = + serde_yml::from_slice(&output.stdout).expect("stdout should contain only YAML"); + assert_eq!( + value, + serde_json::json!([{ + "sandbox": "my-sandbox", + "bind_address": "0.0.0.0", + "port": 8443, + "pid": DEAD_PID, + "alive": false, + }]) + ); +} + +#[test] +fn forward_list_structured_output_emits_empty_collections() { + let config_dir = tempfile::tempdir().expect("create config directory"); + + for format in ["json", "yaml"] { + let output = run_forward_list(config_dir.path(), &["--output", format]); + assert_success(&output); + assert!(output.stderr.is_empty(), "{format} output wrote to stderr"); + + let value: serde_json::Value = match format { + "json" => serde_json::from_slice(&output.stdout).expect("parse empty JSON collection"), + "yaml" => serde_yml::from_slice(&output.stdout).expect("parse empty YAML collection"), + _ => unreachable!(), + }; + assert_eq!(value, serde_json::json!([])); + } +} + +#[test] +fn forward_list_table_output_keeps_existing_columns() { + let config_dir = tempfile::tempdir().expect("create config directory"); + write_dead_forward(config_dir.path()); + + let output = run_forward_list(config_dir.path(), &[]); + assert_success(&output); + assert!(output.stderr.is_empty(), "table output wrote to stderr"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let rows: Vec> = stdout + .lines() + .map(|line| line.split_whitespace().collect()) + .collect(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0], vec!["SANDBOX", "BIND", "PORT", "PID", "STATUS"]); + assert_eq!( + &rows[1][..4], + ["my-sandbox", "0.0.0.0", "8443", "4294967295"] + ); + assert!(rows[1][4].contains("dead")); +} + +#[test] +fn forward_list_help_documents_output_flag() { + let config_dir = tempfile::tempdir().expect("create config directory"); + let output = run_forward_list(config_dir.path(), &["--help"]); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).expect("help should be UTF-8"); + assert!(stdout.contains("-o, --output "), "{stdout}"); + assert!(stdout.contains("[default: table]"), "{stdout}"); + assert!( + stdout.contains("possible values: table, yaml, json"), + "{stdout}" + ); +} diff --git a/crates/openshell-cli/tests/helpers/mod.rs b/crates/openshell-cli/tests/helpers/mod.rs index a58e750b91..c4e9b4b75a 100644 --- a/crates/openshell-cli/tests/helpers/mod.rs +++ b/crates/openshell-cli/tests/helpers/mod.rs @@ -8,6 +8,95 @@ //! mod helpers; //! ``` +#[macro_export] +macro_rules! unimplemented_sandbox_template_rpcs { + () => { + fn create_sandbox_template<'life0, 'async_trait>( + &'life0 self, + _request: tonic::Request, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + tonic::Status, + >, + > + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) + } + + fn get_sandbox_template<'life0, 'async_trait>( + &'life0 self, + _request: tonic::Request, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + tonic::Status, + >, + > + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) + } + + fn list_sandbox_templates<'life0, 'async_trait>( + &'life0 self, + _request: tonic::Request, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + tonic::Status, + >, + > + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) + } + + fn delete_sandbox_template<'life0, 'async_trait>( + &'life0 self, + _request: tonic::Request, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + tonic::Response, + tonic::Status, + >, + > + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) + } + }; +} + use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, }; diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 38c68ed83a..12c838baf1 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -13,10 +13,11 @@ use openshell_cli::{ }; use openshell_core::proto::{ CreateProviderRequest, CreateSshSessionRequest, CreateSshSessionResponse, - DeleteProviderRequest, DeleteProviderResponse, ExecSandboxEvent, ExecSandboxInput, - ExecSandboxRequest, GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - ServiceStatus, UpdateProviderRequest, + DeleteProviderRequest, DeleteProviderResponse, ExchangeProviderSubjectTokenRequest, + ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, ServiceStatus, + UpdateProviderRequest, open_shell_server::{OpenShell, OpenShellServer}, }; use tempfile::tempdir; @@ -33,6 +34,20 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -66,6 +81,20 @@ impl OpenShell for TestOpenShell { )) } + async fn stop_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn start_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_sandbox( &self, _request: tonic::Request, @@ -84,6 +113,8 @@ impl OpenShell for TestOpenShell { )) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, _request: tonic::Request, @@ -192,6 +223,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 3f173115c8..e48ca84af0 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -14,7 +14,8 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, @@ -22,15 +23,17 @@ use openshell_core::proto::{ HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderCredentialRefresh, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCredential, - ProviderProfileDiscovery, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse, - SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, - WatchSandboxRequest, setting_value, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, ProviderProfile, + ProviderProfileCredential, ProviderProfileDiscovery, ProviderResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, + Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, + UpdateProviderRequest, WatchSandboxRequest, }; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{Mutex, mpsc}; @@ -42,12 +45,17 @@ use tonic::{Response, Status}; struct ProviderState { providers: Arc>>, profiles: Arc>>, + scoped_profiles: Arc>>, refresh_statuses: Arc>>, refresh_requests: Arc>>, + provider_update_requests: Arc>>, + deny_provider_reads: Arc, delete_provider_requests: Arc>>, + delete_provider_profile_requests: Arc>>, fail_configure_refresh_message: Arc>>, fail_rotate_refresh_message: Arc>>, fail_delete_provider_message: Arc>>, + fail_delete_provider_profile_message: Arc>>, sandbox_providers: Arc>>>, sandbox_provider_requests: Arc>>, global_settings: Arc>>, @@ -98,6 +106,20 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -129,6 +151,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(SandboxResponse::default())) } + async fn stop_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn start_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_sandbox( &self, request: tonic::Request, @@ -149,6 +185,7 @@ impl OpenShell for TestOpenShell { }), spec: None, status: None, + ..Sandbox::default() }), })) } @@ -160,6 +197,8 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, request: tonic::Request, @@ -351,6 +390,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, request: tonic::Request, @@ -359,10 +405,10 @@ impl OpenShell for TestOpenShell { .into_inner() .provider .ok_or_else(|| Status::invalid_argument("provider is required"))?; - if provider.credentials.is_empty() { + if provider.credentials.is_empty() && provider.credential_handles.is_empty() { let bootstrap_allowed = if let Some(profile) = openshell_providers::builtin_profiles() .iter() - .find(|profile| profile.id == provider.r#type) + .find(|p| p.id.eq_ignore_ascii_case(&provider.r#type)) { profile.allows_empty_provider_credentials() } else { @@ -403,6 +449,9 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { + if self.state.deny_provider_reads.load(Ordering::SeqCst) { + return Err(Status::permission_denied("scope 'provider:read' required")); + } let name = request.into_inner().name; let providers = self.state.providers.lock().await; let provider = providers @@ -447,8 +496,18 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let id = request.into_inner().id; - let profile = if let Some(profile) = openshell_providers::builtin_profiles() + let request = request.into_inner(); + let id = request.id; + let scoped_profile = self + .state + .scoped_profiles + .lock() + .await + .get(&(request.workspace, id.clone())) + .cloned(); + let profile = if let Some(profile) = scoped_profile { + profile + } else if let Some(profile) = openshell_providers::builtin_profiles() .iter() .find(|profile| profile.id == id) { @@ -582,6 +641,11 @@ impl OpenShell for TestOpenShell { .into_inner() .provider .ok_or_else(|| Status::invalid_argument("provider is required"))?; + self.state + .provider_update_requests + .lock() + .await + .push(provider.clone()); let mut providers = self.state.providers.lock().await; let existing = providers @@ -638,6 +702,11 @@ impl OpenShell for TestOpenShell { provider.credential_expires_at_ms, ), profile_workspace: existing.profile_workspace, + credential_handles: if provider.credential_handles.is_empty() { + existing.credential_handles + } else { + provider.credential_handles + }, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); @@ -716,6 +785,10 @@ impl OpenShell for TestOpenShell { next_refresh_at_ms: 0, last_refresh_at_ms: 0, last_error: String::new(), + recovery_action: 0, + failure_code: String::new(), + provider_error_subtype: String::new(), + last_error_at_ms: 0, }; drop(providers); self.state @@ -820,6 +893,20 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let id = request.into_inner().id; + self.state + .delete_provider_profile_requests + .lock() + .await + .push(id.clone()); + let delete_failure = self + .state + .fail_delete_provider_profile_message + .lock() + .await + .take(); + if let Some(message) = delete_failure { + return Err(Status::internal(message)); + } let deleted = self.state.profiles.lock().await.remove(&id).is_some(); Ok(Response::new( openshell_core::proto::DeleteProviderProfileResponse { deleted }, @@ -1117,15 +1204,132 @@ async fn run_server() -> TestServer { } } -async fn enable_providers_v2(ts: &TestServer) { - ts.state.global_settings.lock().await.insert( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - SettingValue { - value: Some(setting_value::Value::BoolValue(true)), +async fn install_test_profile(ts: &TestServer, id: &str, credential_key: &str) { + ts.state.profiles.lock().await.insert( + id.to_string(), + ProviderProfile { + id: id.to_string(), + display_name: id.to_string(), + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + env_vars: vec![credential_key.to_string()], + required: true, + ..Default::default() + }], + ..Default::default() }, ); } +/// A readable provider must carry its stored type and profile workspace into +/// the update request. Policy interceptors evaluate the request before the +/// gateway merges it with stored state, so an update that omits them cannot be +/// authorized against the profile that owns the provider. +/// +/// The stored `profile_workspace` is forwarded verbatim rather than recomputed +/// from the request workspace. The gateway treats it as immutable, so deriving +/// it here would look like a change and be rejected. +#[tokio::test] +async fn provider_update_preserves_stored_type_and_profile_workspace_when_readable() { + let ts = run_server().await; + + run::provider_create( + &ts.endpoint, + "my-claude", + "claude", + false, + &["API_KEY=abc".to_string()], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect("provider create"); + + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "my-claude", + from_existing: false, + from_oidc_token: false, + credentials: &["API_KEY=rotated".to_string()], + config: &[], + credential_expires_at: &[], + workspace: "default", + tls: &ts.tls, + }) + .await + .expect("provider update"); + + let requests = ts.state.provider_update_requests.lock().await; + let request = requests.last().expect("provider update request"); + // `claude` normalizes to the canonical `claude-code` at creation, so the + // update carries the stored type rather than the alias the caller typed. + assert_eq!(request.r#type, "claude-code"); + // Forwarded verbatim rather than recomputed. The gateway treats + // profile_workspace as immutable, so any substitution here would look like + // a change and be rejected. + let stored = ts.state.providers.lock().await; + let stored = stored.get("my-claude").expect("stored provider"); + assert_eq!(request.profile_workspace, stored.profile_workspace); +} + +#[tokio::test] +async fn provider_delete_continues_after_entry_failure() { + let ts = run_server().await; + *ts.state.fail_delete_provider_message.lock().await = + Some("simulated provider delete failure".to_string()); + + let err = run::provider_delete( + &ts.endpoint, + &["failing-provider".to_string(), "later-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect_err("provider delete should report aggregate failure"); + + let msg = err.to_string(); + assert!( + msg.contains("failed to delete 1 provider: failing-provider"), + "unexpected error: {msg}" + ); + assert_eq!( + ts.state.delete_provider_requests.lock().await.clone(), + vec!["failing-provider".to_string(), "later-provider".to_string()] + ); +} + +#[tokio::test] +async fn provider_profile_delete_continues_after_entry_failure() { + let ts = run_server().await; + *ts.state.fail_delete_provider_profile_message.lock().await = + Some("simulated provider profile delete failure".to_string()); + + let err = run::provider_profile_delete( + &ts.endpoint, + &["failing-profile".to_string(), "later-profile".to_string()], + "default", + &ts.tls, + ) + .await + .expect_err("provider profile delete should report aggregate failure"); + + let msg = err.to_string(); + assert!( + msg.contains("failed to delete 1 provider profile: failing-profile"), + "unexpected error: {msg}" + ); + assert_eq!( + ts.state + .delete_provider_profile_requests + .lock() + .await + .clone(), + vec!["failing-profile".to_string(), "later-profile".to_string()] + ); +} + #[tokio::test] async fn provider_cli_run_functions_support_full_crud_flow() { let ts = run_server().await; @@ -1160,19 +1364,30 @@ async fn provider_cli_run_functions_support_full_crud_flow() { .await .expect("provider list"); - run::provider_update( - &ts.endpoint, - "my-claude", - false, - &["API_KEY=rotated".to_string()], - &["profile=prod".to_string()], - &[], - "default", - &ts.tls, - ) + // A credential-only update must remain available to callers that have + // provider:write but not provider:read. + ts.state.deny_provider_reads.store(true, Ordering::SeqCst); + + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "my-claude", + from_existing: false, + from_oidc_token: false, + credentials: &["API_KEY=rotated".to_string()], + config: &["profile=prod".to_string()], + credential_expires_at: &[], + workspace: "default", + tls: &ts.tls, + }) .await .expect("provider update"); + let requests = ts.state.provider_update_requests.lock().await; + let request = requests.last().expect("provider update request"); + assert!(request.r#type.is_empty()); + assert!(request.profile_workspace.is_empty()); + drop(requests); + run::provider_delete(&ts.endpoint, &["my-claude".to_string()], "default", &ts.tls) .await .expect("provider delete"); @@ -1295,11 +1510,12 @@ async fn provider_list_json_empty() { #[tokio::test] async fn provider_refresh_cli_run_functions_wire_requests() { let ts = run_server().await; + install_test_profile(&ts, "custom-graph", "MS_GRAPH_ACCESS_TOKEN").await; run::provider_create( &ts.endpoint, "my-graph", - "outlook", + "custom-graph", false, &["MS_GRAPH_ACCESS_TOKEN=token".to_string()], false, @@ -1384,11 +1600,12 @@ async fn provider_refresh_cli_run_functions_wire_requests() { #[tokio::test] async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { let ts = run_server().await; + install_test_profile(&ts, "custom-chat", "GOOGLE_CHAT_ACCESS_TOKEN").await; run::provider_create( &ts.endpoint, "gc-bridge", - "outlook", + "custom-chat", false, &["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()], false, @@ -1518,19 +1735,17 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() }, ); - run::provider_create_with_options( - &ts.endpoint, - "custom-refresh-provider", - "custom-refresh", - false, - &[], - false, - true, - &[], - "default", - "default", - &ts.tls, - ) + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "custom-refresh-provider", + provider_type: "custom-refresh", + credentials: &[], + credential_source: run::ProviderCreateCredentialSource::Runtime, + config: &[], + workspace: "default", + profile_workspace: "default", + tls: &ts.tls, + }) .await .expect("provider create"); @@ -1541,7 +1756,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() } #[tokio::test] -async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_profiles() { +async fn provider_create_allows_no_source_for_runtime_resolved_profiles() { let ts = run_server().await; ts.state.profiles.lock().await.insert( "custom-refresh".to_string(), @@ -1561,7 +1776,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ }, ); - let err = run::provider_create( + run::provider_create( &ts.endpoint, "custom-refresh-provider", "custom-refresh", @@ -1573,11 +1788,10 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ &ts.tls, ) .await - .expect_err("empty runtime-resolved providers should require an explicit source"); + .expect("runtime-resolved provider should not require a credential source"); - assert!(err.to_string().contains("--runtime-credentials")); assert!( - !ts.state + ts.state .providers .lock() .await @@ -1585,6 +1799,36 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ ); } +#[tokio::test] +async fn provider_create_allows_credentialless_policy_profile() { + let ts = run_server().await; + + run::provider_create( + &ts.endpoint, + "pypi", + "pypi", + false, + &[], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect("credential-less provider create"); + + let provider = ts + .state + .providers + .lock() + .await + .get("pypi") + .cloned() + .expect("pypi provider"); + assert!(provider.credentials.is_empty()); + assert_eq!(provider.r#type, "pypi"); +} + #[tokio::test] async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results() { let ts = run_server().await; @@ -1621,7 +1865,7 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results ) .await .expect("sandbox provider attach is idempotent"); - run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "default", &ts.tls) + run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "table", "default", &ts.tls) .await .expect("sandbox provider list"); run::sandbox_provider_detach( @@ -1793,6 +2037,21 @@ binaries: [/usr/bin/custom] .expect("custom provider should be stored"); assert_eq!(provider.r#type, "custom-api"); + let mut custom_alt_profile = ts + .state + .profiles + .lock() + .await + .get("custom-api") + .cloned() + .expect("custom-api profile should be stored"); + custom_alt_profile.id = "custom-alt".to_string(); + ts.state + .profiles + .lock() + .await + .insert("custom-alt".to_string(), custom_alt_profile); + run::provider_delete( &ts.endpoint, &["custom-provider".to_string()], @@ -1801,15 +2060,22 @@ binaries: [/usr/bin/custom] ) .await .expect("custom provider delete"); - run::provider_profile_delete(&ts.endpoint, "custom-api", "default", &ts.tls) - .await - .expect("profile delete"); + run::provider_profile_delete( + &ts.endpoint, + &["custom-api".to_string(), "custom-alt".to_string()], + "default", + &ts.tls, + ) + .await + .expect("profile delete"); + let profiles = ts.state.profiles.lock().await; + assert!(!profiles.contains_key("custom-api")); + assert!(!profiles.contains_key("custom-alt")); } #[tokio::test] -async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() { +async fn provider_create_from_existing_uses_profile_discovery() { let ts = run_server().await; - enable_providers_v2(&ts).await; ts.state.profiles.lock().await.insert( "custom-discovery".to_string(), ProviderProfile { @@ -1859,7 +2125,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() } #[tokio::test] -async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled() { +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")]); @@ -1893,9 +2159,8 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled( } #[tokio::test] -async fn provider_create_from_existing_vertex_discovers_credentials_and_config_when_v2_enabled() { +async fn provider_create_from_existing_vertex_discovers_credentials_and_config() { let ts = run_server().await; - enable_providers_v2(&ts).await; let _env = EnvVarGuard::set(&[ ("VERTEX_AI_TOKEN", "ya29.vertex-v2-fallback"), ("VERTEX_AI_PROJECT_ID", "vertex-v2-project"), @@ -1956,9 +2221,8 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w } #[tokio::test] -async fn provider_create_from_existing_requires_profile_when_v2_enabled() { +async fn provider_create_from_existing_requires_profile() { let ts = run_server().await; - enable_providers_v2(&ts).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")]); @@ -1979,7 +2243,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { assert!( err.to_string() - .contains("providers v2 discovery requires a provider profile"), + .contains("import a matching profile before using this provider type"), "unexpected error: {err}" ); assert!(!ts.state.providers.lock().await.contains_key("v2-generic")); @@ -1988,7 +2252,6 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { #[tokio::test] async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothing() { let ts = run_server().await; - enable_providers_v2(&ts).await; ts.state.profiles.lock().await.insert( "empty-discovery".to_string(), ProviderProfile { @@ -2036,9 +2299,8 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin } #[tokio::test] -async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() { +async fn provider_update_from_existing_uses_profile_discovery() { let ts = run_server().await; - enable_providers_v2(&ts).await; ts.state.profiles.lock().await.insert( "custom-update-discovery".to_string(), ProviderProfile { @@ -2069,20 +2331,22 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ); let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); - run::provider_update( - &ts.endpoint, - "custom-update", - true, - &[], - &[], - &[], - "default", - &ts.tls, - ) + 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, + }) .await .expect("profile-backed provider update --from-existing"); @@ -2100,6 +2364,147 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() ); } +#[tokio::test] +async fn provider_update_from_existing_preserves_global_profile_scope() { + let ts = run_server().await; + let profile_id = "shadowed-update-discovery"; + for (profile_workspace, env_var) in [ + ("", "GLOBAL_UPDATE_DISCOVERY_API_KEY"), + ("default", "WORKSPACE_UPDATE_DISCOVERY_API_KEY"), + ] { + ts.state.scoped_profiles.lock().await.insert( + (profile_workspace.to_string(), profile_id.to_string()), + ProviderProfile { + id: profile_id.to_string(), + credentials: vec![ProviderProfileCredential { + name: "api_key".to_string(), + env_vars: vec![env_var.to_string()], + required: true, + ..Default::default() + }], + discovery: Some(ProviderProfileDiscovery { + credentials: vec!["api_key".to_string()], + }), + ..Default::default() + }, + ); + } + ts.state.providers.lock().await.insert( + "global-update".to_string(), + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "id-global-update".to_string(), + name: "global-update".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: profile_id.to_string(), + profile_workspace: String::new(), + ..Default::default() + }, + ); + let _env = EnvVarGuard::set(&[ + ("GLOBAL_UPDATE_DISCOVERY_API_KEY", "global-secret"), + ("WORKSPACE_UPDATE_DISCOVERY_API_KEY", "workspace-secret"), + ]); + + 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, + }) + .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") + ); +} + +#[tokio::test] +async fn provider_update_from_oidc_token_preserves_global_profile_scope() { + let ts = run_server().await; + let profile_id = "shadowed-oidc-update"; + for (profile_workspace, subject_credential) in [ + ("", "GLOBAL_SUBJECT_TOKEN"), + ("default", "WORKSPACE_SUBJECT_TOKEN"), + ] { + ts.state.scoped_profiles.lock().await.insert( + (profile_workspace.to_string(), profile_id.to_string()), + ProviderProfile { + id: profile_id.to_string(), + credentials: vec![ProviderProfileCredential { + name: "dynamic_token".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: subject_credential.to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }, + ); + } + ts.state.providers.lock().await.insert( + "global-oidc-update".to_string(), + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "id-global-oidc-update".to_string(), + name: "global-oidc-update".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: profile_id.to_string(), + profile_workspace: String::new(), + ..Default::default() + }, + ); + + let err = run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "global-oidc-update", + from_existing: false, + from_oidc_token: true, + credentials: &["GLOBAL_SUBJECT_TOKEN".to_string()], + config: &[], + credential_expires_at: &[], + workspace: "default", + tls: &ts.tls, + }) + .await + .expect_err("unnamed test gateway should stop after profile validation"); + + assert!( + err.to_string().contains("active named OIDC gateway"), + "global profile should accept GLOBAL_SUBJECT_TOKEN before OIDC loading: {err}" + ); +} + #[tokio::test] async fn provider_profile_import_from_directory_imports_supported_profile_files() { let ts = run_server().await; @@ -2296,11 +2701,11 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() } #[tokio::test] -async fn provider_create_supports_generic_type_and_env_lookup_credentials() { +async fn provider_create_rejects_profileless_generic_type() { let ts = run_server().await; let _guard = EnvVarGuard::set(&[("NAV_GENERIC_TEST_KEY", "generic-value")]); - run::provider_create( + let err = run::provider_create( &ts.endpoint, "my-generic", "generic", @@ -2312,24 +2717,75 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { &ts.tls, ) .await - .expect("provider create"); + .expect_err("profileless generic provider creation should fail"); - 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-generic".to_string(), - workspace: String::new(), - }) - .await - .expect("get provider should succeed") - .into_inner(); - let provider = response.provider.expect("provider should exist"); - assert_eq!(provider.r#type, "generic"); + assert!( + err.to_string() + .contains("provider profile 'generic' not found"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn provider_create_sends_inline_credentials() { + let ts = run_server().await; + + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "openai-inline", + provider_type: "openai", + credentials: &["OPENAI_API_KEY=sk-test".to_string()], + credential_source: run::ProviderCreateCredentialSource::ExplicitCredentials, + config: &[], + workspace: "default", + profile_workspace: "default", + tls: &ts.tls, + }) + .await + .expect("provider create with inline credential"); + + let stored = ts.state.providers.lock().await; assert_eq!( - provider.credentials.get("NAV_GENERIC_TEST_KEY"), - Some(&"generic-value".to_string()) + stored + .get("openai-inline") + .and_then(|provider| provider.credentials.get("OPENAI_API_KEY")) + .map(String::as_str), + Some("sk-test") + ); + assert!( + stored + .get("openai-inline") + .expect("provider") + .credential_handles + .is_empty() + ); +} + +#[tokio::test] +async fn provider_create_prefers_exact_imported_alias_profile() { + let ts = run_server().await; + install_test_profile(&ts, "gh", "GITHUB_TOKEN").await; + + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "enterprise-github", + provider_type: "gh", + credentials: &["GITHUB_TOKEN=test-token".to_string()], + credential_source: run::ProviderCreateCredentialSource::ExplicitCredentials, + config: &[], + workspace: "default", + profile_workspace: "default", + tls: &ts.tls, + }) + .await + .expect("create provider from exact imported alias profile"); + + let stored = ts.state.providers.lock().await; + let provider = stored.get("enterprise-github").expect("provider"); + assert_eq!(provider.r#type, "gh"); + assert_eq!( + provider.credentials.get("GITHUB_TOKEN").map(String::as_str), + Some("test-token") ); } @@ -2378,7 +2834,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { assert!( err.to_string() - .contains("--from-gcloud-adc cannot be combined with --from-existing or --credential"), + .contains("--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential"), "unexpected error: {err}" ); assert!(ts.state.providers.lock().await.is_empty()); @@ -2404,7 +2860,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { assert!( err.to_string() - .contains("--from-gcloud-adc cannot be combined with --from-existing or --credential"), + .contains("--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential"), "unexpected error: {err}" ); assert!(ts.state.providers.lock().await.is_empty()); @@ -2413,14 +2869,14 @@ 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(&[("NAV_EMPTY_ENV_KEY", "")]); + let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "")]); let err = run::provider_create( &ts.endpoint, "bad-provider", - "generic", + "nvidia", false, - &["NAV_EMPTY_ENV_KEY".to_string()], + &["NVIDIA_API_KEY".to_string()], false, &[], "default", @@ -2431,7 +2887,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() { assert!( err.to_string() - .contains("requires local env var 'NAV_EMPTY_ENV_KEY' to be set to a non-empty value"), + .contains("requires local env var 'NVIDIA_API_KEY' to be set to a non-empty value"), "unexpected error: {err}" ); } @@ -2826,7 +3282,6 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate #[tokio::test] async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex_credentials() { let ts = run_server().await; - enable_providers_v2(&ts).await; let _env = EnvVarGuard::set(&[ ("VERTEX_AI_PROJECT_ID", "vertex-config-only-project"), ("VERTEX_AI_REGION", "us-central1"), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 5fa27234c4..7be771c442 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![cfg(not(target_os = "windows"))] + mod helpers; use helpers::{ @@ -12,20 +14,22 @@ use openshell_cli::tls::TlsOptions; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, - DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, - ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, - GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GpuResourceRequirements, - HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, - ListSandboxesResponse, PlatformEvent, ProviderResponse, RevokeSshSessionRequest, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, + CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, + DeleteSandboxResponse, DeleteSandboxTemplateRequest, DetachSandboxProviderRequest, + DetachSandboxProviderResponse, ExchangeProviderSubjectTokenRequest, + ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, + GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxTemplateRequest, + GpuResourceRequirements, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, + ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, ListSandboxesRequest, + ListSandboxesResponse, PlatformEvent, Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, SandboxCondition, SandboxLogLine, SandboxPhase, - SandboxResponse, SandboxStatus, SandboxStreamEvent, ServiceStatus, SettingValue, - SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, sandbox_stream_event, - setting_value, + SandboxResponse, SandboxStatus, SandboxStreamEvent, SandboxTemplateResponse, + SandboxWorkloadTemplate, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, + WatchSandboxRequest, sandbox_stream_event, }; use std::collections::HashMap; use std::fs; @@ -44,11 +48,21 @@ use tonic::{Response, Status}; struct SandboxState { deleted_names: Arc>>>, create_requests: Arc>>, + fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, + vm_error_with_observed_exit: Arc, vm_slow_progress_before_ready: Arc, vm_log_churn_before_ready: Arc, + terminal_before_relay: Arc, + ssh_session_failures_remaining: Arc, + ssh_session_requests: Arc, global_settings: Arc>>, gateway_config_requests: Arc, + providers: Arc>>, + template_create_requests: Arc>>, + template_get_requests: Arc>>, + template_list_requests: Arc>>, + template_delete_requests: Arc>>, } #[derive(Clone, Default)] @@ -58,6 +72,20 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -114,6 +142,20 @@ impl OpenShell for TestOpenShell { })) } + async fn stop_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn start_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_sandbox( &self, request: tonic::Request, @@ -145,6 +187,95 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + async fn create_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let mut template = request.template.clone().unwrap_or_default(); + let name = template + .metadata + .as_ref() + .map_or_else(|| "template".to_string(), |metadata| metadata.name.clone()); + template.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("template-{name}"), + name, + created_at_ms: 0, + labels: template + .metadata + .as_ref() + .map(|metadata| metadata.labels.clone()) + .unwrap_or_default(), + resource_version: 1, + annotations: HashMap::new(), + workspace: request.workspace.clone(), + deletion_timestamp_ms: 0, + }); + self.state + .template_create_requests + .lock() + .await + .push(request); + Ok(Response::new(SandboxTemplateResponse { + template: Some(template), + })) + } + + async fn get_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + self.state + .template_get_requests + .lock() + .await + .push(request.clone()); + Ok(Response::new(SandboxTemplateResponse { + template: Some(SandboxWorkloadTemplate { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("template-{}", request.name), + name: request.name, + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 1, + annotations: HashMap::new(), + workspace: request.workspace, + deletion_timestamp_ms: 0, + }), + spec: None, + }), + })) + } + + async fn list_sandbox_templates( + &self, + request: tonic::Request, + ) -> Result, Status> { + self.state + .template_list_requests + .lock() + .await + .push(request.into_inner()); + Ok(Response::new(ListSandboxTemplatesResponse { + templates: Vec::new(), + })) + } + + async fn delete_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + self.state + .template_delete_requests + .lock() + .await + .push(request.into_inner()); + Ok(Response::new( + openshell_core::proto::DeleteSandboxTemplateResponse { deleted: true }, + )) + } + async fn list_sandbox_providers( &self, _request: tonic::Request, @@ -175,7 +306,11 @@ impl OpenShell for TestOpenShell { .deleted_names .lock() .await - .push(vec![request.name]); + .push(vec![request.name.clone()]); + let delete_failure = self.state.fail_delete_sandbox_message.lock().await.take(); + if let Some(message) = delete_failure { + return Err(Status::internal(message)); + } Ok(Response::new(DeleteSandboxResponse { deleted: true })) } @@ -212,6 +347,19 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { + self.state + .ssh_session_requests + .fetch_add(1, Ordering::SeqCst); + if self + .state + .ssh_session_failures_remaining + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Err(Status::failed_precondition("sandbox is not ready")); + } let sandbox_id = request.into_inner().sandbox_id; Ok(Response::new(CreateSshSessionResponse { sandbox_id, @@ -260,6 +408,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, @@ -278,21 +433,39 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - Ok(Response::new(ListProvidersResponse::default())) + Ok(Response::new(ListProvidersResponse { + providers: self.state.providers.lock().await.clone(), + })) } async fn list_provider_profiles( &self, _request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let profiles = openshell_providers::builtin_profiles() + .iter() + .map(openshell_providers::ProviderTypeProfile::to_proto) + .collect(); + Ok(Response::new( + openshell_core::proto::ListProviderProfilesResponse { profiles }, + )) } async fn get_provider_profile( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let id = request.into_inner().id; + let profile = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == id) + .ok_or_else(|| Status::not_found("provider profile not found"))? + .to_proto(); + Ok(Response::new( + openshell_core::proto::ProviderProfileResponse { + profile: Some(profile), + }, + )) } async fn import_provider_profiles( @@ -378,11 +551,16 @@ impl OpenShell for TestOpenShell { let sandbox_id = request.into_inner().id; let (tx, rx) = mpsc::channel(4); let vm_error_after_started = self.state.vm_error_after_started.load(Ordering::SeqCst); + let vm_error_with_observed_exit = self + .state + .vm_error_with_observed_exit + .load(Ordering::SeqCst); let vm_slow_progress_before_ready = self .state .vm_slow_progress_before_ready .load(Ordering::SeqCst); let vm_log_churn_before_ready = self.state.vm_log_churn_before_ready.load(Ordering::SeqCst); + let terminal_before_relay = self.state.terminal_before_relay.load(Ordering::SeqCst); tokio::spawn(async move { let mut provisioning = Sandbox { @@ -414,8 +592,17 @@ impl OpenShell for TestOpenShell { ..provisioning.clone() }; error.set_phase(SandboxPhase::Error as i32); + if vm_error_with_observed_exit { + error.status.as_mut().unwrap().exit_code = Some(137); + } let mut ready = provisioning.clone(); ready.set_phase(SandboxPhase::Ready as i32); + let mut completed = provisioning.clone(); + completed.status = Some(SandboxStatus { + exit_code: Some(0), + ..SandboxStatus::default() + }); + completed.set_phase(SandboxPhase::Completed as i32); let _ = tx .send(Ok(SandboxStreamEvent { @@ -465,6 +652,14 @@ impl OpenShell for TestOpenShell { .await; return; } + if terminal_before_relay { + let _ = tx + .send(Ok(SandboxStreamEvent { + payload: Some(sandbox_stream_event::Payload::Sandbox(completed)), + })) + .await; + return; + } if vm_slow_progress_before_ready { tokio::time::sleep(Duration::from_millis(600)).await; let _ = tx @@ -1112,13 +1307,61 @@ async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } -async fn enable_providers_v2(server: &TestServer) { - server.openshell.state.global_settings.lock().await.insert( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }, - ); +async fn template_create_requests(server: &TestServer) -> Vec { + server + .openshell + .state + .template_create_requests + .lock() + .await + .clone() +} + +async fn template_list_requests(server: &TestServer) -> Vec { + server + .openshell + .state + .template_list_requests + .lock() + .await + .clone() +} + +async fn template_delete_requests(server: &TestServer) -> Vec { + server + .openshell + .state + .template_delete_requests + .lock() + .await + .clone() +} + +async fn add_provider(server: &TestServer, name: &str, provider_type: &str) { + server + .openshell + .state + .providers + .lock() + .await + .push(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("provider-{name}"), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }); } fn test_tls(server: &TestServer) -> TlsOptions { @@ -1142,6 +1385,42 @@ fn test_config() -> run::SandboxCreateConfig<'static> { } } +#[tokio::test] +async fn sandbox_delete_continues_after_entry_failure() { + let server = run_server().await; + let tls = test_tls(&server); + *server + .openshell + .state + .fail_delete_sandbox_message + .lock() + .await = Some("simulated sandbox delete failure".to_string()); + + let err = run::sandbox_delete( + &server.endpoint, + &["failing-sandbox".to_string(), "later-sandbox".to_string()], + false, + "default", + &tls, + "openshell", + ) + .await + .expect_err("sandbox delete should report aggregate failure"); + + let msg = err.to_string(); + assert!( + msg.contains("failed to delete 1 sandbox: failing-sandbox"), + "unexpected error: {msg}" + ); + assert_eq!( + deleted_names(&server).await, + vec![ + vec!["failing-sandbox".to_string()], + vec!["later-sandbox".to_string()] + ] + ); +} + #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; @@ -1274,6 +1553,73 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { assert!(!resources.fields.contains_key("requests")); } +#[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 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); +} + +#[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 requests = create_requests(&server).await; + assert!(!requests[0].await_main_process_attachment); +} + #[tokio::test] async fn sandbox_create_sends_driver_config_json() { let server = run_server().await; @@ -1338,6 +1684,201 @@ async fn sandbox_create_sends_driver_config_json() { ); } +#[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 requests = create_requests(&server).await; + let request = requests.first().expect("create request should be recorded"); + assert_eq!(request.workload_template_name, "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()); +} + +#[tokio::test] +async fn sandbox_template_create_sends_workload_template_resource() { + 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", + "default", + &tls, + ) + .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!(request.workspace, "default"); + 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.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) + ); +} + +#[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, + 5, + 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.limit, 25); + assert_eq!(list_request.offset, 5); + assert_eq!(list_request.label_selector, "team=runtime"); + assert_eq!(list_request.workspace, "default"); + assert!(!list_request.all_workspaces); + + 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!(delete_request.workspace, "default"); +} + +#[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, + ) + .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, ""); +} + #[tokio::test] async fn sandbox_create_sends_gpu_default_request() { let server = run_server().await; @@ -1409,9 +1950,8 @@ async fn sandbox_create_sends_gpu_count_request() { } #[tokio::test] -async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { +async fn sandbox_create_skips_inferred_provider_without_local_credentials() { let server = run_server().await; - enable_providers_v2(&server).await; let fake_ssh_dir = tempfile::tempdir().unwrap(); let xdg_dir = tempfile::tempdir().unwrap(); let _env = test_env(&fake_ssh_dir, &xdg_dir); @@ -1422,7 +1962,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { &server.endpoint, "openshell", run::SandboxCreateConfig { - name: Some("v2-no-inferred-provider"), + name: Some("no-inferred-provider"), command: &["claude".into(), "--version".into()], tty_override: Some(true), ..test_config() @@ -1442,7 +1982,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { .clone(); assert!( providers.is_empty(), - "providers v2 should not infer command providers, got {providers:?}" + "missing local credentials should skip inferred providers, got {providers:?}" ); } @@ -1489,6 +2029,44 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { assert!(!rendered.contains("timed out")); } +#[tokio::test] +async fn sandbox_create_preserves_vm_error_when_exit_code_is_observed() { + let server = run_server().await; + server + .openshell + .state + .vm_error_after_started + .store(true, Ordering::SeqCst); + server + .openshell + .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 rendered = err.to_string(); + assert!(rendered.contains("sandbox entered error phase while provisioning")); + assert!(rendered.contains("ProcessExited: VM process exited with status 0")); +} + #[tokio::test] async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { let server = run_server().await; @@ -1562,6 +2140,50 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { assert!(err.to_string().contains("sandbox provisioning timed out")); } +#[tokio::test] +async fn sandbox_create_retries_terminal_attachment_until_relay_registers() { + let server = run_server().await; + server + .openshell + .state + .terminal_before_relay + .store(true, Ordering::SeqCst); + server + .openshell + .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 + ); +} + #[tokio::test] async fn sandbox_create_deletes_command_sessions_with_no_keep() { let server = run_server().await; @@ -1590,6 +2212,14 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { 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, @@ -1597,6 +2227,37 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { ); } +#[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"); + + assert_eq!(exit_code, 7); + assert_eq!( + deleted_names(&server).await, + vec![vec!["ephemeral-failure".to_string()]] + ); +} + #[tokio::test] async fn sandbox_create_deletes_shell_sessions_with_no_keep() { let server = run_server().await; @@ -1738,6 +2399,7 @@ async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails( } #[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(); @@ -1768,6 +2430,7 @@ async fn sandbox_forward_foreground_fails_when_ssh_exits_before_listener_opens() } #[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(); @@ -1945,6 +2608,44 @@ async fn run_cli_sandbox_create( .unwrap() } +async fn run_cli_sandbox_template_create( + server: &TestServer, + name: &str, + extra_args: &[&str], +) -> std::process::Output { + let xdg_dir = tempfile::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"] { + fs::copy(server.dir.path().join(filename), tls_dir.join(filename)).unwrap(); + } + + let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); + for (key, _) in std::env::vars().filter(|(k, _)| k.starts_with("OPENSHELL_")) { + cmd.env_remove(&key); + } + cmd.args([ + "--gateway", + "openshell", + "--gateway-endpoint", + &server.endpoint, + "sandbox", + "template", + "create", + name, + "--image", + "registry.example.com/agent:latest", + "--output=json", + ]) + .args(extra_args) + .env("XDG_CONFIG_HOME", xdg_dir.path()) + .env("HOME", xdg_dir.path()) + .env("OPENSHELL_PROVISION_TIMEOUT", "5") + .output() + .await + .unwrap() +} + #[tokio::test] async fn sandbox_create_json_stdout_is_parseable() { let server = run_server().await; @@ -1974,3 +2675,63 @@ async fn sandbox_create_yaml_stdout_is_parseable() { serde_yml::from_str::(&stdout) .unwrap_or_else(|err| panic!("stdout should contain only YAML: {err}\n{stdout}")); } + +#[tokio::test] +async fn sandbox_template_create_warns_for_credential_env_vars() { + let server = run_server().await; + + let result = run_cli_sandbox_template_create( + &server, + "credential-env", + &["--env", "OPENAI_API_KEY=plain-secret"], + ) + .await; + + assert!( + result.status.success(), + "sandbox template create failed:\n{}", + String::from_utf8_lossy(&result.stderr) + ); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains("OPENAI_API_KEY looks like a credential"), + "template create should warn for credential-looking --env values: {stderr}" + ); + assert!( + stderr.contains("To hide it from the agent, use a provider instead"), + "warning should point users toward providers: {stderr}" + ); + + let requests = template_create_requests(&server).await; + assert_eq!(requests.len(), 1); +} + +#[tokio::test] +async fn sandbox_template_create_suppresses_credential_env_warnings() { + let server = run_server().await; + + let result = run_cli_sandbox_template_create( + &server, + "credential-env-suppressed", + &[ + "--env", + "OPENAI_API_KEY=plain-secret", + "--no-credential-warnings", + ], + ) + .await; + + assert!( + result.status.success(), + "sandbox template create failed:\n{}", + String::from_utf8_lossy(&result.stderr) + ); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + !stderr.contains("OPENAI_API_KEY looks like a credential"), + "template create should suppress credential-looking --env warnings: {stderr}" + ); + + let requests = template_create_requests(&server).await; + assert_eq!(requests.len(), 1); +} diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 019b2b12e4..5b62c7c15c 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -14,7 +14,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, @@ -48,6 +49,20 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -79,6 +94,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(SandboxResponse::default())) } + async fn stop_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn start_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_sandbox( &self, request: tonic::Request, @@ -109,6 +138,8 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } + unimplemented_sandbox_template_rpcs!(); + async fn list_sandbox_providers( &self, _request: tonic::Request, @@ -255,6 +286,13 @@ impl OpenShell for TestOpenShell { )) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-conformance-cli/Cargo.toml b/crates/openshell-conformance-cli/Cargo.toml new file mode 100644 index 0000000000..ce9f89c17c --- /dev/null +++ b/crates/openshell-conformance-cli/Cargo.toml @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 0000000000..c7f35bc13f --- /dev/null +++ b/crates/openshell-conformance-cli/src/main.rs @@ -0,0 +1,412 @@ +// 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::fmt::Write; +use std::future::Future; +use std::io::Read; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::ExitCode; +use std::sync::Arc; + +use clap::{Parser, Subcommand, ValueEnum}; +use openshell_conformance::{ + ConformancePlan, HostAction, HostActionExecutor, OpenShellRunner, PlanRun, Scenario, + default_scenarios, 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 action-free scenarios, named scenarios, or an explicit plan. + Run { + /// Action-free scenario names. Omit to run every action-free scenario. + scenarios: Vec, + /// Explicit path to the `OpenShell` CLI. Defaults to `openshell` on PATH. + #[arg(long)] + openshell_bin: Option, + /// Versioned TOML conformance plan. Use '-' to read the plan from stdin. + #[arg(long, conflicts_with = "scenarios")] + plan: 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, + plan, + output, + } => run(&requested, openshell_bin, plan, output).await, + } +} + +fn list(output: OutputFormat) -> Result<(), String> { + match output { + OutputFormat::Text => { + for candidate in scenarios() { + println!("{:<16} {}", 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, + plan_path: Option, + output: OutputFormat, +) -> Result<(), String> { + if let Some(plan_path) = plan_path { + let plan = read_plan(&plan_path)?; + return run_plan(&plan, binary, output).await; + } + + let selected = select_scenarios(requested)?; + let mut results = Vec::with_capacity(selected.len()); + for candidate in selected { + let plan_run = default_plan_run(candidate.name); + results.push(run_scenario(candidate, &plan_run, binary.as_ref(), None).await); + } + + render_results(results, output) +} + +async fn run_plan( + plan: &ConformancePlan, + binary: Option, + output: OutputFormat, +) -> Result<(), String> { + let executor: Arc = Arc::new(ProcessHostAction); + let mut results = Vec::with_capacity(plan.runs.len()); + for plan_run in &plan.runs { + let candidate = scenario(&plan_run.scenario) + .expect("validated conformance plan references a registered scenario"); + let result = + run_scenario(candidate, plan_run, binary.as_ref(), Some(executor.clone())).await; + let result = match (result.passed, &plan.diagnostics) { + (false, Some(diagnostics)) => append_diagnostics(result, &executor, diagnostics).await, + _ => result, + }; + let passed = result.passed; + results.push(result); + if !passed { + break; + } + } + + render_results(results, output) +} + +fn default_plan_run(scenario: &str) -> PlanRun { + PlanRun { + scenario: scenario.to_string(), + workload_expectation: None, + actions: Vec::new(), + } +} + +async fn run_scenario( + candidate: &'static Scenario, + plan_run: &PlanRun, + binary: Option<&PathBuf>, + host_action_executor: Option>, +) -> 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()), + }; + } + }; + if let Some(host_action_executor) = host_action_executor { + runner = runner.with_host_action_executor(host_action_executor); + } + eprintln!("CLI conformance run ID: {}", runner.id()); + let scenario_result = match runner.check_gateway_status().await { + Ok(()) => candidate.run(&mut runner, plan_run).await, + Err(error) => Err(error), + }; + let outcome = runner.finish(scenario_result).await; + ScenarioResult { + name: candidate.name, + passed: outcome.is_ok(), + diagnostic: outcome.err(), + } +} + +async fn append_diagnostics( + mut result: ScenarioResult<'static>, + executor: &Arc, + diagnostics: &openshell_conformance::PlanDiagnostics, +) -> ScenarioResult<'static> { + let action = diagnostics.as_action(); + if let Err(error) = executor.execute(&action).await { + let diagnostic = result.diagnostic.get_or_insert_default(); + let _ = write!(diagnostic, "\n\nsecondary diagnostics failure:\n{error}"); + } + result +} + +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 read_plan(path: &PathBuf) -> Result { + let contents = if path.as_os_str() == "-" { + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|error| format!("read conformance plan from stdin: {error}"))?; + input + } else { + std::fs::read_to_string(path) + .map_err(|error| format!("read conformance plan {}: {error}", path.display()))? + }; + ConformancePlan::parse(&contents).map_err(|error| format!("invalid conformance plan: {error}")) +} + +fn select_scenarios(requested: &[String]) -> Result, String> { + if requested.is_empty() { + return Ok(default_scenarios().collect()); + } + requested + .iter() + .map(|name| { + let candidate = scenario(name).ok_or_else(|| { + format!("unknown scenario '{name}'; run `openshell-conformance list`") + })?; + if candidate.requires_plan() { + return Err(format!( + "scenario '{name}' requires an explicit --plan; run `openshell-conformance list`" + )); + } + Ok(candidate) + }) + .collect() +} + +struct ProcessHostAction; + +impl HostActionExecutor for ProcessHostAction { + fn execute( + &self, + action: &HostAction, + ) -> Pin> + Send + '_>> { + let name = action.name.clone(); + let command = action.command.clone(); + let timeout = action.timeout(); + let timeout_secs = action.timeout_secs; + Box::pin(async move { + let mut process = tokio::process::Command::new(&command); + process.kill_on_drop(true); + let output = tokio::time::timeout(timeout, process.output()) + .await + .map_err(|_| { + format!( + "host action {:?} command '{}' timed out after {}s", + name, + command.display(), + timeout_secs, + ) + })? + .map_err(|error| { + format!( + "start host action {:?} command '{}': {error}", + name, + command.display(), + ) + })?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "host action {:?} command '{}' exited {:?}:\nstdout:\n{}\nstderr:\n{}", + name, + command.display(), + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )) + }) + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn selects_all_scenarios_by_default() { + assert_eq!( + select_scenarios(&[]).expect("select all").len(), + default_scenarios().count() + ); + } + + #[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 action_scenario_requires_an_explicit_plan() { + let error = select_scenarios(&["sandbox-continuity".to_string()]) + .expect_err("action scenario requires a plan"); + + assert!(error.contains("requires an explicit --plan")); + } + + #[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); + } + + #[test] + fn parses_plan_from_stdin() { + let cli = Cli::try_parse_from(["openshell-conformance", "run", "--plan", "-"]) + .expect("parse plan from stdin"); + let Command::Run { plan, .. } = cli.command else { + panic!("expected run") + }; + assert_eq!(plan, Some(PathBuf::from("-"))); + } +} diff --git a/crates/openshell-conformance/Cargo.toml b/crates/openshell-conformance/Cargo.toml new file mode 100644 index 0000000000..2c355a0052 --- /dev/null +++ b/crates/openshell-conformance/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance" +description = "Reusable OpenShell CLI conformance scenarios and runner" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/openshell-conformance/src/executor.rs b/crates/openshell-conformance/src/executor.rs new file mode 100644 index 0000000000..b45c102ee1 --- /dev/null +++ b/crates/openshell-conformance/src/executor.rs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Process execution boundary for the CLI conformance runner. + +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::{Output, Stdio}; +use std::time::Duration; + +use tokio::time::timeout; + +pub type CliExecution<'a> = + Pin> + Send + 'a>>; + +pub trait CliExecutor: Send + Sync { + fn execute(&self, args: Vec, command_timeout: Duration) -> CliExecution<'_>; +} + +pub enum CliExecutionError { + Spawn(std::io::Error), + Timeout, +} + +pub struct ProcessCli { + binary: PathBuf, +} + +impl ProcessCli { + pub fn new(binary: PathBuf) -> Self { + Self { binary } + } +} + +impl CliExecutor for ProcessCli { + fn execute(&self, args: Vec, command_timeout: Duration) -> CliExecution<'_> { + Box::pin(async move { + let mut process = tokio::process::Command::new(&self.binary); + process + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + timeout(command_timeout, process.output()) + .await + .map_err(|_| CliExecutionError::Timeout)? + .map_err(CliExecutionError::Spawn) + }) + } +} diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs new file mode 100644 index 0000000000..63cd1677e4 --- /dev/null +++ b/crates/openshell-conformance/src/lib.rs @@ -0,0 +1,1070 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Reusable support for portable `OpenShell` CLI conformance scenarios. + +pub mod executor; +pub mod plan; +mod scenarios; + +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::ExitStatus; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rand::Rng; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use tokio::time::sleep; + +use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; + +pub use plan::{ConformancePlan, HostAction, PlanDiagnostics, PlanRun, WorkloadExpectation}; +pub use scenarios::{SANDBOX_CONTINUITY_SCENARIO, SMOKE_SCENARIO}; + +/// An installed conformance scenario. +#[derive(Debug)] +pub struct Scenario { + pub name: &'static str, + pub description: &'static str, + requires_plan: bool, + run: for<'a> fn(&'a mut OpenShellRunner, &'a PlanRun) -> ScenarioFuture<'a>, + validate_plan_run: Option, +} + +pub type ScenarioFuture<'a> = Pin> + Send + 'a>>; +type PlanRunValidator = fn(&PlanRun) -> Result<(), String>; + +impl Scenario { + pub async fn run( + &self, + runner: &mut OpenShellRunner, + plan_run: &PlanRun, + ) -> Result<(), String> { + self.validate_plan_run(plan_run)?; + (self.run)(runner, plan_run).await + } + + pub fn validate_plan_run(&self, plan_run: &PlanRun) -> Result<(), String> { + self.validate_plan_run.map_or_else( + || default_validate_plan_run(plan_run), + |validate| validate(plan_run), + ) + } + + /// Whether this scenario may run only through an explicit target plan. + pub fn requires_plan(&self) -> bool { + self.requires_plan + } +} + +fn default_validate_plan_run(plan_run: &PlanRun) -> Result<(), String> { + if plan_run.workload_expectation.is_some() || !plan_run.actions.is_empty() { + return Err(format!( + "scenario {:?} does not accept workload_expectation or actions", + plan_run.scenario + )); + } + Ok(()) +} + +const SCENARIOS: &[Scenario] = &[SMOKE_SCENARIO, SANDBOX_CONTINUITY_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) +} + +/// Returns scenarios that need no host-level disruption capability. +pub fn default_scenarios() -> impl Iterator { + scenarios() + .iter() + .filter(|scenario| !scenario.requires_plan) +} + +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(120); +pub const STATUS_TIMEOUT: Duration = Duration::from_secs(30); +const GATEWAY_STATUS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); +const GATEWAY_STATUS_INTERVAL: Duration = Duration::from_secs(2); + +/// Generate the suite-owned identifier used in resource names and diagnostics. +fn generate_run_id() -> String { + const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::rng(); + (0..10) + .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char) + .collect() +} + +/// The completed outcome of one `OpenShell` CLI process. +#[derive(Debug)] +pub struct CommandResult { + run_id: String, + scenario: String, + step: String, + expectation: String, + command: String, + status: ExitStatus, + elapsed: Duration, + stdout: String, + stderr: String, +} + +impl CommandResult { + pub fn success(&self) -> bool { + self.status.success() + } + + pub fn exit_code(&self) -> Option { + self.status.code() + } + + pub fn stdout(&self) -> &str { + &self.stdout + } + + pub fn stderr(&self) -> &str { + &self.stderr + } + + pub fn elapsed(&self) -> Duration { + self.elapsed + } + + pub fn json(&self) -> Result { + serde_json::from_str(&self.stdout).map_err(|source| RunnerError::InvalidJson { + context: self.context(), + source, + stdout: self.stdout.clone(), + stderr: self.stderr.clone(), + }) + } + + pub fn require_success(&self) -> Result<(), String> { + if self.success() { + return Ok(()); + } + Err(self.failure_diagnostic(&self.expectation)) + } + + pub fn failure_diagnostic(&self, expectation: &str) -> String { + format!( + "{}\nexpected: {expectation}\nactual: exit {} after {:.1?}\ncommand: {}\nstdout:\n{}\nstderr:\n{}", + self.context(), + exit_description(self.status), + self.elapsed, + self.command, + self.stdout, + self.stderr, + ) + } + + fn context(&self) -> String { + format!("[run {}][{}/{}]", self.run_id, self.scenario, self.step) + } +} + +/// Failures in reusable runner mechanics rather than scenario assertions. +#[derive(Debug)] +pub enum RunnerError { + BinaryUnavailable(String), + Spawn { + context: String, + command: String, + source: std::io::Error, + }, + Timeout { + context: String, + command: String, + timeout: Duration, + }, + InvalidJson { + context: String, + source: serde_json::Error, + stdout: String, + stderr: String, + }, + PollTimeout { + context: String, + timeout: Duration, + last_observation: String, + }, + ObservationFailed { + context: String, + diagnostic: String, + }, +} + +impl fmt::Display for RunnerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BinaryUnavailable(message) => write!(f, "{message}"), + Self::Spawn { + context, + command, + source, + } => write!( + f, + "{context} failed to spawn command: {source}\ncommand: {command}" + ), + Self::Timeout { + context, + command, + timeout, + } => write!( + f, + "{context} timed out after {timeout:.1?}\ncommand: {command}" + ), + Self::InvalidJson { + context, + source, + stdout, + stderr, + } => write!( + f, + "{context} returned invalid JSON: {source}\nstdout:\n{stdout}\nstderr:\n{stderr}" + ), + Self::PollTimeout { + context, + timeout, + last_observation, + } => write!( + f, + "{context} did not satisfy the observation within {timeout:.1?}\nlast observation:\n{last_observation}" + ), + Self::ObservationFailed { + context, + diagnostic, + } => write!(f, "{context} observation failed early:\n{diagnostic}"), + } + } +} + +impl Error for RunnerError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Spawn { source, .. } => Some(source), + Self::InvalidJson { source, .. } => Some(source), + _ => None, + } + } +} + +/// Result of one read-only polling observation. +pub enum Poll { + Ready(T), + Pending(String), + Failed(String), +} + +#[derive(Debug, Deserialize)] +struct StatusOutput { + gateway: Option, + server: Option, + status: String, + version: Option, + authentication: Option, +} + +#[derive(Debug, Deserialize)] +struct AuthenticationOutput { + status: String, +} + +/// Runs `OpenShell` commands for one conformance scenario and owns its cleanup. +pub struct OpenShellRunner { + cli: Arc, + host_action_executor: Option>, + run_id: String, + scenario: String, + known_sandboxes: BTreeSet, + finished: bool, +} + +/// Executes one target-supplied host-side action from an explicit plan. +/// +/// This intentionally differs from [`CliExecutor`]: that executor models +/// `OpenShell` CLI invocations through the runner's configured binary and emits +/// structured command results, while an action is a plan-owned executable with +/// no caller-provided arguments. +pub trait HostActionExecutor: Send + Sync { + fn execute( + &self, + action: &HostAction, + ) -> Pin> + Send + '_>>; +} + +/// A runner command with diagnostic context but no timeout yet. +pub struct CommandStep<'a> { + runner: &'a OpenShellRunner, + step: String, + description: Option, +} + +/// A fully configured runner command ready to execute. +pub struct OpenShellCommand<'a> { + runner: &'a OpenShellRunner, + step: String, + description: String, + timeout: Duration, +} + +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() { + return Err(RunnerError::BinaryUnavailable(format!( + "OpenShell CLI binary not found at {}", + binary.display() + ))); + } + Ok(Self::with_executor( + Arc::new(ProcessCli::new(binary)), + scenario, + )) + } + + /// Creates a runner with an injected executor. This is useful for harness tests. + pub fn with_executor(cli: Arc, scenario: &str) -> Self { + Self { + cli, + host_action_executor: None, + run_id: generate_run_id(), + scenario: scenario.to_string(), + known_sandboxes: BTreeSet::new(), + finished: false, + } + } + + /// Attaches the target-side executor used only by explicit plan actions. + #[must_use] + pub fn with_host_action_executor( + mut self, + host_action_executor: Arc, + ) -> Self { + self.host_action_executor = Some(host_action_executor); + self + } + + /// Execute a target-supplied action declared by the active plan. + pub async fn execute_host_action(&self, action: &HostAction) -> Result<(), String> { + let Some(host_action_executor) = &self.host_action_executor else { + return Err(format!( + "{} requires a host action executor; run this scenario through an explicit plan", + self.context(&format!("action/{}", action.name)) + )); + }; + eprintln!( + "{} applying target host action", + self.context(&format!("action/{}", action.name)) + ); + host_action_executor.execute(action).await.map_err(|error| { + format!( + "{} failed: {error}", + self.context(&format!("action/{}", action.name)) + ) + }) + } + + pub fn id(&self) -> &str { + &self.run_id + } + + pub fn scenario(&self) -> &str { + &self.scenario + } + + pub fn step(&self, step: impl Into) -> CommandStep<'_> { + CommandStep { + runner: self, + step: step.into(), + description: None, + } + } + + /// Confirm that the configured gateway is reachable before a scenario runs. + pub async fn check_gateway_status(&mut self) -> Result<(), String> { + let status = self + .poll_until( + "preflight", + STATUS_TIMEOUT, + GATEWAY_STATUS_INTERVAL, + async |runner| match runner + .step("status/json") + .description("machine-readable gateway status succeeds") + .with_timeout(GATEWAY_STATUS_ATTEMPT_TIMEOUT) + .run(&["status", "--output", "json"]) + .await + { + Ok(result) if !result.success() => Poll::Pending( + result.failure_diagnostic("gateway status command succeeds"), + ), + Ok(result) => match result.json::() { + Ok(status) if status.status == "connected" => Poll::Ready(status), + Ok(status) if status.status == "not_configured" => Poll::Failed( + "no active gateway; register and select a gateway before running conformance" + .to_string(), + ), + Ok(status) => Poll::Pending(format!( + "gateway status is {:?}; expected \"connected\"\nstderr:\n{}", + status.status, + result.stderr() + )), + Err(error) => Poll::Failed(error.to_string()), + }, + Err(error) => Poll::Pending(error.to_string()), + }, + ) + .await + .map_err(|error| error.to_string())?; + + eprintln!( + "gateway preflight connected: gateway={}, server={}, version={}, authentication={}", + status.gateway.as_deref().unwrap_or("unknown"), + status.server.as_deref().unwrap_or("unknown"), + status.version.as_deref().unwrap_or("unknown"), + status + .authentication + .as_ref() + .map_or("unknown", |authentication| authentication.status.as_str()), + ); + Ok(()) + } + + /// Register a sandbox name for cleanup. + /// + /// Call this before running the command that may create the sandbox. The + /// scenario remains responsible for constructing and running that command. + pub fn track_sandbox(&mut self, name: &str) { + self.known_sandboxes.insert(name.to_string()); + } + + /// Stop tracking a sandbox after the scenario confirms it is absent. + pub fn forget_sandbox(&mut self, name: &str) { + self.known_sandboxes.remove(name); + } + + pub async fn poll_until( + &mut self, + step: &str, + poll_timeout: Duration, + interval: Duration, + mut observe: F, + ) -> Result + where + F: AsyncFnMut(&mut Self) -> Poll, + { + let started = Instant::now(); + let context = self.context(step); + + loop { + match observe(self).await { + Poll::Ready(value) => return Ok(value), + Poll::Pending(diagnostic) => { + if started.elapsed() >= poll_timeout { + return Err(RunnerError::PollTimeout { + context, + timeout: poll_timeout, + last_observation: diagnostic, + }); + } + } + Poll::Failed(diagnostic) => { + return Err(RunnerError::ObservationFailed { + context, + diagnostic, + }); + } + } + sleep(interval).await; + } + } + + pub async fn finish(mut self, scenario_result: Result<(), String>) -> Result<(), String> { + let cleanup_result = self.cleanup().await; + self.finished = true; + combine_results(scenario_result, cleanup_result) + } + + async fn run_strings( + &self, + step: &str, + expectation: &str, + args: Vec, + command_timeout: Duration, + ) -> Result { + let context = self.context(step); + let command = sanitized_command(&args); + eprintln!("{context} running: {command}"); + + let started = Instant::now(); + let output = + self.cli + .execute(args, command_timeout) + .await + .map_err(|error| match error { + CliExecutionError::Timeout => RunnerError::Timeout { + context: context.clone(), + command: command.clone(), + timeout: command_timeout, + }, + CliExecutionError::Spawn(source) => RunnerError::Spawn { + context: context.clone(), + command: command.clone(), + source, + }, + })?; + let elapsed = started.elapsed(); + eprintln!( + "{context} completed in {:.1?}: exit {}", + elapsed, + exit_description(output.status) + ); + + Ok(CommandResult { + run_id: self.run_id.clone(), + scenario: self.scenario.clone(), + step: step.to_string(), + expectation: expectation.to_string(), + command, + status: output.status, + elapsed, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + + async fn cleanup(&self) -> Result<(), String> { + if self.known_sandboxes.is_empty() { + return Ok(()); + } + + let cleanup_started = Instant::now(); + let mut failures = Vec::new(); + for name in self.known_sandboxes.clone() { + let remaining = remaining_cleanup_time(cleanup_started); + if remaining.is_zero() { + failures.push(format!( + "{} cleanup budget expired before deleting sandbox '{name}'", + self.context("cleanup/delete") + )); + break; + } + match self + .step("cleanup/delete") + .description(format!("sandbox '{name}' is deleted or already absent")) + .with_timeout(remaining) + .run(&["sandbox", "delete", &name]) + .await + { + Ok(result) if result.success() || output_reports_not_found(&result) => {} + Ok(result) => { + failures.push(result.failure_diagnostic(&format!( + "sandbox '{name}' is deleted or already absent" + ))); + } + Err(error) => failures.push(error.to_string()), + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("\n\n")) + } + } + + fn context(&self, step: &str) -> String { + format!("[run {}][{}/{}]", self.run_id, self.scenario, step) + } +} + +impl<'a> CommandStep<'a> { + #[must_use] + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_timeout(self, timeout: Duration) -> OpenShellCommand<'a> { + let description = self + .description + .unwrap_or_else(|| format!("step '{}' succeeds", self.step)); + OpenShellCommand { + runner: self.runner, + step: self.step, + description, + timeout, + } + } +} + +impl OpenShellCommand<'_> { + pub async fn run(&self, args: &[&str]) -> Result { + self.runner + .run_strings( + &self.step, + &self.description, + args.iter().map(|arg| (*arg).to_string()).collect(), + self.timeout, + ) + .await + } +} + +impl Drop for OpenShellRunner { + fn drop(&mut self) { + if self.finished { + return; + } + let resources = if self.known_sandboxes.is_empty() { + "none explicitly tracked".to_string() + } else { + self.known_sandboxes + .iter() + .cloned() + .collect::>() + .join(", ") + }; + eprintln!( + "[run {}][{}] WARNING: runner dropped without finish(); known sandboxes: {}", + self.run_id, self.scenario, resources + ); + } +} + +fn combine_results( + scenario_result: Result<(), String>, + cleanup_result: Result<(), String>, +) -> Result<(), String> { + match (scenario_result, cleanup_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(functional), Ok(())) => Err(functional), + (Ok(()), Err(cleanup)) => Err(format!("cleanup failed:\n{cleanup}")), + (Err(functional), Err(cleanup)) => Err(format!( + "{functional}\n\nsecondary cleanup failure:\n{cleanup}" + )), + } +} + +fn remaining_cleanup_time(started: Instant) -> Duration { + CLEANUP_TIMEOUT.saturating_sub(started.elapsed()) +} + +fn output_reports_not_found(result: &CommandResult) -> bool { + result.stderr().to_ascii_lowercase().contains("not found") + || result.stdout().to_ascii_lowercase().contains("not found") +} + +fn exit_description(status: ExitStatus) -> String { + status.code().map_or_else( + || "terminated by signal".to_string(), + |code| code.to_string(), + ) +} + +fn sanitized_command(args: &[String]) -> String { + let mut redact_next = false; + let rendered = args.iter().map(|arg| { + let rendered = if redact_next { + redact_next = false; + "".to_string() + } else if sensitive_flag(arg) { + redact_next = !arg.contains('='); + arg.split_once('=') + .map_or_else(|| arg.clone(), |(flag, _)| format!("{flag}=")) + } else { + arg.clone() + }; + shell_escape(&rendered) + }); + std::iter::once("openshell".to_string()) + .chain(rendered) + .collect::>() + .join(" ") +} + +fn sensitive_flag(arg: &str) -> bool { + let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag); + matches!( + flag, + "--credential" + | "--credentials" + | "--env" + | "--header" + | "--material" + | "--password" + | "--secret" + | "--token" + ) || flag.contains("client-secret") + || flag.contains("private-key") + || flag.ends_with("-token") +} + +fn shell_escape(arg: &str) -> String { + if arg + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_./:@=,".contains(character)) + { + return arg.to_string(); + } + format!("'{}'", arg.replace('\'', "'\\''")) +} + +#[cfg(all(test, unix))] +mod tests { + use std::collections::VecDeque; + use std::os::unix::process::ExitStatusExt; + use std::process::Output; + use std::sync::Mutex; + + use serde::Deserialize; + + use super::executor::CliExecution; + use super::*; + + struct MockCli { + state: Mutex, + } + + struct MockCliState { + responses: VecDeque, + invocations: Vec>, + } + + enum MockResponse { + Output { + exit_code: i32, + stdout: String, + stderr: String, + }, + Timeout, + } + + impl MockResponse { + fn output(exit_code: i32, stdout: &str, stderr: &str) -> Self { + Self::Output { + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + } + } + + fn success(stdout: &str) -> Self { + Self::output(0, stdout, "") + } + } + + impl MockCli { + fn new(responses: Vec) -> Self { + Self { + state: Mutex::new(MockCliState { + responses: responses.into(), + invocations: Vec::new(), + }), + } + } + + fn invocations(&self) -> Vec> { + self.state + .lock() + .expect("lock mock CLI state") + .invocations + .clone() + } + } + + impl CliExecutor for MockCli { + fn execute(&self, args: Vec, _command_timeout: Duration) -> CliExecution<'_> { + let response = { + let mut state = self.state.lock().expect("lock mock CLI state"); + state.invocations.push(args); + state + .responses + .pop_front() + .expect("mock CLI received an unexpected invocation") + }; + + Box::pin(async move { + match response { + MockResponse::Output { + exit_code, + stdout, + stderr, + } => Ok(Output { + status: ExitStatus::from_raw(exit_code << 8), + stdout: stdout.into_bytes(), + stderr: stderr.into_bytes(), + }), + MockResponse::Timeout => Err(CliExecutionError::Timeout), + } + }) + } + } + + fn test_runner(responses: Vec) -> (OpenShellRunner, Arc) { + let cli = Arc::new(MockCli::new(responses)); + let runner = OpenShellRunner::with_executor(cli.clone(), "smoke"); + (runner, cli) + } + + #[test] + fn with_binary_rejects_a_missing_file() { + let directory = tempfile::tempdir().expect("create missing CLI directory"); + let Err(error) = OpenShellRunner::with_binary(directory.path().join("missing"), "smoke") + else { + panic!("missing CLI should be rejected"); + }; + + assert!(matches!(error, RunnerError::BinaryUnavailable(_))); + } + + #[test] + fn exposes_generated_identity() { + let (mut runner, _cli) = test_runner(Vec::new()); + + assert_eq!(runner.id().len(), 10); + assert_eq!(runner.scenario(), "smoke"); + runner.finished = true; + } + + #[tokio::test] + async fn captures_streams_and_nonzero_exit_as_result() { + let (mut runner, _cli) = test_runner(vec![MockResponse::output(7, "stdout", "stderr")]); + + let result = runner + .step("capture") + .description("sandbox list succeeds") + .with_timeout(Duration::from_secs(1)) + .run(&["sandbox", "list"]) + .await + .expect("completed nonzero exit is a result"); + + assert_eq!(result.exit_code(), Some(7)); + assert_eq!(result.stdout(), "stdout"); + assert_eq!(result.stderr(), "stderr"); + assert!( + result + .require_success() + .expect_err("nonzero result should fail its expectation") + .contains("expected: sandbox list succeeds") + ); + runner.finished = true; + } + + #[tokio::test] + async fn reports_timeout_as_typed_error() { + let (mut runner, _cli) = test_runner(vec![MockResponse::Timeout]); + + let error = runner + .step("timeout") + .with_timeout(Duration::from_millis(10)) + .run(&["status"]) + .await + .expect_err("command should time out"); + + assert!(matches!(error, RunnerError::Timeout { .. })); + runner.finished = true; + } + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct JsonFixture { + status: String, + } + + #[tokio::test] + async fn deserializes_stdout_json_without_stderr() { + let (mut runner, _cli) = test_runner(vec![MockResponse::output( + 0, + "{\"status\":\"connected\"}", + "diagnostic", + )]); + + let result = runner + .step("json") + .with_timeout(Duration::from_secs(1)) + .run(&["status"]) + .await + .expect("run fake CLI"); + + assert_eq!( + result.json::().expect("parse JSON"), + JsonFixture { + status: "connected".to_string() + } + ); + runner.finished = true; + } + + #[tokio::test] + async fn accepts_connected_gateway_status() { + let (mut runner, _cli) = + test_runner(vec![MockResponse::success("{\"status\":\"connected\"}")]); + + runner + .check_gateway_status() + .await + .expect("connected gateway should satisfy preflight"); + + runner.finished = true; + } + + #[tokio::test] + async fn polling_returns_ready_value() { + let (mut runner, _cli) = test_runner(Vec::new()); + let mut attempts = 0; + + let value = runner + .poll_until( + "poll", + Duration::from_secs(1), + Duration::from_millis(1), + async |_runner| { + attempts += 1; + if attempts == 2 { + Poll::Ready("ready") + } else { + Poll::Pending("not ready".to_string()) + } + }, + ) + .await + .expect("poll should become ready"); + + assert_eq!(value, "ready"); + runner.finished = true; + } + + #[tokio::test] + async fn polling_timeout_preserves_last_observation() { + let (mut runner, _cli) = test_runner(Vec::new()); + + let error = runner + .poll_until( + "poll", + Duration::from_millis(1), + Duration::from_millis(1), + async |_runner| Poll::<()>::Pending("still pending".to_string()), + ) + .await + .expect_err("poll should time out"); + + assert!(matches!( + error, + RunnerError::PollTimeout { + last_observation, + .. + } if last_observation == "still pending" + )); + runner.finished = true; + } + + #[tokio::test] + async fn finish_skips_cleanup_commands_without_registered_resources() { + let (runner, cli) = test_runner(Vec::new()); + + let error = runner + .finish(Err("preflight failed".to_string())) + .await + .expect_err("functional failure should be preserved"); + + assert_eq!(error, "preflight failed"); + assert!(cli.invocations().is_empty()); + } + + #[test] + fn tracks_sandbox_for_cleanup() { + let (mut runner, _cli) = test_runner(Vec::new()); + + runner.track_sandbox("ct-0123456789-01"); + + assert!(runner.known_sandboxes.contains("ct-0123456789-01")); + runner.finished = true; + } + + #[test] + fn forgets_exact_name_cleanup() { + let (mut runner, _cli) = test_runner(Vec::new()); + + runner.track_sandbox("ct-0123456789-01"); + runner.forget_sandbox("ct-0123456789-01"); + + assert!(runner.known_sandboxes.is_empty()); + runner.finished = true; + } + + #[tokio::test] + async fn finish_deletes_explicitly_tracked_resources() { + let (mut runner, cli) = test_runner(vec![MockResponse::success("")]); + runner.track_sandbox("ct-0123456789-01"); + + runner + .finish(Ok(())) + .await + .expect("owned resource cleanup should succeed"); + + assert_eq!( + cli.invocations(), + vec![vec![ + "sandbox".to_string(), + "delete".to_string(), + "ct-0123456789-01".to_string(), + ]] + ); + } + + #[test] + fn functional_failure_remains_primary_when_cleanup_also_fails() { + let error = combine_results( + Err("functional failure".to_string()), + Err("cleanup failure".to_string()), + ) + .expect_err("combined result should fail"); + + assert!(error.starts_with("functional failure")); + assert!(error.contains("secondary cleanup failure:\ncleanup failure")); + } + + #[test] + fn generated_run_id_is_portable_and_compact() { + let run_id = generate_run_id(); + assert_eq!(run_id.len(), 10); + assert!( + run_id + .chars() + .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) + ); + } + + #[test] + fn sanitizes_sensitive_cli_arguments() { + let command = sanitized_command(&[ + "provider".to_string(), + "create".to_string(), + "--credential".to_string(), + "TOKEN=secret".to_string(), + "--client-secret=hunter2".to_string(), + ]); + + assert!(!command.contains("TOKEN=secret")); + assert!(!command.contains("hunter2")); + assert!(command.contains("")); + } +} diff --git a/crates/openshell-conformance/src/plan.rs b/crates/openshell-conformance/src/plan.rs new file mode 100644 index 0000000000..ad955ea5f6 --- /dev/null +++ b/crates/openshell-conformance/src/plan.rs @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Versioned, target-supplied execution plans for conformance scenarios. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Deserialize; + +use crate::scenario; + +pub const PLAN_VERSION: u32 = 1; + +#[derive(Debug, Deserialize)] +pub struct ConformancePlan { + pub version: u32, + #[serde(default)] + pub runs: Vec, + pub diagnostics: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PlanRun { + pub scenario: String, + pub workload_expectation: Option, + #[serde(default)] + pub actions: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum WorkloadExpectation { + Reconciled, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct HostAction { + pub name: String, + pub command: PathBuf, + pub timeout_secs: u64, +} + +impl HostAction { + pub fn timeout(&self) -> Duration { + Duration::from_secs(self.timeout_secs) + } +} + +#[derive(Debug, Deserialize)] +pub struct PlanDiagnostics { + pub command: PathBuf, + pub timeout_secs: u64, +} + +impl PlanDiagnostics { + pub fn as_action(&self) -> HostAction { + HostAction { + name: "diagnostics".to_string(), + command: self.command.clone(), + timeout_secs: self.timeout_secs, + } + } +} + +impl ConformancePlan { + pub fn parse(input: &str) -> Result { + let plan = toml::from_str::(input).map_err(|error| error.to_string())?; + plan.validate()?; + Ok(plan) + } + + fn validate(&self) -> Result<(), String> { + if self.version != PLAN_VERSION { + return Err(format!( + "unsupported conformance plan version {}; expected {PLAN_VERSION}", + self.version + )); + } + if self.runs.is_empty() { + return Err("conformance plan must contain at least one run".to_string()); + } + if let Some(diagnostics) = &self.diagnostics { + validate_command( + "diagnostics", + &diagnostics.command, + diagnostics.timeout_secs, + )?; + } + for run in &self.runs { + let Some(scenario) = scenario(&run.scenario) else { + return Err(format!( + "unknown scenario {:?}; run `openshell-conformance list`", + run.scenario + )); + }; + scenario.validate_plan_run(run)?; + for action in &run.actions { + validate_command( + &format!("action {:?}", action.name), + &action.command, + action.timeout_secs, + )?; + } + } + Ok(()) + } +} + +fn validate_command(label: &str, command: &Path, timeout_secs: u64) -> Result<(), String> { + if !command.is_absolute() { + return Err(format!( + "{label} command must be an absolute path: {}", + command.display() + )); + } + if timeout_secs == 0 { + return Err(format!("{label} timeout_secs must be greater than zero")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_smoke_and_continuity_plan() { + let plan = ConformancePlan::parse( + r#" + version = 1 + + [[runs]] + scenario = "smoke" + + [[runs]] + scenario = "sandbox-continuity" + workload_expectation = "reconciled" + + [[runs.actions]] + name = "gateway-upgrade" + command = "/usr/local/libexec/restart-gateway" + timeout_secs = 120 + "#, + ) + .expect("valid plan"); + + assert_eq!(plan.runs.len(), 2); + assert_eq!(plan.runs[1].actions[0].name, "gateway-upgrade"); + } + + #[test] + fn parses_plan_diagnostics() { + let plan = ConformancePlan::parse( + r#" + version = 1 + + [diagnostics] + command = "/usr/local/libexec/diagnostics" + timeout_secs = 60 + + [[runs]] + scenario = "smoke" + "#, + ) + .expect("valid plan with diagnostics"); + + assert_eq!( + plan.diagnostics + .expect("diagnostics configured") + .as_action() + .name, + "diagnostics" + ); + } + + #[test] + fn rejects_a_relative_action_command() { + let error = ConformancePlan::parse( + r#" + version = 1 + + [[runs]] + scenario = "sandbox-continuity" + workload_expectation = "reconciled" + + [[runs.actions]] + name = "gateway-restart" + command = "restart-gateway" + timeout_secs = 120 + "#, + ) + .expect_err("relative command must fail"); + + assert!(error.contains("absolute path")); + } + + #[test] + fn rejects_an_actionless_continuity_run() { + let error = ConformancePlan::parse( + r#" + version = 1 + + [[runs]] + scenario = "sandbox-continuity" + workload_expectation = "reconciled" + "#, + ) + .expect_err("continuity requires an action"); + + assert!(error.contains("requires at least one action")); + } + + #[test] + fn default_validation_rejects_actions_for_smoke() { + let error = ConformancePlan::parse( + r#" + version = 1 + + [[runs]] + scenario = "smoke" + + [[runs.actions]] + name = "gateway-restart" + command = "/usr/local/libexec/restart-gateway" + timeout_secs = 120 + "#, + ) + .expect_err("smoke does not accept actions"); + + assert!(error.contains("does not accept workload_expectation or actions")); + } +} diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs new file mode 100644 index 0000000000..c5211b3690 --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Registered, portable conformance scenarios. + +mod sandbox_continuity; +mod smoke; + +pub use sandbox_continuity::SANDBOX_CONTINUITY_SCENARIO; +pub use smoke::SMOKE_SCENARIO; diff --git a/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs b/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs new file mode 100644 index 0000000000..7fe1f0d06b --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Product-visible sandbox continuity across host-side actions. + +use std::time::Duration; + +use serde::Deserialize; + +use crate::{ + HostAction, OpenShellRunner, PlanRun, Poll, Scenario, ScenarioFuture, WorkloadExpectation, +}; + +const CREATE_TIMEOUT: Duration = Duration::from_secs(600); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); +const RECOVERY_TIMEOUT: Duration = Duration::from_secs(240); +const RECOVERY_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Debug, Deserialize)] +struct SandboxState { + name: String, + phase: String, +} + +/// Certify sandbox state and workspace continuity across host-side actions. +pub const SANDBOX_CONTINUITY_SCENARIO: Scenario = Scenario { + name: "sandbox-continuity", + description: "Verify sandbox state and workspace continuity across planned host actions.", + requires_plan: true, + run: run_sandbox_continuity, + validate_plan_run: Some(validate_plan_run), +}; + +fn run_sandbox_continuity<'a>( + runner: &'a mut OpenShellRunner, + plan_run: &'a PlanRun, +) -> ScenarioFuture<'a> { + Box::pin(async move { run_sandbox_continuity_inner(runner, plan_run).await }) +} + +fn validate_plan_run(plan_run: &PlanRun) -> Result<(), String> { + if plan_run.workload_expectation != Some(WorkloadExpectation::Reconciled) { + return Err( + "scenario 'sandbox-continuity' requires workload_expectation = \"reconciled\"" + .to_string(), + ); + } + if plan_run.actions.is_empty() { + return Err("scenario 'sandbox-continuity' requires at least one action".to_string()); + } + Ok(()) +} + +async fn run_sandbox_continuity_inner( + runner: &mut OpenShellRunner, + plan_run: &PlanRun, +) -> Result<(), String> { + // The VM driver permits at most 19 characters in a sandbox name. + let running_name = format!("ct-{}-r", runner.id()); + let stopped_name = format!("ct-{}-s", runner.id()); + let marker = format!("openshell-sandbox-continuity-{}", runner.id()); + let marker_path = "/sandbox/.openshell-sandbox-continuity"; + let running_script = + format!("printf '%s\\n' '{marker}' > {marker_path}; while true; do sleep 1; done"); + + create_retained_sandbox(runner, &running_name, &running_script, "running").await?; + assert_marker(runner, &running_name, marker_path, &marker, "pre-action").await?; + + create_retained_sandbox( + runner, + &stopped_name, + "while true; do sleep 1; done", + "stopped", + ) + .await?; + let stop = runner + .step("stop") + .description(format!("sandbox '{stopped_name}' stops")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "stop", &stopped_name]) + .await + .map_err(|error| error.to_string())?; + stop.require_success()?; + wait_for_phase(runner, &stopped_name, "Stopped", "stopped-before-actions").await?; + + for action in &plan_run.actions { + apply_action_and_assert( + runner, + action, + &running_name, + &stopped_name, + marker_path, + &marker, + ) + .await?; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn apply_action_and_assert( + runner: &mut OpenShellRunner, + action: &HostAction, + running_name: &str, + stopped_name: &str, + marker_path: &str, + marker: &str, +) -> Result<(), String> { + let step = format!("after-{}", action.name); + runner.execute_host_action(action).await?; + runner.check_gateway_status().await?; + wait_for_phase(runner, running_name, "Ready", &format!("running-{step}")).await?; + wait_for_phase(runner, stopped_name, "Stopped", &format!("stopped-{step}")).await?; + assert_marker(runner, running_name, marker_path, marker, &step).await +} + +async fn create_retained_sandbox( + runner: &mut OpenShellRunner, + name: &str, + script: &str, + step: &str, +) -> Result<(), String> { + runner.track_sandbox(name); + let create = runner + .step(format!("create-{step}")) + .description(format!("retained sandbox '{name}' is created")) + .with_timeout(CREATE_TIMEOUT) + .run(&[ + "sandbox", "create", "--name", name, "--from", "base", "--detach", "--no-tty", "--", + "sh", "-lc", script, + ]) + .await + .map_err(|error| error.to_string())?; + create.require_success() +} + +async fn assert_marker( + runner: &OpenShellRunner, + name: &str, + path: &str, + marker: &str, + step: &str, +) -> Result<(), String> { + let result = runner + .step(format!("marker-{step}")) + .description(format!("sandbox '{name}' retains its workspace marker")) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", "exec", "--name", name, "--no-tty", "--", "cat", path, + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + if result.stdout().lines().any(|line| line.trim() == marker) { + Ok(()) + } else { + Err(result.failure_diagnostic(&format!( + "sandbox '{name}' stdout contains marker {marker:?}" + ))) + } +} + +async fn wait_for_phase( + runner: &mut OpenShellRunner, + name: &str, + expected_phase: &str, + step: &str, +) -> Result<(), String> { + let name = name.to_string(); + let expected_phase = expected_phase.to_string(); + let step = step.to_string(); + let poll_step = step.clone(); + runner + .poll_until( + &poll_step, + RECOVERY_TIMEOUT, + RECOVERY_INTERVAL, + async move |runner| { + let result = runner + .step(format!("{step}/get")) + .description(format!("sandbox '{name}' reaches phase {expected_phase}")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "get", &name, "--output", "json"]) + .await; + match result { + Ok(result) if !result.success() => Poll::Pending( + result.failure_diagnostic(&format!("sandbox '{name}' can be retrieved")), + ), + Ok(result) => match result.json::() { + Ok(state) if state.name != name => Poll::Failed(format!( + "sandbox get returned {:?}; expected '{name}'", + state.name + )), + Ok(state) if state.phase == expected_phase => Poll::Ready(()), + Ok(state) => Poll::Pending(format!( + "sandbox '{name}' phase is {:?}; expected {expected_phase:?}", + state.phase + )), + Err(error) => Poll::Failed(error.to_string()), + }, + Err(error) => Poll::Pending(error.to_string()), + } + }, + ) + .await + .map_err(|error| error.to_string()) +} diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs new file mode 100644 index 0000000000..cb5ca40122 --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Portable phase-1 CLI conformance scenario. + +use std::time::{Duration, Instant}; + +use crate::{OpenShellRunner, PlanRun, STATUS_TIMEOUT, Scenario, ScenarioFuture}; +use serde::Deserialize; +use tokio::time::sleep; + +const CREATE_TIMEOUT: Duration = Duration::from_secs(600); +const LIST_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); +const LIST_PAGE_SIZE: u32 = 1_000; +const EXEC_TIMEOUT: Duration = Duration::from_secs(120); +const DELETE_TIMEOUT: Duration = Duration::from_secs(120); +const DELETE_POLL_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Debug, Deserialize)] +struct SandboxListEntry { + name: String, + phase: String, +} + +/// 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.", + requires_plan: false, + run: run_smoke, + validate_plan_run: None, +}; + +fn run_smoke<'a>(runner: &'a mut OpenShellRunner, _plan_run: &'a PlanRun) -> ScenarioFuture<'a> { + Box::pin(async move { run_smoke_inner(runner).await }) +} + +async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { + let status = runner + .step("status") + .description("openshell status succeeds") + .with_timeout(STATUS_TIMEOUT) + .run(&["status"]) + .await + .map_err(|error| error.to_string())?; + status.require_success()?; + + let sandbox_name = format!("ct-{}-01", runner.id()); + runner.track_sandbox(&sandbox_name); + let create = runner + .step("create") + .description("sandbox creation succeeds") + .with_timeout(CREATE_TIMEOUT) + .run(&[ + "sandbox", + "create", + "--name", + &sandbox_name, + "--from", + "base", + "--detach", + ]) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + + let get = runner + .step("get-ready") + .description(format!("sandbox '{sandbox_name}' can be retrieved")) + .with_timeout(LIST_ATTEMPT_TIMEOUT) + .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + get.require_success()?; + + let sandbox = get + .json::() + .map_err(|error| error.to_string())?; + if sandbox.name != sandbox_name { + return Err(format!( + "sandbox get returned {:?}; expected sandbox '{sandbox_name}'", + sandbox.name + )); + } + if sandbox.phase != "Ready" { + return Err(format!( + "sandbox '{sandbox_name}' is in phase {:?}; expected Ready", + sandbox.phase + )); + } + + check_sandbox_listed(runner, &sandbox_name).await?; + + let marker = format!("openshell-conformance-{}", runner.id()); + let exec = runner + .step("exec") + .description("sandbox exec exits successfully") + .with_timeout(EXEC_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &sandbox_name, + "--no-tty", + "--", + "echo", + &marker, + ]) + .await + .map_err(|error| error.to_string())?; + exec.require_success()?; + let expected_stdout = format!("{marker}\n"); + if exec.stdout() != expected_stdout { + return Err(exec.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))); + } + + let delete = runner + .step("delete") + .description("sandbox deletion succeeds") + .with_timeout(DELETE_TIMEOUT) + .run(&["sandbox", "delete", &sandbox_name]) + .await + .map_err(|error| error.to_string())?; + delete.require_success()?; + + check_empty_list(runner, &sandbox_name).await?; + runner.forget_sandbox(&sandbox_name); + Ok(()) +} + +async fn check_sandbox_listed(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + if find_sandbox(runner, sandbox_name, "list-visible") + .await? + .is_some() + { + return Ok(()); + } + Err(format!( + "sandbox '{sandbox_name}' does not appear in sandbox list" + )) +} + +async fn check_empty_list(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + let started = Instant::now(); + + loop { + match find_sandbox(runner, sandbox_name, "list-empty/query").await? { + None => return Ok(()), + Some(sandbox) if started.elapsed() >= DELETE_TIMEOUT => { + return Err(format!( + "sandbox '{sandbox_name}' remains listed in phase {:?} after {DELETE_TIMEOUT:.1?}", + sandbox.phase + )); + } + Some(_) => sleep(DELETE_POLL_INTERVAL).await, + } + } +} + +async fn find_sandbox( + runner: &OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result, String> { + let mut offset = 0u32; + + loop { + let limit = LIST_PAGE_SIZE.to_string(); + let page_offset = offset.to_string(); + let result = runner + .step(format!("{step}/{offset}")) + .description(format!("sandbox list page at offset {offset} succeeds")) + .with_timeout(LIST_ATTEMPT_TIMEOUT) + .run(&[ + "sandbox", + "list", + "--limit", + &limit, + "--offset", + &page_offset, + "--output", + "json", + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + + let sandboxes = result + .json::>() + .map_err(|error| error.to_string())?; + if let Some(sandbox) = sandboxes + .iter() + .find(|sandbox| sandbox.name == sandbox_name) + { + return Ok(Some(SandboxListEntry { + name: sandbox.name.clone(), + phase: sandbox.phase.clone(), + })); + } + if sandboxes.len() < LIST_PAGE_SIZE as usize { + return Ok(None); + } + + offset = offset + .checked_add(LIST_PAGE_SIZE) + .ok_or_else(|| "sandbox list pagination offset overflowed".to_string())?; + } +} diff --git a/crates/openshell-core/BUILD.bazel b/crates/openshell-core/BUILD.bazel deleted file mode 100644 index f3931d7105..0000000000 --- a/crates/openshell-core/BUILD.bazel +++ /dev/null @@ -1,45 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") -load("@workspace_version//:version.bzl", "WORKSPACE_VERSION") - -rust_library( - name = "openshell-core", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - compile_data = [ - "//proto:openshell_proto_descriptor_set", - "//proto:openshell_rust_proto_src", - ], - crate_features = ["telemetry"], - rustc_env = { - "OPENSHELL_DESCRIPTOR_PATH": "$(execpath //proto:openshell_proto_descriptor_set)", - "OPENSHELL_PROTO_PATH": "$(execpath //proto:openshell_rust_proto_src)", - }, - rustc_flags = ["--cfg=bazel"], - version = WORKSPACE_VERSION, - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True) + [ - "//proto:descriptor_rust_proto", - "//proto:empty_rust_proto", - "//proto:struct_rust_proto", - ], -) - -rust_test( - name = "openshell-core_test", - crate = ":openshell-core", - crate_features = ["telemetry"], - rustc_flags = ["--cfg=bazel"], - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-core", - ":openshell-core_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 35a3732cf9..c96d536f07 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -11,12 +11,15 @@ license.workspace = true repository.workspace = true [dependencies] +async-trait = "0.1" +openshell-extension-core = { path = "../openshell-extension-core" } glob = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } -tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tonic = { workspace = true, features = ["channel", "tls-ring"] } tonic-prost = { workspace = true } tokio = { workspace = true } +tokio-stream = { workspace = true } thiserror = { workspace = true } miette = { workspace = true } serde = { workspace = true } @@ -24,26 +27,35 @@ serde_json = { workspace = true } tracing = { workspace = true } url = { workspace = true } ipnet = "2" +rustls = { workspace = true } +rustls-pemfile = { workspace = true } base64 = { workspace = true } chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true } -reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots"], optional = true } +reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"], optional = true } +tar = { version = "0.4", optional = true } +tempfile = { version = "3", optional = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } +rustix = { workspace = true } [features] default = ["telemetry"] ## Compile in anonymous telemetry emission support. On by default; disable with ## `--no-default-features` (plus any other features you need) for a build that ## contains no telemetry endpoint, no HTTP client, and no emission code at all. -telemetry = ["dep:reqwest", "dep:chrono"] +driver-extraction = ["dep:tar", "dep:tempfile"] +telemetry = ["dep:reqwest", "dep:chrono", "reqwest?/blocking"] +## OAuth2 token request helpers used by supervisor and gateway. +oauth = ["dep:reqwest"] [build-dependencies] tonic-prost-build = { workspace = true } -protobuf-src = { workspace = true } +protoc-bin-vendored = { workspace = true } [dev-dependencies] tempfile = "3" +rcgen = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 187231858f..38c961b1d4 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -4,16 +4,20 @@ use std::env; use std::path::{Path, PathBuf}; +mod build_version; + const PROTO_REL: &str = "../../proto"; fn main() -> Result<(), Box> { // --- Git-derived version --- - // Compute a version from `git describe` for local builds. In Docker/CI - // builds where .git is absent, this silently does nothing and the binary - // falls back to CARGO_PKG_VERSION (which is already sed-patched by the - // build pipeline). + // Compute a version from tags and commit metadata for local builds. In + // Docker/CI builds where .git is absent, this silently does nothing and + // the binary falls back to CARGO_PKG_VERSION (which is already sed-patched + // by the build pipeline). println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/logs/HEAD"); println!("cargo:rerun-if-changed=../../.git/refs/tags"); + println!("cargo:rerun-if-changed=../../.git/packed-refs"); if let Some(version) = git_version() { println!("cargo:rustc-env=OPENSHELL_GIT_VERSION={version}"); @@ -22,20 +26,19 @@ 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 bundled protoc from protobuf-src. The system protoc (from apt-get) - // does not bundle the well-known type includes (google/protobuf/struct.proto - // etc.), so we must use protobuf-src which ships both the binary and the - // include tree. + // 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", protobuf_src::protoc()); + env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); + env::set_var("PROTOC_INCLUDE", protoc_bin_vendored::include_path()?); } let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); let proto_root = manifest_dir.join(PROTO_REL); - let mut proto_files = Vec::new(); collect_proto_files(&proto_root, &mut proto_files)?; proto_files.sort(); @@ -73,53 +76,43 @@ fn collect_proto_files(dir: &Path, out: &mut Vec) -> std::io::Result<() Ok(()) } -/// Derive a version string from `git describe --tags`. +/// Derive the release or development version from git metadata. /// /// Implements the "guess-next-dev" convention used by the release pipeline -/// (`setuptools-scm`): when there are commits past the last tag, the patch -/// version is bumped and `-dev.+g` is appended. +/// (`tasks/scripts/release.py`): exact stable and prerelease tags retain their +/// version. Otherwise, the latest merged stable release gets a patch bump and +/// `-dev.+g` is appended. /// /// Examples: -/// on tag v0.0.3 → "0.0.3" -/// 3 commits past v0.0.3 → "0.0.4-dev.3+g2bf9969" +/// on tag v0.1.0-pre.1 → "0.1.0-pre.1" +/// 3 commits past v0.0.3 → "0.0.4-dev.3+g2bf9969ab" /// -/// Returns `None` when git is unavailable or the repo has no matching tags. +/// Returns `None` when git metadata cannot be read. fn git_version() -> Option { - // Match numeric release tags only (e.g. `v0.0.29`). The bare glob `v*` - // also matches non-release tags like `vm-dev` or `vm-prod`; when one of - // those lands on the same commit as a release tag, `git describe` picks - // it and the resulting version string collapses to `m-dev` after the - // leading `v` is stripped below. Requiring a digit after `v` excludes - // those development tags without losing any release tag. - let output = std::process::Command::new("git") - .args(["describe", "--tags", "--long", "--match", "v[0-9]*"]) - .output() - .ok()?; - - if !output.status.success() { - return None; + let exact_tags = git_output(&["tag", "--points-at", "HEAD"])?; + if let Some(version) = build_version::exact_release_version(exact_tags.lines()) { + return Some(version); } - let desc = String::from_utf8(output.stdout).ok()?; - let desc = desc.trim(); - let desc = desc.strip_prefix('v').unwrap_or(desc); + let merged_tags = git_output(&["tag", "--merged", "HEAD", "--list", "v*.*.*"])?; + let latest_tag = build_version::latest_stable_tag(merged_tags.lines()); + let revision_range = latest_tag + .as_deref() + .map_or_else(|| "HEAD".to_string(), |tag| format!("{tag}..HEAD")); + let distance = git_output(&["rev-list", "--count", &revision_range])? + .parse() + .ok()?; + let sha = git_output(&["rev-parse", "--short=9", "HEAD"])?; - // `git describe --long` format: --g - // Split from the right to handle tags that contain hyphens. - let (rest, sha) = desc.rsplit_once('-')?; - let (tag, commits_str) = rest.rsplit_once('-')?; - let commits: u32 = commits_str.parse().ok()?; + build_version::next_dev_version(latest_tag.as_deref(), distance, &sha) +} - if commits == 0 { - // Exactly on a tag — use the tag version as-is. - return Some(tag.to_string()); +fn git_output(args: &[&str]) -> Option { + let output = std::process::Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; } - - // Bump patch version (guess-next-dev scheme). - let mut parts = tag.splitn(3, '.'); - let major = parts.next()?; - let minor = parts.next()?; - let patch: u32 = parts.next()?.parse().ok()?; - - Some(format!("{major}.{minor}.{}-dev.{commits}+{sha}", patch + 1)) + String::from_utf8(output.stdout) + .ok() + .map(|output| output.trim().to_string()) } diff --git a/crates/openshell-core/build_version.rs b/crates/openshell-core/build_version.rs new file mode 100644 index 0000000000..944808be93 --- /dev/null +++ b/crates/openshell-core/build_version.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type StableVersion = (u32, u32, u32); +type PrereleaseVersion = (u32, u32, u32, u32); + +fn parse_stable_tag(tag: &str) -> Option { + let tag = tag.strip_prefix('v').unwrap_or(tag); + let mut parts = tag.split('.'); + let version = ( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + ); + parts.next().is_none().then_some(version) +} + +fn parse_prerelease_tag(tag: &str) -> Option { + let tag = tag.strip_prefix('v').unwrap_or(tag); + let (base, sequence) = tag.rsplit_once("-pre.")?; + let (major, minor, patch) = parse_stable_tag(base)?; + let sequence = sequence.parse().ok()?; + (sequence > 0).then_some((major, minor, patch, sequence)) +} + +pub fn exact_release_version<'a>(tags: impl Iterator) -> Option { + let tags = tags.collect::>(); + + if let Some(((major, minor, patch), _)) = tags + .iter() + .filter_map(|tag| parse_stable_tag(tag).map(|version| (version, tag))) + .max_by_key(|(version, _)| *version) + { + return Some(format!("{major}.{minor}.{patch}")); + } + + tags.iter() + .filter_map(|tag| parse_prerelease_tag(tag)) + .max() + .map(|(major, minor, patch, sequence)| format!("{major}.{minor}.{patch}-pre.{sequence}")) +} + +pub fn latest_stable_tag<'a>(tags: impl Iterator) -> Option { + tags.filter_map(|tag| parse_stable_tag(tag).map(|version| (version, tag))) + .max_by_key(|(version, _)| *version) + .map(|(_, tag)| tag.to_string()) +} + +pub fn next_dev_version(tag: Option<&str>, distance: u32, sha: &str) -> Option { + let (major, minor, patch) = tag.map_or(Some((0, 0, 0)), parse_stable_tag)?; + Some(format!( + "{major}.{minor}.{}-dev.{distance}+g{sha}", + patch + 1 + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_stable_release_wins_over_prerelease() { + let tags = ["v0.1.0-pre.2", "v0.1.0", "vm-dev"]; + assert_eq!( + exact_release_version(tags.into_iter()).as_deref(), + Some("0.1.0") + ); + } + + #[test] + fn exact_prerelease_uses_highest_sequence() { + let tags = ["v0.1.0-pre.1", "v0.1.0-pre.2"]; + assert_eq!( + exact_release_version(tags.into_iter()).as_deref(), + Some("0.1.0-pre.2") + ); + } + + #[test] + fn latest_stable_ignores_prerelease_and_non_release_tags() { + let tags = ["v0.0.116", "v0.1.0-pre.1", "vm-dev", "v0.0.99"]; + assert_eq!( + latest_stable_tag(tags.into_iter()).as_deref(), + Some("v0.0.116") + ); + } + + #[test] + fn next_dev_version_bumps_latest_stable_patch() { + assert_eq!( + next_dev_version(Some("v0.0.116"), 32, "5b925dd8a").as_deref(), + Some("0.0.117-dev.32+g5b925dd8a") + ); + } + + #[test] + fn next_dev_version_without_a_release_starts_at_first_patch() { + assert_eq!( + next_dev_version(None, 7, "abcdef123").as_deref(), + Some("0.0.1-dev.7+gabcdef123") + ); + } +} diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index daa867f16f..3507011120 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -4,14 +4,10 @@ //! Configuration management for `OpenShell` components. use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::collections::BTreeMap; -use std::fmt; -#[cfg(unix)] -use std::io::{Read, Write}; use std::net::SocketAddr; -#[cfg(unix)] -use std::os::unix::fs::FileTypeExt; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; @@ -27,12 +23,12 @@ pub const DEFAULT_SSH_PORT: u16 = 2222; /// Default gateway server port. pub const DEFAULT_SERVER_PORT: u16 = 17670; +/// Default operator-facing name for a gateway installation. +pub const DEFAULT_GATEWAY_NAME: &str = "openshell"; + /// Default container stop timeout in seconds (SIGTERM → SIGKILL). pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; -/// Default Docker bridge network name for local sandboxes. -pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; - /// Default domain used for browser-facing sandbox service URLs. pub const DEFAULT_SERVICE_ROUTING_DOMAIN: &str = "openshell.localhost"; @@ -113,31 +109,9 @@ pub const CDI_GPU_DEVICE_ALL: &str = "nvidia.com/gpu=all"; /// Default maximum number of processes (PIDs) allowed inside a sandbox container. /// -/// Shared by the Docker and Podman drivers; override via driver config. +/// Compute drivers may override this through backend configuration. pub const DEFAULT_SANDBOX_PIDS_LIMIT: i64 = 2048; -/// Compute backends the gateway can orchestrate sandboxes through. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComputeDriverKind { - Kubernetes, - Vm, - Docker, - Podman, -} - -impl ComputeDriverKind { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Kubernetes => "kubernetes", - Self::Vm => "vm", - Self::Docker => "docker", - Self::Podman => "podman", - } - } -} - /// Normalize a configured compute driver name. /// /// Built-in driver names and custom remote driver names share the same @@ -159,255 +133,6 @@ pub fn normalize_compute_driver_name(value: &str) -> Result { Ok(value.to_ascii_lowercase()) } -impl fmt::Display for ComputeDriverKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl FromStr for ComputeDriverKind { - type Err = String; - - fn from_str(value: &str) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "kubernetes" => Ok(Self::Kubernetes), - "vm" => Ok(Self::Vm), - "docker" => Ok(Self::Docker), - "podman" => Ok(Self::Podman), - other => Err(format!( - "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman" - )), - } - } -} - -/// Auto-detect the appropriate compute driver based on the runtime environment. -/// -/// Priority order: Kubernetes → Podman → Docker. -/// VM is never auto-detected (requires explicit `--drivers vm`). -/// -/// Returns the first driver where the environment check passes. -/// Returns `None` if no compatible driver is found. -pub fn detect_driver() -> Option { - // Kubernetes: check for KUBERNETES_SERVICE_HOST env var (set inside pods) - if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { - return Some(ComputeDriverKind::Kubernetes); - } - - // Podman: check for a reachable local API socket. - if is_podman_available() { - return Some(ComputeDriverKind::Podman); - } - - // Docker: check for a reachable local API socket. - if is_docker_available() { - return Some(ComputeDriverKind::Docker); - } - - None -} - -fn is_podman_available() -> bool { - detect_podman_socket().is_some() -} - -/// Return the first responsive Podman API socket, or `None` if none respond. -pub fn detect_podman_socket() -> Option { - detect_podman_socket_from_candidates(&podman_socket_candidates()) -} - -fn detect_podman_socket_from_candidates(candidates: &[PathBuf]) -> Option { - candidates - .iter() - .find(|path| podman_socket_responds(path)) - .cloned() -} - -fn podman_socket_candidates() -> Vec { - let socket = std::env::var("OPENSHELL_PODMAN_SOCKET") - .ok() - .filter(|path| !path.trim().is_empty()) - .map(PathBuf::from); - podman_socket_candidates_from_env( - socket, - std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from), - std::env::var_os("HOME").map(PathBuf::from), - ) -} - -fn podman_socket_candidates_from_env( - socket: Option, - runtime_dir: Option, - home: Option, -) -> Vec { - let mut candidates = Vec::new(); - - if let Some(path) = socket { - candidates.push(path); - } - - if let Some(runtime_dir) = runtime_dir { - candidates.push(runtime_dir.join("podman/podman.sock")); - } - - #[cfg(target_os = "linux")] - { - candidates.push(PathBuf::from(format!( - "/run/user/{}/podman/podman.sock", - current_uid() - ))); - } - - if let Some(home) = home { - candidates.push(home.join(".local/share/containers/podman/machine/podman.sock")); - } - - candidates -} - -fn is_docker_available() -> bool { - detect_docker_socket().is_some() -} - -pub fn detect_docker_socket() -> Option { - detect_docker_socket_from_candidates(&docker_socket_candidates()) -} - -fn detect_docker_socket_from_candidates(candidates: &[PathBuf]) -> Option { - candidates - .iter() - .find(|path| docker_socket_responds(path)) - .cloned() -} - -fn docker_socket_candidates() -> Vec { - let mut candidates = Vec::new(); - - if let Ok(host) = std::env::var("DOCKER_HOST") - && let Some(path) = docker_host_unix_socket_path(&host) - { - candidates.push(path); - } - - candidates.push(PathBuf::from("/var/run/docker.sock")); - - if let Some(home) = std::env::var_os("HOME") { - candidates.push(PathBuf::from(home).join(".docker/run/docker.sock")); - } - - if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { - candidates.push(PathBuf::from(runtime_dir).join("docker.sock")); - } - - candidates -} - -fn docker_host_unix_socket_path(host: &str) -> Option { - let path = host.trim().strip_prefix("unix://")?; - (!path.is_empty()).then(|| PathBuf::from(path)) -} - -#[cfg(unix)] -fn is_unix_socket(path: &Path) -> bool { - path.metadata() - .is_ok_and(|metadata| metadata.file_type().is_socket()) -} - -#[cfg(unix)] -fn podman_socket_responds(path: &Path) -> bool { - unix_socket_http_ping(path, |response| { - http_response_is_success(response) && contains_ascii(response, b"Libpod-Api-Version:") - }) -} - -#[cfg(unix)] -fn docker_socket_responds(path: &Path) -> bool { - unix_socket_http_ping(path, |response| { - http_response_is_success(response) - && contains_ascii(response, b"Api-Version:") - && !contains_ascii(response, b"Libpod-Api-Version:") - }) -} - -#[cfg(unix)] -fn unix_socket_http_ping(path: &Path, accepts_response: impl FnOnce(&[u8]) -> bool) -> bool { - const PROBE_TIMEOUT: Duration = Duration::from_secs(1); - const PING_REQUEST: &[u8] = - b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; - - if !is_unix_socket(path) { - return false; - } - - let Ok(mut stream) = std::os::unix::net::UnixStream::connect(path) else { - return false; - }; - if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err() - || stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err() - || stream.write_all(PING_REQUEST).is_err() - { - return false; - } - - let mut response = [0_u8; 512]; - let mut total = 0; - while total < response.len() { - let Ok(n) = stream.read(&mut response[total..]) else { - return false; - }; - if n == 0 { - break; - } - total += n; - if contains_ascii(&response[..total], b"\r\n\r\n") { - break; - } - } - total > 0 && accepts_response(&response[..total]) -} - -#[cfg(unix)] -fn http_response_is_success(response: &[u8]) -> bool { - response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200") -} - -#[cfg(unix)] -fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { - haystack - .windows(needle.len()) - .any(|window| window.eq_ignore_ascii_case(needle)) -} - -#[cfg(all(unix, test))] -fn is_reachable_unix_socket(path: &Path) -> bool { - is_unix_socket(path) && std::os::unix::net::UnixStream::connect(path).is_ok() -} - -#[cfg(all(unix, target_os = "linux"))] -fn current_uid() -> u32 { - use std::os::unix::fs::MetadataExt; - - std::fs::metadata("/proc/self").map_or(0, |metadata| metadata.uid()) -} - -#[cfg(not(unix))] -fn is_unix_socket(path: &Path) -> bool { - let _ = path; - false -} - -#[cfg(not(unix))] -fn podman_socket_responds(path: &Path) -> bool { - let _ = path; - false -} - -#[cfg(not(unix))] -fn docker_socket_responds(path: &Path) -> bool { - let _ = path; - false -} - /// Server configuration. /// /// Built programmatically in [`crate::Config::new`] and the gateway CLI from @@ -417,6 +142,9 @@ fn docker_socket_responds(path: &Path) -> bool { /// `Deserialize` impls for that purpose). #[derive(Debug, Clone)] pub struct Config { + /// Operator-assigned name for this gateway installation. + pub name: String, + /// Address to bind the server to. pub bind_address: SocketAddr, @@ -480,6 +208,13 @@ pub struct Config { /// resolved by the gateway config loader. pub compute_driver_endpoints: BTreeMap, + /// Credential drivers enabled for provider credential storage. + pub credential_drivers: Vec, + + /// Optional credential-driver default retained for compatibility. When + /// set, it must match the single enabled credential driver. + pub default_credential_driver: Option, + /// TTL for SSH session tokens, in seconds. 0 disables expiry. pub ssh_session_ttl_secs: u64, @@ -546,6 +281,24 @@ pub struct TlsConfig { /// When `false`, client certificates are accepted but not required. #[serde(default)] pub require_client_auth: bool, + + /// Path to an external TLS certificate file (e.g. ACME/publicly-trusted). + /// When set, the server uses SNI-based certificate selection: connections + /// whose SNI hostname matches `external_server_names` receive this cert, + /// all others receive the primary (internal) cert. + #[serde(default)] + pub external_cert_path: Option, + + /// Path to the private key for the external TLS certificate. + #[serde(default)] + pub external_key_path: Option, + + /// Hostnames that should be served with the external certificate. + /// Connections whose SNI matches one of these names receive the external + /// cert; all other connections (including those with no SNI) receive the + /// primary (internal) cert. + #[serde(default)] + pub external_server_names: Vec, } /// OIDC (`OpenID` Connect) configuration for JWT-based authentication. @@ -623,6 +376,19 @@ pub struct GatewayInterceptorConfig { /// Interceptor gRPC endpoint. Supports `http://`, `https://`, and /// `unix://` endpoints. pub grpc_endpoint: String, + /// Optional PEM trust-root bundle for an HTTPS endpoint. The gateway + /// loads this file during interceptor initialization. + #[serde(default)] + pub tls_ca_cert_path: Option, + /// Exact JWT audience for this service. When omitted, a kind-scoped value + /// is derived from the configured registration name. + #[serde(default)] + pub audience: Option, + /// Opt out of extension authentication for this interceptor, permitting a + /// plaintext `http://` endpoint with no bearer credential. Development and + /// trusted-network deployments only. + #[serde(default)] + pub allow_insecure_transport: bool, /// Deterministic service ordering. Lower values run first. #[serde(default)] pub order: i32, @@ -648,6 +414,19 @@ pub struct GatewayInterceptorConfig { pub bindings: Vec, } +impl GatewayInterceptorConfig { + /// Resolve the configured JWT audience to its deterministic default. + pub fn resolved_audience(&self) -> Cow<'_, str> { + self.audience + .as_deref() + .filter(|audience| !audience.is_empty()) + .map_or_else( + || Cow::Owned(format!("urn:openshell:extension:interceptor:{}", self.name)), + Cow::Borrowed, + ) + } +} + /// Operator policy for authorizing interceptor manifest bindings. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -739,8 +518,8 @@ pub struct GatewayJwtConfig { pub public_key_path: PathBuf, /// Path to the `kid` value (plain text, one line). pub kid_path: PathBuf, - /// Stable gateway identity embedded in `iss`/`aud`. Defaults to the - /// hostname-or-`openshell` placeholder if unset. + /// Stable gateway identity embedded in `iss`/`aud`. Defaults to + /// `openshell`. #[serde(default = "default_gateway_id")] pub gateway_id: String, /// Token lifetime in seconds. A value of 0 disables expiration and is @@ -773,6 +552,7 @@ impl Config { /// Create a new config with optional TLS. pub fn new(tls: Option) -> Self { Self { + name: DEFAULT_GATEWAY_NAME.to_string(), bind_address: default_bind_address(), health_bind_address: None, metrics_bind_address: None, @@ -791,6 +571,8 @@ impl Config { database_url: String::new(), compute_drivers: vec![], compute_driver_endpoints: BTreeMap::new(), + credential_drivers: Vec::new(), + default_credential_driver: None, ssh_session_ttl_secs: default_ssh_session_ttl_secs(), grpc_rate_limit_requests: None, grpc_rate_limit_window_secs: None, @@ -798,6 +580,13 @@ impl Config { } } + /// Create a new configuration with the gateway installation name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + /// Create a new configuration with the given bind address. #[must_use] pub const fn with_bind_address(mut self, addr: SocketAddr) -> Self { @@ -857,6 +646,24 @@ impl Config { self } + /// Create a new configuration with the configured credential drivers. + #[must_use] + pub fn with_credential_drivers(mut self, drivers: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.credential_drivers = drivers.into_iter().map(Into::into).collect(); + self + } + + /// Create a new configuration with the default credential driver. + #[must_use] + pub fn with_default_credential_driver(mut self, driver: Option>) -> Self { + self.default_credential_driver = driver.map(Into::into); + self + } + /// Create a new configuration with the SSH session TTL. #[must_use] pub const fn with_ssh_session_ttl_secs(mut self, secs: u64) -> Self { @@ -1017,50 +824,15 @@ const fn default_ssh_session_ttl_secs() -> u64 { #[cfg(test)] mod tests { - #[cfg(unix)] - use super::is_reachable_unix_socket; use super::{ - ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, + Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, GatewayProviderProfileSourceConfig, PolicyValidationFailureMode, - detect_docker_socket_from_candidates, detect_driver, detect_podman_socket_from_candidates, - docker_host_unix_socket_path, docker_socket_responds, is_unix_socket, - normalize_compute_driver_name, podman_socket_candidates_from_env, podman_socket_responds, + normalize_compute_driver_name, }; - #[cfg(unix)] - use std::io::{Read as _, Write as _}; use std::net::SocketAddr; - #[cfg(unix)] - use std::os::unix::net::UnixListener; - use std::path::PathBuf; use std::time::Duration; - #[test] - fn compute_driver_kind_parses_supported_values() { - assert_eq!( - "kubernetes".parse::().unwrap(), - ComputeDriverKind::Kubernetes - ); - assert_eq!( - "vm".parse::().unwrap(), - ComputeDriverKind::Vm - ); - assert_eq!( - "podman".parse::().unwrap(), - ComputeDriverKind::Podman - ); - assert_eq!( - "docker".parse::().unwrap(), - ComputeDriverKind::Docker - ); - } - - #[test] - fn compute_driver_kind_rejects_unknown_values() { - let err = "firecracker".parse::().unwrap_err(); - assert!(err.contains("unsupported compute driver 'firecracker'")); - } - #[test] fn policy_validation_failure_mode_is_secure_by_default() { assert_eq!( @@ -1118,6 +890,29 @@ mod tests { ); } + #[test] + fn config_defaults_to_internal_credential_storage() { + let cfg = Config::new(None); + assert!(cfg.credential_drivers.is_empty()); + assert!(cfg.default_credential_driver.is_none()); + } + + #[test] + fn config_accepts_credential_driver_settings() { + let cfg = Config::new(None) + .with_credential_drivers(["kubernetes-secrets", "vault"]) + .with_default_credential_driver(Some("kubernetes-secrets")); + + assert_eq!( + cfg.credential_drivers, + vec!["kubernetes-secrets".to_string(), "vault".to_string()] + ); + assert_eq!( + cfg.default_credential_driver.as_deref(), + Some("kubernetes-secrets") + ); + } + #[test] fn gateway_jwt_ttl_defaults_to_non_expiring() { let cfg: GatewayJwtConfig = serde_json::from_value(serde_json::json!({ @@ -1130,6 +925,15 @@ mod tests { assert_eq!(cfg.ttl_secs, 0); } + #[test] + fn name_defaults_and_can_be_overridden() { + assert_eq!(Config::new(None).name, "openshell"); + assert_eq!( + Config::new(None).with_name("production-us-west").name, + "production-us-west" + ); + } + #[test] fn gateway_interceptor_failure_policy_rejects_ignore() { let err = @@ -1155,6 +959,19 @@ mod tests { defaulted.binding_policy, GatewayInterceptorBindingPolicy::Dynamic ); + assert_eq!( + defaulted.resolved_audience(), + "urn:openshell:extension:interceptor:governance" + ); + let explicitly_empty = GatewayInterceptorConfig { + name: "governance".to_string(), + audience: Some(String::new()), + ..GatewayInterceptorConfig::default() + }; + assert_eq!( + explicitly_empty.resolved_audience(), + "urn:openshell:extension:interceptor:governance" + ); assert_eq!(allowlist, GatewayInterceptorBindingPolicy::Allowlist); assert_eq!(exact, GatewayInterceptorBindingPolicy::Exact); } @@ -1235,238 +1052,6 @@ mod tests { assert_eq!(cfg.health_bind_address, Some(addr)); } - #[test] - fn detect_driver_returns_none_without_k8s_env_or_local_runtime() { - // When KUBERNETES_SERVICE_HOST is not set, no Docker binary/socket is - // available, and no Podman API socket is available, detect_driver - // should return None. - // This test may pass or fail depending on the test environment, - // but it documents the expected behavior. - let _ = detect_driver(); // Returns Some or None based on environment - } - - #[test] - fn docker_host_unix_socket_path_parses_unix_hosts() { - assert_eq!( - docker_host_unix_socket_path("unix:///var/run/docker.sock"), - Some(PathBuf::from("/var/run/docker.sock")) - ); - assert_eq!(docker_host_unix_socket_path("tcp://127.0.0.1:2375"), None); - assert_eq!(docker_host_unix_socket_path("unix://"), None); - } - - #[cfg(unix)] - #[test] - fn is_unix_socket_detects_socket_files() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("docker.sock"); - let _listener = UnixListener::bind(&socket_path).expect("bind unix socket"); - - assert!(is_unix_socket(&socket_path)); - assert!(is_reachable_unix_socket(&socket_path)); - - let regular_file = temp_dir.path().join("not-a-socket"); - std::fs::write(®ular_file, b"not a socket").expect("write regular file"); - assert!(!is_unix_socket(®ular_file)); - assert!(!is_reachable_unix_socket(®ular_file)); - } - - #[cfg(unix)] - #[test] - fn podman_socket_probe_accepts_successful_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("podman.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept podman probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read podman probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write podman ping response"); - }); - - assert!(podman_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - fn podman_socket_probe_rejects_docker_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("podman.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept podman probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read podman probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nServer: Docker/29.2.1\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write docker ping response"); - }); - - assert!(!podman_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - fn docker_socket_probe_accepts_successful_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("docker.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind docker socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept docker probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read docker probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nApi-Version: 1.51\r\nDocker-Experimental: false\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write docker ping response"); - }); - - assert!(docker_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - fn docker_socket_probe_rejects_podman_ping_response() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("podman.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept docker probe"); - let mut request = [0_u8; 128]; - let n = stream.read(&mut request).expect("read docker probe"); - assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write podman ping response"); - }); - - assert!(!docker_socket_responds(&socket_path)); - handle.join().expect("probe server exits"); - } - - #[cfg(unix)] - #[test] - fn docker_socket_probe_rejects_inactive_socket() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let socket_path = temp_dir.path().join("docker.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind docker socket"); - drop(listener); - - assert!(is_unix_socket(&socket_path)); - assert!(!docker_socket_responds(&socket_path)); - } - - #[cfg(unix)] - #[test] - fn docker_socket_detection_returns_the_responsive_candidate() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let inactive_path = temp_dir.path().join("inactive.sock"); - let inactive_listener = UnixListener::bind(&inactive_path).expect("bind inactive socket"); - drop(inactive_listener); - - let responsive_path = temp_dir.path().join("responsive.sock"); - let listener = UnixListener::bind(&responsive_path).expect("bind responsive socket"); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept docker probe"); - let mut request = [0_u8; 128]; - let _ = stream.read(&mut request).expect("read docker probe"); - stream - .write_all(b"HTTP/1.1 200 OK\r\nApi-Version: 1.51\r\nContent-Length: 2\r\n\r\nOK") - .expect("write docker ping response"); - }); - - assert_eq!( - detect_docker_socket_from_candidates(&[inactive_path, responsive_path.clone(),]), - Some(responsive_path) - ); - handle.join().expect("probe server exits"); - } - - #[test] - fn podman_socket_candidates_include_env_runtime_and_home_paths() { - let candidates = podman_socket_candidates_from_env( - Some(PathBuf::from("/tmp/custom-podman.sock")), - Some(PathBuf::from("/tmp/runtime")), - Some(PathBuf::from("/tmp/home")), - ); - - assert!(candidates.contains(&PathBuf::from("/tmp/custom-podman.sock"))); - assert!(candidates.contains(&PathBuf::from("/tmp/runtime/podman/podman.sock"))); - assert!(candidates.contains(&PathBuf::from( - "/tmp/home/.local/share/containers/podman/machine/podman.sock" - ))); - } - - #[cfg(unix)] - #[test] - fn podman_socket_detection_returns_the_responsive_candidate() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let inactive_path = temp_dir.path().join("inactive.sock"); - let inactive_listener = UnixListener::bind(&inactive_path).expect("bind inactive socket"); - drop(inactive_listener); - - let responsive_path = temp_dir.path().join("responsive.sock"); - let listener = UnixListener::bind(&responsive_path).expect("bind responsive socket"); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept podman probe"); - let mut request = [0_u8; 128]; - let _ = stream.read(&mut request).expect("read podman probe"); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", - ) - .expect("write podman ping response"); - }); - - assert_eq!( - detect_podman_socket_from_candidates(&[inactive_path, responsive_path.clone(),]), - Some(responsive_path) - ); - handle.join().expect("probe server exits"); - } - - #[test] - #[allow(unsafe_code)] // std::env::set_var/remove_var require unsafe in Rust 2024 - fn detect_driver_prefers_kubernetes_when_k8s_env_is_set() { - // Save the original env var - let original = std::env::var("KUBERNETES_SERVICE_HOST").ok(); - - // Set the env var - unsafe { - std::env::set_var("KUBERNETES_SERVICE_HOST", "127.0.0.1"); - } - - let result = detect_driver(); - assert_eq!(result, Some(ComputeDriverKind::Kubernetes)); - - // Restore the original env var - unsafe { - match original { - Some(val) => std::env::set_var("KUBERNETES_SERVICE_HOST", val), - None => std::env::remove_var("KUBERNETES_SERVICE_HOST"), - } - } - } - #[test] fn supervisor_image_tag_prefers_explicit_build_tags() { use super::resolve_supervisor_image_tag; diff --git a/crates/openshell-core/src/container_paths.rs b/crates/openshell-core/src/container_paths.rs new file mode 100644 index 0000000000..e26ea53f7a --- /dev/null +++ b/crates/openshell-core/src/container_paths.rs @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical paths reserved for `OpenShell` control state inside sandboxes. +//! +//! Keep fixed in-container and VM guest paths here so the code that creates or +//! consumes control state cannot drift from the mount-collision validator. + +use std::path::{Path, PathBuf}; + +pub const OPT_ROOT: &str = "/opt/openshell"; +pub const ETC_ROOT: &str = "/etc/openshell"; +pub const TLS_ROOT: &str = "/etc/openshell-tls"; +pub const RUN_ROOT: &str = "/run/openshell"; +pub const SIDECAR_RUN_ROOT: &str = "/run/openshell-sidecar"; +pub const NETNS_MOUNT_ROOT: &str = "/run/netns"; +pub const NETNS_IPROUTE2_ROOT: &str = "/var/run/netns"; + +/// Standard Linux container namespaces that an image-selected workspace must +/// not contain or enter. +/// +/// These roots cover the default filesystems and devices defined by the OCI +/// Runtime Specification: procfs, sysfs, cgroups, device nodes, devpts, shared +/// memory, and POSIX message queues. +/// +pub const OCI_RUNTIME_MOUNT_ROOTS: &[&str] = &["/proc", "/sys", "/dev"]; + +/// High-level namespaces mounted or created by `OpenShell` inside sandboxes. +/// +/// This is intentionally not a general Linux system-path denylist. Kernel and +/// image-provided paths have separate trust models; see NVIDIA/OpenShell#2578. +pub const CONTROL_ROOTS: &[&str] = &[ + OPT_ROOT, + ETC_ROOT, + TLS_ROOT, + RUN_ROOT, + SIDECAR_RUN_ROOT, + NETNS_MOUNT_ROOT, + // The supervisor currently uses the conventional iproute2 spelling. + NETNS_IPROUTE2_ROOT, +]; + +pub const SUPERVISOR_CONTAINER_DIR: &str = "/opt/openshell/bin"; +pub const SUPERVISOR_CONTAINER_BINARY: &str = "/opt/openshell/bin/openshell-sandbox"; +pub const TLS_CLIENT_DIR: &str = "/etc/openshell/tls/client"; +pub const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt"; +pub const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt"; +pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key"; +pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; +pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy"; +pub const CONTAINER_POLICY_PATH: &str = "/etc/openshell/policy.yaml"; +pub const POLICY_ADVISOR_SKILL_PATH: &str = "/etc/openshell/skills/policy_advisor.md"; + +pub const SSH_SOCKET_PATH: &str = "/run/openshell/ssh.sock"; +pub const SIDECAR_CONTROL_SOCKET: &str = "/run/openshell-sidecar/control.sock"; +pub const SIDECAR_TLS_DIR: &str = "/etc/openshell-tls/proxy"; +pub const SIDECAR_CLIENT_TLS_DIR: &str = "/etc/openshell-tls/proxy/client"; +pub const CLIENT_TLS_DIR: &str = "/etc/openshell-tls/client"; +pub const SUPERVISOR_CA_CERT_PATH: &str = "/etc/openshell-tls/openshell-ca.pem"; +pub const SUPERVISOR_CA_BUNDLE_PATH: &str = "/etc/openshell-tls/ca-bundle.pem"; + +pub const VM_GUEST_TLS_CA_PATH: &str = "/opt/openshell/tls/ca.crt"; +pub const VM_GUEST_TLS_CERT_PATH: &str = "/opt/openshell/tls/tls.crt"; +pub const VM_GUEST_TLS_KEY_PATH: &str = "/opt/openshell/tls/tls.key"; +pub const VM_GUEST_SANDBOX_TOKEN_PATH: &str = "/opt/openshell/auth/sandbox.jwt"; +pub const VM_GUEST_INIT_DROPIN_DIR: &str = "/opt/openshell/init.d"; +pub const VM_GUEST_INIT_DROPIN_MANIFEST: &str = "/opt/openshell/init.d.manifest"; + +/// Guest path for the corporate upstream-proxy credential in VM sandboxes. +/// +/// The VM driver stages the `user:pass` credential here (mode `0600`, +/// root-only) inside the per-sandbox overlay upperdir, and passes only this +/// path on the supervisor's argv. A microVM has no bind mounts or container +/// secrets, so this is the same delivery the per-sandbox JWT already uses. +pub const VM_GUEST_UPSTREAM_PROXY_AUTH_PATH: &str = "/opt/openshell/auth/upstream-proxy"; + +/// Guest path for the corporate proxy CA bundle in VM sandboxes. +/// +/// A CA certificate is not secret, so unlike the credential this is staged +/// world-readable. The supervisor trusts it for the handshake with an +/// `https://` proxy and for server certificates re-signed by a +/// TLS-intercepting proxy. +pub const VM_GUEST_PROXY_CA_PATH: &str = "/opt/openshell/tls/proxy-ca.pem"; + +/// Guest path for the driver-authored supervisor argument list in VM sandboxes. +/// +/// Podman and Kubernetes build the supervisor's command line directly; the VM +/// guest init script execs a fixed argv, so driver-owned arguments travel +/// through this file instead. The driver writes it into the overlay upperdir +/// on every launch — empty when it has no arguments to pass — so a sandbox +/// image can neither forge entries nor shadow the driver's copy, and the +/// guest appends exactly what it finds there and nothing else. +pub const VM_GUEST_SUPERVISOR_ARGS_PATH: &str = "/opt/openshell/supervisor-args"; +pub const VM_UMOCI_PATH: &str = "/opt/openshell/bin/umoci"; +pub const VM_SANDBOX_OWNER_NORMALIZED_MARKER: &str = "/opt/openshell/.sandbox-owner-normalized"; + +/// Return the conventional iproute2 path for a named network namespace. +pub fn netns_path(name: &str) -> PathBuf { + Path::new(NETNS_IPROUTE2_ROOT).join(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_fixed_control_path_is_below_a_reserved_root() { + let paths = [ + SUPERVISOR_CONTAINER_DIR, + SUPERVISOR_CONTAINER_BINARY, + TLS_CLIENT_DIR, + TLS_CA_MOUNT_PATH, + TLS_CERT_MOUNT_PATH, + TLS_KEY_MOUNT_PATH, + SANDBOX_TOKEN_MOUNT_PATH, + UPSTREAM_PROXY_AUTH_MOUNT_PATH, + CONTAINER_POLICY_PATH, + POLICY_ADVISOR_SKILL_PATH, + SSH_SOCKET_PATH, + SIDECAR_CONTROL_SOCKET, + SIDECAR_TLS_DIR, + SIDECAR_CLIENT_TLS_DIR, + CLIENT_TLS_DIR, + SUPERVISOR_CA_CERT_PATH, + SUPERVISOR_CA_BUNDLE_PATH, + VM_GUEST_TLS_CA_PATH, + VM_GUEST_TLS_CERT_PATH, + VM_GUEST_TLS_KEY_PATH, + VM_GUEST_SANDBOX_TOKEN_PATH, + VM_GUEST_INIT_DROPIN_DIR, + VM_GUEST_INIT_DROPIN_MANIFEST, + VM_GUEST_UPSTREAM_PROXY_AUTH_PATH, + VM_GUEST_PROXY_CA_PATH, + VM_GUEST_SUPERVISOR_ARGS_PATH, + VM_UMOCI_PATH, + VM_SANDBOX_OWNER_NORMALIZED_MARKER, + ]; + + for path in paths { + assert!( + CONTROL_ROOTS + .iter() + .any(|root| Path::new(path).starts_with(root)), + "fixed control path {path} is outside the reserved roots" + ); + } + } + + #[test] + fn runtime_roots_cover_standard_oci_mount_destinations() { + for path in [ + "/proc", + "/dev", + "/dev/pts", + "/dev/shm", + "/dev/mqueue", + "/sys", + "/sys/fs/cgroup", + ] { + assert!( + OCI_RUNTIME_MOUNT_ROOTS + .iter() + .any(|root| Path::new(path).starts_with(root)), + "OCI runtime mount {path} is outside the reserved roots" + ); + } + } +} diff --git a/crates/openshell-core/src/driver_mounts.rs b/crates/openshell-core/src/driver_mounts.rs index 086d992c37..b1a3049882 100644 --- a/crates/openshell-core/src/driver_mounts.rs +++ b/crates/openshell-core/src/driver_mounts.rs @@ -5,6 +5,8 @@ use std::path::Path; +use crate::container_paths::{CONTROL_ROOTS, OCI_RUNTIME_MOUNT_ROOTS}; + /// `SELinux` relabelling mode for bind mounts. /// /// On hosts with `SELinux` enabled (e.g. Fedora, RHEL) a bind-mounted path @@ -25,12 +27,9 @@ pub enum SelinuxLabel { Private, } -const RESERVED_MOUNT_TARGETS: &[&str] = &[ - "/opt/openshell", - "/etc/openshell", - "/etc/openshell-tls", - "/run/netns", -]; +/// Compatibility workspace used when an OCI image has no usable working +/// directory and by drivers whose workspace remains fixed. +pub const DEFAULT_WORKSPACE_ROOT: &str = "/sandbox"; /// Validate a non-empty driver mount source. pub fn validate_mount_source(source: &str, field: &str) -> Result<(), String> { @@ -67,62 +66,143 @@ pub fn validate_mount_subpath(subpath: &str) -> Result<(), String> { return Err("mount subpath must not contain NUL bytes".to_string()); } let path = Path::new(subpath); - if path.is_absolute() - || path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { + if path.components().any(|component| { + matches!( + component, + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::ParentDir + ) + }) { return Err("mount subpath must be relative and must not contain '..'".to_string()); } Ok(()) } /// Validate a container-side mount target for user-supplied driver mounts. +/// +/// Workspace collisions depend on the inspected image's resolved working +/// directory and are checked separately by `validate_workspace_mount_target`. pub fn validate_container_mount_target(target: &str) -> Result<(), String> { - if target.is_empty() { - return Err("mount target must not be empty".to_string()); - } - if target != target.trim() { - return Err("mount target must not contain surrounding whitespace".to_string()); - } - if target.as_bytes().contains(&0) { - return Err("mount target must not contain NUL bytes".to_string()); - } - if !target.starts_with('/') { - return Err("mount target must be an absolute container path".to_string()); - } - if target != "/" { - let segments = target.split('/').skip(1).collect::>(); - let has_internal_empty_segment = segments - .iter() - .take(segments.len().saturating_sub(1)) - .any(|segment| segment.is_empty()); - if has_internal_empty_segment || segments.contains(&".") { - return Err( - "mount target must be normalized and must not contain empty path segments or '.'" - .to_string(), - ); + let normalized = normalize_absolute_container_path(target, "mount target")?; + let path = Path::new(&normalized); + for reserved in CONTROL_ROOTS { + let reserved = Path::new(reserved); + if paths_overlap(path, reserved) { + return Err(format!( + "mount target '{target}' conflicts with reserved OpenShell path '{}'", + reserved.display() + )); } } - let path = Path::new(target); - if path == Path::new("/") { - return Err("mount target must not be the container root".to_string()); + Ok(()) +} + +/// Resolve an OCI image working directory to the internal workspace root used +/// by local container drivers. +/// +/// Empty declarations and `/` use the compatibility fallback. Non-empty +/// declarations must already be normalized absolute paths so the inspected +/// value and the path passed to the supervisor cannot be interpreted +/// differently. +pub fn resolve_oci_workspace_root(working_dir: &str) -> Result { + if working_dir.is_empty() || working_dir == "/" { + return Ok(DEFAULT_WORKSPACE_ROOT.to_string()); } - if path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err("mount target must not contain '..'".to_string()); + let workspace_root = normalize_absolute_container_path(working_dir, "OCI WorkingDir")?; + for runtime_path in OCI_RUNTIME_MOUNT_ROOTS { + validate_workspace_reserved_path(&workspace_root, runtime_path, "OCI runtime mount")?; } - if path == Path::new("/sandbox") { - return Err("mount target '/sandbox' is reserved for the OpenShell workspace".to_string()); + for control_path in CONTROL_ROOTS { + validate_workspace_control_path(&workspace_root, control_path)?; } - for reserved in RESERVED_MOUNT_TARGETS { - if path_is_or_under(path, Path::new(reserved)) { - return Err(format!( - "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" - )); - } + + Ok(workspace_root) +} + +fn normalize_absolute_container_path(value: &str, field: &str) -> Result { + if value.is_empty() { + return Err(format!("{field} must not be empty")); + } + if value != value.trim() { + return Err(format!("{field} must not contain surrounding whitespace")); + } + if value.chars().any(char::is_control) { + return Err(format!("{field} must not contain control characters")); + } + if !value.starts_with('/') { + return Err(format!("{field} must be an absolute container path")); + } + + let segments = value.split('/').skip(1).collect::>(); + let has_internal_empty_segment = segments + .iter() + .take(segments.len().saturating_sub(1)) + .any(|segment| segment.is_empty()); + if has_internal_empty_segment || segments.contains(&".") || segments.contains(&"..") { + return Err(format!( + "{field} must be normalized without empty, '.', or '..' path segments" + )); + } + + let normalized = value.trim_end_matches('/'); + if normalized.is_empty() { + return Err(format!("{field} must not be the container root")); + } + Ok(normalized.to_string()) +} + +/// Reject a workspace that contains or is contained by an `OpenShell` control +/// path. Drivers use this for runtime-configured paths such as the SSH socket. +pub fn validate_workspace_control_path( + workspace_root: &str, + control_path: &str, +) -> Result<(), String> { + validate_workspace_reserved_path(workspace_root, control_path, "OpenShell control path") +} + +fn validate_workspace_reserved_path( + workspace_root: &str, + reserved_path: &str, + description: &str, +) -> Result<(), String> { + let normalized_workspace = normalize_absolute_container_path(workspace_root, "OCI WorkingDir")?; + let normalized_reserved = normalize_absolute_container_path(reserved_path, description)?; + let workspace = Path::new(&normalized_workspace); + let reserved = Path::new(&normalized_reserved); + if paths_overlap(workspace, reserved) { + return Err(format!( + "OCI WorkingDir '{workspace_root}' conflicts with {description} '{reserved_path}'" + )); + } + Ok(()) +} + +/// Reject a mount that contains or is contained by a runtime-configured +/// `OpenShell` control path, such as the sandbox SSH socket. +pub fn validate_mount_control_path(target: &str, control_path: &str) -> Result<(), String> { + let normalized_target = normalize_absolute_container_path(target, "mount target")?; + let normalized_control = + normalize_absolute_container_path(control_path, "OpenShell control path")?; + if paths_overlap( + Path::new(&normalized_target), + Path::new(&normalized_control), + ) { + return Err(format!( + "mount target '{target}' conflicts with OpenShell control path '{control_path}'" + )); + } + Ok(()) +} + +/// Reject a user-supplied mount that would replace or contain the resolved +/// workspace root. Mounts below the workspace remain valid. +pub fn validate_workspace_mount_target(target: &str, workspace_root: &str) -> Result<(), String> { + let normalized_target = normalize_mount_target(target); + if path_is_or_under(Path::new(workspace_root), Path::new(&normalized_target)) { + return Err(format!( + "mount target '{target}' is reserved for the OpenShell workspace" + )); } Ok(()) } @@ -140,6 +220,10 @@ pub fn path_is_or_under(path: &Path, parent: &Path) -> bool { path == parent || path.starts_with(parent) } +fn paths_overlap(left: &Path, right: &Path) -> bool { + path_is_or_under(left, right) || path_is_or_under(right, left) +} + #[cfg(test)] mod tests { use super::*; @@ -151,15 +235,103 @@ mod tests { } #[test] - fn container_target_rejects_workspace_root_only() { - let err = validate_container_mount_target("/sandbox/").unwrap_err(); + fn container_target_workspace_reservation_is_dynamic() { + validate_container_mount_target("/sandbox/").unwrap(); + validate_workspace_mount_target("/sandbox/", "/sandbox").unwrap_err(); + validate_workspace_mount_target("/workspace/", "/sandbox").unwrap(); + validate_workspace_mount_target("/workspace/cache", "/workspace").unwrap(); + validate_workspace_mount_target("/workspace", "/workspace/project").unwrap_err(); + validate_workspace_mount_target("/workspace-other", "/workspace/project").unwrap(); + } + + #[test] + fn oci_workspace_root_uses_fallback_and_accepts_normalized_absolute_paths() { + assert_eq!(resolve_oci_workspace_root("").unwrap(), "/sandbox"); + assert_eq!(resolve_oci_workspace_root("/").unwrap(), "/sandbox"); + assert_eq!( + resolve_oci_workspace_root("/workspace/project/").unwrap(), + "/workspace/project" + ); + assert_eq!( + resolve_oci_workspace_root("/workspace with spaces").unwrap(), + "/workspace with spaces" + ); + } + + #[test] + fn oci_workspace_root_rejects_relative_and_malformed_paths() { + for invalid in [ + "workspace", + "./workspace", + "/workspace/../etc", + "/workspace/./project", + "/workspace//project", + "/workspace\0project", + "/workspace ", + "/workspace\nproject", + ] { + assert!( + resolve_oci_workspace_root(invalid).is_err(), + "expected '{invalid}' to be rejected" + ); + } + } + + #[test] + fn oci_workspace_root_rejects_runtime_and_openshell_control_path_collisions() { + for invalid in [ + "/proc", + "/proc/self", + "/sys", + "/sys/fs/cgroup", + "/dev", + "/dev/shm", + "/etc", + "/opt", + "/opt/openshell", + "/opt/openshell/bin/project", + "/etc/openshell/tls/client", + "/etc/openshell/auth", + "/etc/openshell/skills", + "/etc/openshell-tls", + "/run", + "/run/openshell/cache", + "/run/openshell-sidecar/control.sock", + "/run/netns/project", + "/var/run/netns/project", + ] { + assert!( + resolve_oci_workspace_root(invalid).is_err(), + "expected control-path workspace '{invalid}' to be rejected" + ); + } - assert!(err.contains("reserved for the OpenShell workspace")); + for valid in [ + "/app", + "/etc/project", + "/home/app", + "/opt/app", + "/usr/bin/project", + "/usr/src/app", + "/var/lib/app", + "/var/app/current", + "/var/task", + "/var/www/app", + "/processor", + "/system", + "/device", + ] { + assert_eq!( + resolve_oci_workspace_root(valid).unwrap(), + valid, + "expected application workspace '{valid}' to remain valid" + ); + } } #[test] fn container_target_rejects_reserved_openshell_tls_legacy_path() { - let err = validate_container_mount_target("/etc/openshell-tls/client").unwrap_err(); + let err = validate_container_mount_target("/etc/openshell-tls/proxy/client").unwrap_err(); assert!(err.contains("/etc/openshell-tls")); } @@ -174,6 +346,33 @@ mod tests { #[test] fn container_target_does_not_prefix_match_unrelated_paths() { validate_container_mount_target("/etc/openshell-tools").unwrap(); + validate_container_mount_target("/run/openshell-tools").unwrap(); + } + + #[test] + fn mount_target_rejects_runtime_configured_control_path_overlap() { + for target in ["/custom", "/custom/ssh.sock", "/custom/ssh.sock/cache"] { + assert!( + validate_mount_control_path(target, "/custom/ssh.sock").is_err(), + "expected '{target}' to conflict with the configured control path" + ); + } + validate_mount_control_path("/custom-other", "/custom/ssh.sock").unwrap(); + } + + #[test] + fn workspace_rejects_malformed_runtime_control_paths() { + for control_path in [ + "workspace/ssh.sock", + "/workspace/../run/ssh.sock", + "/workspace//ssh.sock", + "", + ] { + assert!( + validate_workspace_control_path("/workspace", control_path).is_err(), + "expected malformed control path '{control_path}' to be rejected" + ); + } } #[test] @@ -203,15 +402,15 @@ mod tests { fn mount_target_rejects_internal_empty_or_dot_segments() { assert_eq!( validate_container_mount_target("/sandbox/work//tmp").unwrap_err(), - "mount target must be normalized and must not contain empty path segments or '.'" + "mount target must be normalized without empty, '.', or '..' path segments" ); assert_eq!( validate_container_mount_target("/sandbox/work/./tmp").unwrap_err(), - "mount target must be normalized and must not contain empty path segments or '.'" + "mount target must be normalized without empty, '.', or '..' path segments" ); assert_eq!( validate_container_mount_target("/sandbox/work/../../tmp").unwrap_err(), - "mount target must not contain '..'" + "mount target must be normalized without empty, '.', or '..' path segments" ); validate_container_mount_target("/sandbox/work/").unwrap(); } diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index a5bcc55ad3..74871751da 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -3,9 +3,9 @@ //! Utility helpers shared across compute-driver crates. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::proto::compute::v1::{DriverSandbox, GetCapabilitiesResponse}; +use crate::proto::compute::v1::DriverSandbox; // --------------------------------------------------------------------------- // Sandbox container/pod label keys (openshell.ai/ namespace) @@ -30,6 +30,9 @@ pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace"; /// Container/pod label carrying the sandbox workspace. pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace"; +/// Label carrying the gateway identity on managed namespaces. +pub const LABEL_GATEWAY_ID: &str = "openshell.ai/gateway-id"; + /// Label selector that matches all OpenShell-managed resources which carry a /// sandbox ID label. Used by list and watch operations to exclude foreign /// resources from the same namespace. @@ -37,6 +40,30 @@ pub fn openshell_sandbox_label_selector() -> String { format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}") } +// --------------------------------------------------------------------------- +// Sandbox condition reason strings set by compute drivers. +// --------------------------------------------------------------------------- + +/// Ready-condition reason when a container exits on its own. +/// +/// Covers an ordinary application exit or crash (exit 0, a non-zero error code, +/// or an uncaught fault). This is a terminal reason: gateway startup does NOT +/// auto-restart it, so a genuine failure keeps its error signal instead of +/// being relaunched. +pub const CONDITION_EXITED: &str = "ContainerExited"; + +/// Ready-condition reason when a container was terminated by an external signal. +/// +/// SIGKILL/SIGTERM (exit 137/143) is what a Podman/Docker machine or daemon +/// restart does to running containers. Distinct from `CONDITION_EXITED` so +/// gateway startup can recover machine-restart victims while leaving ordinary +/// application exits terminal. +pub const CONDITION_RUNTIME_RESTART: &str = "ContainerRuntimeRestart"; + +/// Ready-condition reason when a container is explicitly stopped via the +/// runtime API (e.g. `podman stop`, gateway-initiated shutdown). +pub const CONDITION_STOPPED: &str = "ContainerStopped"; + // --------------------------------------------------------------------------- /// Path to the sandbox supervisor binary inside the container image. @@ -89,6 +116,17 @@ pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; /// mount so the credential never appears in container environment/metadata. pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy"; +/// Container-side mount path for the corporate proxy CA bundle. +/// +/// Drivers with a `proxy_ca_bundle` operator setting bind-mount the host PEM +/// file here (read-only) and pass the path on the supervisor's argv via +/// `--upstream-proxy-ca-bundle`. The supervisor trusts it for the TLS +/// handshake with an `https://` corporate egress proxy and for server +/// certificates re-signed by a TLS-intercepting proxy. Unlike the proxy +/// credential, a CA certificate is not secret, so a plain read-only bind +/// mount is used rather than a driver secret. +pub const PROXY_CA_MOUNT_PATH: &str = "/etc/openshell/tls/proxy/ca-bundle.pem"; + /// A validated corporate upstream-proxy address. /// /// Produced by [`parse_upstream_proxy_url`], which is the single source of @@ -103,6 +141,9 @@ pub struct UpstreamProxyAddr { pub host: String, /// Proxy TCP port (always explicit in the accepted URL grammar). pub port: u16, + /// `true` when the proxy URL used the `https://` scheme, so the supervisor + /// wraps the connection to the proxy in TLS before the CONNECT handshake. + pub secure: bool, } /// Why an upstream proxy URL was rejected by [`parse_upstream_proxy_url`]. @@ -123,11 +164,11 @@ pub enum UpstreamProxyUrlError { /// `http://host:port` contract exactly. #[error("proxy URL must include an explicit scheme, e.g. http://proxy.corp.com:3128")] MissingScheme, - /// The URL uses a scheme other than `http` (TLS and SOCKS proxies are + /// The URL uses a scheme other than `http` or `https` (SOCKS proxies are /// not supported by the sandbox supervisor). #[error( - "unsupported proxy scheme '{0}': only http:// forward proxies are \ - supported by the sandbox supervisor" + "unsupported proxy scheme '{0}': only http:// and https:// forward \ + proxies are supported by the sandbox supervisor" )] UnsupportedScheme(String), /// The URL has no explicit port. Corporate proxies rarely listen on the @@ -157,11 +198,13 @@ pub enum UpstreamProxyUrlError { /// Parse and validate a corporate upstream-proxy URL. /// -/// The accepted grammar is exactly `http://host:port`: the scheme and the -/// port must both be explicit, only `http://` proxies are accepted, and -/// inline userinfo is rejected. The URL must address the proxy only: a path -/// (other than a bare trailing `/`), query, or fragment is rejected rather -/// than silently discarded. +/// The accepted grammar is exactly `http://host:port` or `https://host:port`: +/// the scheme and the port must both be explicit, only `http://` and +/// `https://` proxies are accepted, and inline userinfo is rejected. The URL +/// must address the proxy only: a path (other than a bare trailing `/`), +/// query, or fragment is rejected rather than silently discarded. The +/// returned [`UpstreamProxyAddr::secure`] records whether the `https://` +/// scheme was used so the supervisor knows to TLS-wrap the proxy connection. /// /// # Errors /// @@ -177,11 +220,15 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result Result Result { + read_regular_file_bounded(path, MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES).map_err(|err| match err { + BoundedReadError::Open(e) => format!("failed to open proxy auth file '{path}': {e}"), + BoundedReadError::Stat(e) => format!("failed to stat proxy auth file '{path}': {e}"), + BoundedReadError::NotRegular => format!("proxy auth file '{path}' is not a regular file"), + BoundedReadError::TooLarge => format!( + "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" + ), + BoundedReadError::Read(e) => format!("failed to read proxy auth file '{path}': {e}"), + }) +} + +/// Hard upper bound on the size of a corporate proxy CA bundle file. +/// +/// A CA bundle holding every corporate trust anchor is a few tens of +/// kilobytes; this cap only exists so a hostile or misconfigured path (a huge +/// file, or a special file such as `/dev/zero`) cannot exhaust gateway, +/// driver, or supervisor memory during a bounded read. +pub const MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES: u64 = 1024 * 1024; + +/// Read and validate an operator corporate proxy CA bundle PEM file. +/// +/// Rejects non-regular files (e.g. `/dev/zero`, directories, FIFOs) and files +/// larger than [`MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES`], then requires the +/// bundle to contribute at least one trust anchor rustls actually accepts — +/// see [`validate_upstream_proxy_ca_bundle_pem`]. Returns the PEM contents. +/// +/// Shared by the compute driver (at sandbox-create time, so the operator gets +/// an error naming the setting) and the in-container supervisor (at startup), +/// so a bundle accepted on the host is never rejected inside the sandbox and +/// vice versa. This is a blocking read; async callers should wrap it (e.g. +/// `tokio::task::spawn_blocking`). +/// +/// `label` names the operator-facing setting (`proxy_ca_bundle`, or the +/// supervisor's argument name) and prefixes every error. +/// +/// # Errors +/// +/// Returns a descriptive error (never containing file contents) when the path +/// cannot be read, is not a regular file, exceeds the size bound, or holds no +/// usable certificate. +pub fn read_upstream_proxy_ca_bundle_file(path: &str, label: &str) -> Result { + let pem = read_regular_file_bounded(path, MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES).map_err( + |err| match err { + BoundedReadError::Open(e) | BoundedReadError::Stat(e) | BoundedReadError::Read(e) => { + format!("{label} '{path}' could not be read: {e}") + } + BoundedReadError::NotRegular => { + format!("{label} '{path}' is not a regular file") + } + BoundedReadError::TooLarge => format!( + "{label} '{path}' exceeds the {MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES}-byte limit" + ), + }, + )?; + validate_upstream_proxy_ca_bundle_pem(&pem, path, label)?; + Ok(pem) +} + +/// Require a CA bundle PEM to contribute at least one usable trust anchor. +/// +/// Fail-closed to match the rest of the operator-owned proxy configuration: +/// the operator explicitly pointed at this file, so a bundle with no usable +/// certificate is an error rather than a silent fall-back to the built-in +/// roots that would quietly weaken the trust boundary. +/// +/// Validating that rustls accepts an anchor — rather than only that PEM +/// framing base64-decodes — is what makes the host-side check equivalent to +/// the guest-side one: a PEM block holding invalid DER passes +/// `rustls_pemfile::certs` but is silently dropped by +/// `RootCertStore::add_parsable_certificates`, so counting PEM blocks alone +/// would accept on the host a bundle that contributes zero anchors at runtime. +/// +/// # Errors +/// +/// Returns a descriptive error, prefixed with `label` and naming `path`, when +/// the PEM holds no certificate block or no block contains valid X.509 DER. +pub fn validate_upstream_proxy_ca_bundle_pem( + pem: &str, + path: &str, + label: &str, +) -> Result<(), String> { + let certs: Vec<_> = rustls_pemfile::certs(&mut pem.as_bytes()) + .flatten() + .collect(); + if certs.is_empty() { + return Err(format!( + "{label} '{path}' contains no PEM certificate blocks" + )); + } + let mut store = rustls::RootCertStore::empty(); + let (added, _ignored) = store.add_parsable_certificates(certs); + if added == 0 { + return Err(format!( + "{label} '{path}' contains no usable trust anchors \ + (PEM blocks were found but none contain valid X.509 DER)" + )); + } + Ok(()) +} + +/// Failure modes of [`read_regular_file_bounded`], so each caller can phrase +/// them in terms of the operator setting it is reading. +enum BoundedReadError { + Open(std::io::Error), + Stat(std::io::Error), + NotRegular, + TooLarge, + Read(std::io::Error), +} + +/// Read a regular file into a `String`, rejecting anything larger than +/// `max_bytes` and anything that is not a regular file. +/// +/// Backs the operator-supplied proxy file readers, which must never let a +/// hostile or misconfigured path (`/dev/zero`, a FIFO, a directory, a huge +/// file) exhaust memory or block the caller. +fn read_regular_file_bounded(path: &str, max_bytes: u64) -> Result { use std::io::Read as _; + // Windows rejects opening a directory before a file handle is available, + // while Unix permits the open and rejects it via handle metadata below. + // Preflight the path so every platform reports the intended non-regular + // file error. The post-open check remains necessary to close the TOCTOU + // window if the path is replaced between these operations. + #[cfg(target_os = "windows")] + { + let path_metadata = std::fs::metadata(path).map_err(BoundedReadError::Open)?; + if !path_metadata.is_file() { + return Err(BoundedReadError::NotRegular); + } + } + // On Unix, open non-blocking so a FIFO with no writer does not hang the // open() call indefinitely; the regular-file check below then rejects it. // O_NONBLOCK has no effect on the subsequent read of a regular file. @@ -344,38 +521,155 @@ pub fn read_upstream_proxy_credential_file(path: &str) -> Result #[cfg(not(unix))] let open_result = std::fs::File::open(path); - let file = open_result.map_err(|e| format!("failed to open proxy auth file '{path}': {e}"))?; - let metadata = file - .metadata() - .map_err(|e| format!("failed to stat proxy auth file '{path}': {e}"))?; + let file = open_result.map_err(BoundedReadError::Open)?; + let metadata = file.metadata().map_err(BoundedReadError::Stat)?; if !metadata.is_file() { - return Err(format!("proxy auth file '{path}' is not a regular file")); + return Err(BoundedReadError::NotRegular); } - if metadata.len() > MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES { - return Err(format!( - "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" - )); + if metadata.len() > max_bytes { + return Err(BoundedReadError::TooLarge); } // Bound the read even if the file grows between stat and read. let mut buf = String::new(); - file.take(MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES + 1) + file.take(max_bytes + 1) .read_to_string(&mut buf) - .map_err(|e| format!("failed to read proxy auth file '{path}': {e}"))?; - if buf.len() as u64 > MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES { - return Err(format!( - "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" - )); + .map_err(BoundedReadError::Read)?; + if buf.len() as u64 > max_bytes { + return Err(BoundedReadError::TooLarge); } Ok(buf) } +/// Operator-supplied corporate upstream-proxy settings, as a borrowed view. +/// +/// Compute drivers store these keys under their own +/// `[openshell.drivers.]` table; this type exists so the pairing rules +/// between them live in one place instead of being restated per driver. +/// Field names map 1:1 onto the documented TOML keys `https_proxy`, +/// `no_proxy`, `proxy_auth_file`, `proxy_auth_allow_insecure`, +/// `proxy_connect_by_hostname`, and `proxy_ca_bundle`. +#[derive(Debug, Clone, Copy, Default)] +pub struct UpstreamProxySettings<'a> { + /// `https_proxy`: the corporate forward proxy URL. + pub url: Option<&'a str>, + /// `no_proxy`: comma-separated bypass list. + pub no_proxy: Option<&'a str>, + /// `proxy_auth_file`: host path to a `user:pass` credential file. + pub auth_file: Option<&'a str>, + /// `proxy_auth_allow_insecure`: acknowledgement that Basic auth to an + /// `http://` proxy travels in cleartext. + pub auth_allow_insecure: Option, + /// `proxy_connect_by_hostname`: send hostnames rather than validated IPs + /// in CONNECT requests. + pub connect_by_hostname: Option, + /// `proxy_ca_bundle`: host path to a PEM CA bundle trusted for the proxy. + pub ca_bundle: Option<&'a str>, +} + +/// Validate operator-supplied corporate upstream-proxy settings, fail-closed. +/// +/// Shares URL semantics with the in-container supervisor through +/// [`parse_upstream_proxy_url`], so a value accepted here can never be +/// rejected by the supervisor at sandbox startup (or vice versa). Every +/// auxiliary setting is only meaningful relative to a proxy boundary the +/// operator believed was in effect, so a stray one is rejected rather than +/// silently accepted while all egress dials directly. +/// +/// A present-but-empty string is rejected everywhere: the supervisor treats +/// an empty driver-supplied argument as a fatal misconfiguration, so a driver +/// must never accept (and later pass) one. +/// +/// # Errors +/// +/// Returns a message naming the offending key. +pub fn validate_upstream_proxy_settings( + settings: &UpstreamProxySettings<'_>, +) -> Result<(), String> { + let proxy_secure = if let Some(url) = settings.url { + let addr = parse_upstream_proxy_url(url).map_err(|err| match err { + UpstreamProxyUrlError::Empty => "https_proxy must not be empty when set".to_string(), + UpstreamProxyUrlError::InlineCredentials => { + "https_proxy must not embed credentials in the URL; supply them via \ + proxy_auth_file so they are not stored in config or sandbox metadata" + .to_string() + } + err => format!("https_proxy {err}"), + })?; + addr.secure + } else { + false + }; + + if let Some(list) = settings.no_proxy { + if list.trim().is_empty() { + return Err("no_proxy must not be empty when set; omit it instead".to_string()); + } + if settings.url.is_none() { + return Err("no_proxy is set but no https_proxy is configured".to_string()); + } + } + + if let Some(path) = settings.auth_file { + if path.trim().is_empty() { + return Err("proxy_auth_file must not be empty when set".to_string()); + } + if settings.url.is_none() { + return Err("proxy_auth_file is set but no https_proxy is configured".to_string()); + } + // Basic auth over the plain-TCP proxy connection is readable by + // anyone on the network path; sending it requires an explicit + // operator acknowledgement rather than being an implicit side effect + // of configuring credentials. For an https:// proxy the credential is + // inside the verified TLS session, so the acknowledgement is + // unnecessary (but tolerated). + if settings.auth_allow_insecure != Some(true) && !proxy_secure { + return Err( + "proxy_auth_file sends the credential as cleartext Basic auth over the \ + plain-TCP connection to the http:// proxy; set proxy_auth_allow_insecure \ + = true to accept that exposure, or remove proxy_auth_file" + .to_string(), + ); + } + } else if settings.auth_allow_insecure.is_some() { + // The acknowledgement without credentials means the operator believed + // an auth file was configured; surface the mismatch. + return Err( + "proxy_auth_allow_insecure is set but no proxy_auth_file is configured".to_string(), + ); + } + + if settings.connect_by_hostname.is_some() && settings.url.is_none() { + return Err( + "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), + ); + } + + // A CA bundle only makes sense relative to a proxy boundary (an https:// + // proxy handshake, or a TLS-intercepting proxy's re-sign CA). The file's + // readability and certificate content are checked at sandbox-create time + // by the driver and fail closed again in the supervisor. + if let Some(path) = settings.ca_bundle { + if path.trim().is_empty() { + return Err("proxy_ca_bundle must not be empty when set".to_string()); + } + if settings.url.is_none() { + return Err("proxy_ca_bundle is set but no https_proxy is configured".to_string()); + } + } + + Ok(()) +} + +/// Container-side directory where the provider SPIFFE Workload API socket is mounted. +pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = "/spiffe-workload-api"; + /// Return the XDG state path for a driver's sandbox JWT token file. /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. /// -/// `driver_subdir` is driver-specific, e.g. `"docker-sandbox-tokens"` or -/// `"podman-sandbox-tokens"`. When `namespace` is `Some`, it is appended as -/// an additional path component (with `/` and `\` replaced by `-`). +/// `driver_subdir` is driver-specific. When `namespace` is `Some`, it is +/// appended as an additional path component (with `/` and `\` replaced by +/// `-`). /// /// # Errors /// Returns an error if the XDG state directory cannot be resolved. @@ -393,22 +687,6 @@ pub fn sandbox_token_path( Ok(path.join(sandbox_id).join("sandbox.jwt")) } -/// Build a [`GetCapabilitiesResponse`] from the common driver capability fields. -/// -/// Every compute driver constructs this response with the same fields. Shared -/// here to avoid repeating the struct literal in each driver crate. -pub fn build_capabilities_response( - driver_name: &str, - driver_version: impl Into, - default_image: impl Into, -) -> GetCapabilitiesResponse { - GetCapabilitiesResponse { - driver_name: driver_name.to_string(), - driver_version: driver_version.into(), - default_image: default_image.into(), - } -} - /// Return the effective log level for a sandbox. /// /// Uses the level from the sandbox spec when non-empty, falling back to @@ -424,7 +702,7 @@ pub fn sandbox_log_level(sandbox: &DriverSandbox, default_level: &str) -> String } // --------------------------------------------------------------------------- -// Supervisor image helpers (shared by Docker and Podman drivers) +// Supervisor image helpers shared by container-backed drivers // --------------------------------------------------------------------------- /// Return the tag portion of a supervisor image reference, or `None` if the @@ -458,6 +736,144 @@ pub fn supervisor_image_should_refresh(image: &str) -> bool { matches!(supervisor_image_tag(image), Some("dev" | "latest")) } +// --------------------------------------------------------------------------- +// Supervisor binary extraction helpers shared by container-backed drivers +// --------------------------------------------------------------------------- + +#[cfg(feature = "driver-extraction")] +/// Extract the payload of the first regular-file entry in a tar archive. +/// +/// Container archive endpoints return a single-file tar when `path` points to +/// a file, so only the first entry is consumed. Returns an error when the +/// archive is empty, the first entry is not a regular file, or the payload is +/// empty. +pub fn extract_first_tar_entry(tar_bytes: &[u8]) -> Result, String> { + let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); + let mut entries = archive + .entries() + .map_err(|err| format!("open tar archive: {err}"))?; + let mut entry = entries + .next() + .ok_or_else(|| "tar archive was empty".to_string())? + .map_err(|err| format!("read tar entry: {err}"))?; + let kind = entry.header().entry_type(); + if !kind.is_file() { + return Err(format!( + "expected a regular file in tar archive, got type {kind:?}" + )); + } + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut bytes) + .map_err(|err| format!("read tar entry payload: {err}"))?; + if bytes.is_empty() { + return Err("tar entry payload was empty".to_string()); + } + Ok(bytes) +} + +#[cfg(feature = "driver-extraction")] +/// Atomically write `bytes` to `final_path` via a sibling temp file. +/// +/// Creates parent directories as needed. The temp file is synced, `chmod 755` +/// (on Unix), and renamed into place so concurrent readers never observe a +/// partial write. Returns a human-readable error string on failure. +pub fn write_cache_binary_atomic(final_path: &Path, bytes: &[u8]) -> Result<(), String> { + let dir = final_path + .parent() + .ok_or_else(|| format!("cache path '{}' has no parent", final_path.display()))?; + std::fs::create_dir_all(dir) + .map_err(|err| format!("failed to create cache dir '{}': {err}", dir.display()))?; + + let mut temp = tempfile::Builder::new() + .prefix(".openshell-sandbox-") + .tempfile_in(dir) + .map_err(|err| format!("failed to create temp file in '{}': {err}", dir.display()))?; + std::io::Write::write_all(&mut temp, bytes) + .map_err(|err| format!("failed to write supervisor binary: {err}"))?; + temp.as_file() + .sync_all() + .map_err(|err| format!("failed to sync supervisor binary: {err}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)) + .map_err(|err| format!("failed to chmod supervisor binary: {err}"))?; + } + + temp.persist(final_path).map_err(|err| { + format!( + "failed to persist supervisor binary to '{}': {}", + final_path.display(), + err.error, + ) + })?; + Ok(()) +} + +/// Return the host-side cache path for an extracted supervisor binary. +/// +/// The path is `$XDG_DATA_HOME/openshell///openshell-sandbox`. +/// `driver_subdir` distinguishes caches across drivers. +pub fn supervisor_cache_path(driver_subdir: &str, digest: &str) -> Result { + let base = crate::paths::xdg_data_dir() + .map_err(|err| format!("failed to resolve XDG data dir: {err}"))?; + Ok(supervisor_cache_path_with_base( + &base, + driver_subdir, + digest, + )) +} + +/// [`supervisor_cache_path`] with an explicit base directory (for testing). +pub fn supervisor_cache_path_with_base(base: &Path, driver_subdir: &str, digest: &str) -> PathBuf { + let sanitized = digest.replace(':', "-"); + base.join("openshell") + .join(driver_subdir) + .join(sanitized) + .join("openshell-sandbox") +} + +/// Generate a unique container name for supervisor binary extraction. +/// +/// Uses the process ID and an atomic counter to avoid collisions across +/// concurrent gateway starts. +pub fn temp_extract_container_name() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let pid = std::process::id(); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + format!("openshell-supervisor-extract-{pid}-{seq}") +} + +/// Validate that the file at `path` starts with the ELF magic bytes (`\x7fELF`). +/// +/// Returns a human-readable error when the file cannot be read or is not a +/// Linux ELF binary. +pub fn validate_linux_elf_binary(path: &Path) -> Result<(), String> { + use std::io::Read; + let mut file = std::fs::File::open(path).map_err(|err| { + format!( + "failed to open supervisor binary '{}': {err}", + path.display() + ) + })?; + let mut magic = [0u8; 4]; + file.read_exact(&mut magic).map_err(|err| { + format!( + "failed to read supervisor binary '{}': {err}", + path.display() + ) + })?; + if magic != [0x7f, b'E', b'L', b'F'] { + return Err(format!( + "supervisor binary '{}' is not a Linux ELF executable", + path.display(), + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -467,6 +883,20 @@ mod tests { let addr = parse_upstream_proxy_url("http://proxy.corp.com:8080").unwrap(); assert_eq!(addr.host, "proxy.corp.com"); assert_eq!(addr.port, 8080); + assert!(!addr.secure, "http:// is not TLS-wrapped"); + } + + #[test] + fn upstream_proxy_url_accepts_https_with_port() { + let addr = parse_upstream_proxy_url("https://proxy.corp.com:3130").unwrap(); + assert_eq!(addr.host, "proxy.corp.com"); + assert_eq!(addr.port, 3130); + assert!(addr.secure, "https:// is TLS-wrapped"); + // An explicit scheme-default port (:443) is accepted even though the + // url crate normalizes it away in the parsed form. + let addr = parse_upstream_proxy_url("https://proxy.corp.com:443").unwrap(); + assert_eq!(addr.port, 443); + assert!(addr.secure); } #[test] @@ -526,12 +956,21 @@ mod tests { } #[test] - fn upstream_proxy_url_rejects_tls_and_socks_schemes() { - for url in ["https://proxy:443", "socks5://proxy:1080"] { - assert!(matches!( - parse_upstream_proxy_url(url), - Err(UpstreamProxyUrlError::UnsupportedScheme(_)) - )); + fn upstream_proxy_url_rejects_socks_schemes() { + // http:// and https:// are supported; only other schemes (SOCKS, etc.) + // are rejected. + for url in [ + "socks5://proxy:1080", + "socks4://proxy:1080", + "ftp://proxy:21", + ] { + assert!( + matches!( + parse_upstream_proxy_url(url), + Err(UpstreamProxyUrlError::UnsupportedScheme(_)) + ), + "{url}" + ); } } @@ -649,7 +1088,7 @@ mod tests { let err = read_upstream_proxy_credential_file(dir.path().to_str().unwrap()).unwrap_err(); assert!(err.contains("regular file"), "{err}"); - if std::path::Path::new("/dev/zero").exists() { + if Path::new("/dev/zero").exists() { let err = read_upstream_proxy_credential_file("/dev/zero").unwrap_err(); assert!(err.contains("regular file"), "{err}"); } @@ -679,4 +1118,273 @@ mod tests { "reading a FIFO must not block" ); } + + /// Build settings with only the fields a case cares about. + #[test] + fn ca_bundle_file_accepts_a_real_certificate() { + // The positive case that pins host acceptance to guest acceptance: + // what the driver stages is exactly what rustls will trust. + let cert = rcgen::generate_simple_self_signed(vec!["proxy.corp.example".to_string()]) + .expect("test CA"); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proxy-ca.pem"); + std::fs::write(&path, cert.cert.pem()).unwrap(); + + let pem = + read_upstream_proxy_ca_bundle_file(path.to_str().unwrap(), "proxy_ca_bundle").unwrap(); + assert!(pem.contains("BEGIN CERTIFICATE")); + } + + #[test] + fn ca_bundle_file_rejects_non_regular_and_oversized_paths() { + // /dev/zero is the case that matters: an unbounded read of it would + // exhaust gateway or driver memory on any authorized sandbox create. + let dir = tempfile::tempdir().unwrap(); + let err = + read_upstream_proxy_ca_bundle_file(dir.path().to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("regular file"), "{err}"); + assert!(err.contains("proxy_ca_bundle"), "{err}"); + + if Path::new("/dev/zero").exists() { + let err = + read_upstream_proxy_ca_bundle_file("/dev/zero", "proxy_ca_bundle").unwrap_err(); + assert!(err.contains("regular file"), "{err}"); + } + + let oversized = dir.path().join("oversized.pem"); + std::fs::write( + &oversized, + vec![b'x'; usize::try_from(MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES).unwrap() + 1], + ) + .unwrap(); + let err = + read_upstream_proxy_ca_bundle_file(oversized.to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("exceeds"), "{err}"); + } + + #[test] + fn ca_bundle_file_missing_path_is_an_error() { + let err = + read_upstream_proxy_ca_bundle_file("/nonexistent/proxy-ca.pem", "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("could not be read"), "{err}"); + } + + #[test] + fn ca_bundle_rejects_a_file_without_certificate_blocks() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proxy-ca.pem"); + std::fs::write(&path, "this is not a certificate\n").unwrap(); + let err = read_upstream_proxy_ca_bundle_file(path.to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("no PEM certificate blocks"), "{err}"); + + std::fs::write(&path, "").unwrap(); + let err = read_upstream_proxy_ca_bundle_file(path.to_str().unwrap(), "proxy_ca_bundle") + .unwrap_err(); + assert!(err.contains("no PEM certificate blocks"), "{err}"); + } + + #[test] + fn ca_bundle_rejects_pem_blocks_holding_invalid_der() { + // Passes `rustls_pemfile::certs` but contributes no trust anchor, so + // accepting it on the host would break every guest after boot. + let err = validate_upstream_proxy_ca_bundle_pem( + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + "/etc/openshell/tls/proxy-ca.pem", + "proxy_ca_bundle", + ) + .unwrap_err(); + assert!(err.contains("no usable trust anchors"), "{err}"); + } + + fn proxy_settings(url: Option<&str>) -> UpstreamProxySettings<'_> { + UpstreamProxySettings { + url, + ..UpstreamProxySettings::default() + } + } + + #[test] + fn upstream_proxy_settings_accept_a_bare_proxy_url() { + validate_upstream_proxy_settings(&proxy_settings(Some("http://proxy.corp.com:3128"))) + .expect("a lone proxy URL is a complete configuration"); + } + + #[test] + fn upstream_proxy_settings_accept_an_empty_configuration() { + validate_upstream_proxy_settings(&UpstreamProxySettings::default()) + .expect("no proxy configured at all is valid"); + } + + #[test] + fn upstream_proxy_settings_reject_an_unsupported_scheme() { + let err = validate_upstream_proxy_settings(&proxy_settings(Some("socks5://proxy:1080"))) + .expect_err("only http:// and https:// proxies are supported"); + assert!(err.starts_with("https_proxy "), "{err}"); + assert!(err.contains("unsupported proxy scheme"), "{err}"); + } + + #[test] + fn upstream_proxy_settings_reject_inline_credentials_by_naming_the_auth_file() { + let err = validate_upstream_proxy_settings(&proxy_settings(Some("http://u:p@proxy:3128"))) + .expect_err("inline credentials would be stored in gateway config"); + assert!(err.contains("proxy_auth_file"), "{err}"); + } + + #[test] + fn upstream_proxy_settings_reject_an_empty_proxy_url() { + let err = validate_upstream_proxy_settings(&proxy_settings(Some(" "))) + .expect_err("present-but-empty is a misconfiguration, not 'unset'"); + assert_eq!(err, "https_proxy must not be empty when set"); + } + + #[test] + fn upstream_proxy_settings_reject_auxiliary_keys_without_a_proxy_url() { + // Each auxiliary key implies a proxy boundary the operator believed + // was in effect; accepting one while every dial goes direct would + // hide a fail-open state. + for (settings, key) in [ + ( + UpstreamProxySettings { + no_proxy: Some("10.0.0.0/8"), + ..UpstreamProxySettings::default() + }, + "no_proxy", + ), + ( + UpstreamProxySettings { + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + ..UpstreamProxySettings::default() + }, + "proxy_auth_file", + ), + ( + UpstreamProxySettings { + connect_by_hostname: Some(true), + ..UpstreamProxySettings::default() + }, + "proxy_connect_by_hostname", + ), + ( + UpstreamProxySettings { + ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem"), + ..UpstreamProxySettings::default() + }, + "proxy_ca_bundle", + ), + ] { + let err = validate_upstream_proxy_settings(&settings) + .expect_err("an auxiliary key without a proxy URL must fail closed"); + assert_eq!( + err, + format!("{key} is set but no https_proxy is configured") + ); + } + } + + #[test] + fn upstream_proxy_settings_reject_empty_auxiliary_values() { + for (settings, expected) in [ + ( + UpstreamProxySettings { + url: Some("http://proxy:3128"), + no_proxy: Some(" "), + ..UpstreamProxySettings::default() + }, + "no_proxy must not be empty when set; omit it instead", + ), + ( + UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_file: Some(""), + ..UpstreamProxySettings::default() + }, + "proxy_auth_file must not be empty when set", + ), + ( + UpstreamProxySettings { + url: Some("http://proxy:3128"), + ca_bundle: Some(""), + ..UpstreamProxySettings::default() + }, + "proxy_ca_bundle must not be empty when set", + ), + ] { + let err = validate_upstream_proxy_settings(&settings) + .expect_err("present-but-empty must never be treated as unset"); + assert_eq!(err, expected); + } + } + + #[test] + fn upstream_proxy_credentials_require_the_cleartext_acknowledgement() { + let err = validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + ..UpstreamProxySettings::default() + }) + .expect_err("Basic auth to an http:// proxy is cleartext on the wire"); + assert!(err.contains("proxy_auth_allow_insecure"), "{err}"); + + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + auth_allow_insecure: Some(true), + ..UpstreamProxySettings::default() + }) + .expect("the explicit acknowledgement makes the exposure an operator decision"); + } + + #[test] + fn upstream_proxy_credentials_need_no_acknowledgement_for_an_https_proxy() { + // The credential travels inside the verified TLS session to the proxy. + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("https://proxy:3130"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + ..UpstreamProxySettings::default() + }) + .expect("an https:// proxy does not expose the credential on the wire"); + + // ... but setting it anyway is tolerated rather than an error. + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("https://proxy:3130"), + auth_file: Some("/etc/openshell/secrets/proxy-auth"), + auth_allow_insecure: Some(true), + ..UpstreamProxySettings::default() + }) + .expect("a redundant acknowledgement is tolerated"); + } + + #[test] + fn upstream_proxy_acknowledgement_without_credentials_is_rejected() { + // Including `= false`: the operator believed an auth file was + // configured, so the mismatch is surfaced rather than ignored. + for ack in [Some(true), Some(false)] { + let err = validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + auth_allow_insecure: ack, + ..UpstreamProxySettings::default() + }) + .expect_err("the acknowledgement is meaningless without a credential"); + assert_eq!( + err, + "proxy_auth_allow_insecure is set but no proxy_auth_file is configured" + ); + } + } + + #[test] + fn upstream_proxy_ca_bundle_is_valid_with_a_plain_http_proxy() { + // A TLS-intercepting proxy can be reached over plain HTTP while still + // re-signing tunneled server certificates with its own CA. + validate_upstream_proxy_settings(&UpstreamProxySettings { + url: Some("http://proxy:3128"), + ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem"), + ..UpstreamProxySettings::default() + }) + .expect("an intercepting proxy's CA is meaningful without an https:// proxy URL"); + } } diff --git a/crates/openshell-core/src/dynamic_string_allowlist.rs b/crates/openshell-core/src/dynamic_string_allowlist.rs new file mode 100644 index 0000000000..1a2968188f --- /dev/null +++ b/crates/openshell-core/src/dynamic_string_allowlist.rs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +use std::sync::{Arc, RwLock}; + +/// Thread-safe dynamic allowlist of strings shared across component boundaries. +#[derive(Debug, Clone)] +pub struct DynamicStringAllowlist { + inner: Arc>>, +} + +impl DynamicStringAllowlist { + fn read_guard(&self) -> std::sync::RwLockReadGuard<'_, BTreeSet> { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write_guard(&self) -> std::sync::RwLockWriteGuard<'_, BTreeSet> { + self.inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + #[must_use] + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(BTreeSet::new())), + } + } + + #[must_use] + pub fn from_set(set: BTreeSet) -> Self { + Self { + inner: Arc::new(RwLock::new(set)), + } + } + + pub fn replace(&self, new_set: BTreeSet) { + *self.write_guard() = new_set; + } + + pub fn merge(&self, additional: &BTreeSet) { + self.write_guard().extend(additional.iter().cloned()); + } + + pub fn read(&self) -> std::sync::RwLockReadGuard<'_, BTreeSet> { + self.read_guard() + } + + #[must_use] + pub fn contains(&self, namespace: &str) -> bool { + self.read_guard().contains(namespace) + } + + pub fn insert(&self, name: String) -> bool { + self.write_guard().insert(name) + } + + pub fn remove(&self, name: &str) -> bool { + self.write_guard().remove(name) + } + + #[must_use] + pub fn shared(&self) -> Arc>> { + Arc::clone(&self.inner) + } +} + +impl Default for DynamicStringAllowlist { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/openshell-core/src/endpoint_path.rs b/crates/openshell-core/src/endpoint_path.rs new file mode 100644 index 0000000000..5f9b37d75b --- /dev/null +++ b/crates/openshell-core/src/endpoint_path.rs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical provider endpoint path matching shared by policy and runtime code. + +/// A compiled provider endpoint path pattern. +/// +/// Invalid glob syntax retains the existing fail-closed behavior: it matches +/// only when the request path is exactly equal to the configured pattern. +#[derive(Debug, Clone)] +pub struct EndpointPathPattern { + source: String, + kind: EndpointPathPatternKind, +} + +#[derive(Debug, Clone)] +enum EndpointPathPatternKind { + Any, + Subtree(String), + Glob(glob::Pattern), + Invalid, +} + +impl EndpointPathPattern { + #[must_use] + pub fn new(pattern: &str) -> Self { + let kind = if pattern.is_empty() || pattern == "**" || pattern == "/**" { + EndpointPathPatternKind::Any + } else if let Some(prefix) = pattern.strip_suffix("/**") { + EndpointPathPatternKind::Subtree(prefix.to_string()) + } else { + glob::Pattern::new(pattern).map_or( + EndpointPathPatternKind::Invalid, + EndpointPathPatternKind::Glob, + ) + }; + Self { + source: pattern.to_string(), + kind, + } + } + + #[must_use] + pub fn matches(&self, path: &str) -> bool { + if self.source == path { + return true; + } + match &self.kind { + EndpointPathPatternKind::Any => true, + EndpointPathPatternKind::Subtree(prefix) => { + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.starts_with('/')) + } + EndpointPathPatternKind::Glob(pattern) => pattern.matches(path), + EndpointPathPatternKind::Invalid => false, + } + } +} + +/// Return whether `path` is selected by a provider endpoint path pattern. +/// +/// Empty paths and `**` match every request path. A trailing `/**` matches the +/// named path itself and every descendant. Other patterns use glob semantics. +#[must_use] +pub fn matches(pattern: &str, path: &str) -> bool { + EndpointPathPattern::new(pattern).matches(path) +} + +#[cfg(test)] +mod tests { + use super::{EndpointPathPattern, matches}; + + #[test] + fn matches_canonical_endpoint_patterns() { + assert!(matches("", "/v1/messages")); + assert!(matches("/**", "/v1/messages")); + assert!(matches("/v1/**", "/v1")); + assert!(matches("/v1/**", "/v1/messages")); + assert!(matches("/v*/messages", "/v1/messages")); + assert!(matches("/v1/*", "/v1/chat/messages")); + assert!(!matches("/v1/**", "/v2/messages")); + assert!(!matches("/v1/*/messages", "/v1/chat/completions")); + } + + #[test] + fn compiled_patterns_preserve_canonical_matching() { + let subtree = EndpointPathPattern::new("/v1/**"); + assert!(subtree.matches("/v1")); + assert!(subtree.matches("/v1/chat/messages")); + assert!(!subtree.matches("/v2/messages")); + + let glob = EndpointPathPattern::new("/v*/messages"); + assert!(glob.matches("/v1/messages")); + assert!(!glob.matches("/v1/completions")); + + let invalid = EndpointPathPattern::new("["); + assert!(invalid.matches("[")); + assert!(!invalid.matches("/v1/messages")); + } +} diff --git a/crates/openshell-core/src/error.rs b/crates/openshell-core/src/error.rs index a149cf006a..145106012d 100644 --- a/crates/openshell-core/src/error.rs +++ b/crates/openshell-core/src/error.rs @@ -106,13 +106,16 @@ impl Error { /// Error type shared by all compute driver implementations. /// -/// Both the Podman and Kubernetes drivers map their backend-specific -/// errors into these variants before crossing crate boundaries. +/// Drivers map backend-specific errors into these variants before crossing +/// crate boundaries. #[derive(Debug, Error)] pub enum ComputeDriverError { /// The requested sandbox already exists. #[error("sandbox already exists")] AlreadyExists, + /// The requested sandbox does not exist. + #[error("sandbox not found")] + NotFound, /// The request contains an invalid argument. #[error("{0}")] InvalidArgument(String), @@ -128,6 +131,7 @@ impl From for tonic::Status { fn from(err: ComputeDriverError) -> Self { match err { ComputeDriverError::AlreadyExists => Self::already_exists("sandbox already exists"), + ComputeDriverError::NotFound => Self::not_found("sandbox not found"), ComputeDriverError::InvalidArgument(m) => Self::invalid_argument(m), ComputeDriverError::Precondition(m) => Self::failed_precondition(m), ComputeDriverError::Message(m) => Self::internal(m), diff --git a/crates/openshell-core/src/external_driver_socket.rs b/crates/openshell-core/src/external_driver_socket.rs new file mode 100644 index 0000000000..e6e1978f83 --- /dev/null +++ b/crates/openshell-core/src/external_driver_socket.rs @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Public Unix-socket transport helpers for out-of-process drivers. + +use std::io; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use tokio::net::{UnixListener, UnixStream}; +use tokio_stream::Stream; + +/// Prepare and bind a private Unix socket owned by the current effective UID. +pub fn bind_private(path: &Path) -> Result { + let parent = path + .parent() + .ok_or_else(|| format!("driver socket path '{}' has no parent", path.display()))?; + let expected_uid = rustix::process::geteuid().as_raw(); + std::fs::create_dir_all(parent) + .map_err(|err| format!("create socket directory {}: {err}", parent.display()))?; + let parent_metadata = std::fs::symlink_metadata(parent) + .map_err(|err| format!("stat socket directory {}: {err}", parent.display()))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.file_type().is_dir() { + return Err(format!( + "driver socket parent '{}' must be a directory, not a symlink", + parent.display() + )); + } + if parent_metadata.uid() != expected_uid { + return Err(format!( + "driver socket parent '{}' is owned by uid {}, expected {}", + parent.display(), + parent_metadata.uid(), + expected_uid + )); + } + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .map_err(|err| format!("chmod socket directory {}: {err}", parent.display()))?; + + match std::fs::symlink_metadata(path) { + Ok(metadata) + if metadata.file_type().is_socket() + && !metadata.file_type().is_symlink() + && metadata.uid() == expected_uid => + { + std::fs::remove_file(path) + .map_err(|err| format!("remove stale socket {}: {err}", path.display()))?; + } + Ok(_) => { + return Err(format!( + "driver socket path '{}' exists but is not an owned Unix socket", + path.display() + )); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("stat driver socket {}: {err}", path.display())), + } + + let listener = UnixListener::bind(path) + .map_err(|err| format!("bind driver socket {}: {err}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|err| format!("chmod driver socket {}: {err}", path.display()))?; + Ok(listener) +} + +/// Remove a socket created by [`bind_private`]. +pub struct SocketCleanup(PathBuf); + +impl SocketCleanup { + #[must_use] + pub fn new(path: PathBuf) -> Self { + Self(path) + } +} + +impl Drop for SocketCleanup { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// Incoming UDS connections restricted to the driver's effective UID. +pub struct SameUidUnixIncoming { + listener: UnixListener, + expected_uid: u32, +} + +impl SameUidUnixIncoming { + #[must_use] + pub fn new(listener: UnixListener) -> Self { + Self { + listener, + expected_uid: rustix::process::geteuid().as_raw(), + } + } +} + +impl Stream for SameUidUnixIncoming { + type Item = io::Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + loop { + match this.listener.poll_accept(cx) { + Poll::Ready(Ok((stream, _))) => match stream.peer_cred() { + Ok(credentials) if credentials.uid() == this.expected_uid => { + return Poll::Ready(Some(Ok(stream))); + } + Ok(credentials) => tracing::warn!( + peer_uid = credentials.uid(), + expected_uid = this.expected_uid, + "rejected external driver socket client" + ), + Err(err) => { + tracing::warn!(error = %err, "failed to authenticate driver socket client"); + } + }, + Poll::Ready(Err(err)) => return Poll::Ready(Some(Err(err))), + Poll::Pending => return Poll::Pending, + } + } + } +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::{PermissionsExt, symlink}; + + use tokio_stream::StreamExt; + + use super::*; + + #[tokio::test] + async fn bind_private_creates_private_socket_and_cleanup_removes_it() { + let temp_dir = tempfile::tempdir().expect("create temporary directory"); + let socket_dir = temp_dir.path().join("driver"); + let socket_path = socket_dir.join("compute.sock"); + + let listener = bind_private(&socket_path).expect("bind private socket"); + + let directory_mode = std::fs::metadata(&socket_dir) + .expect("stat socket directory") + .permissions() + .mode() + & 0o777; + let socket_mode = std::fs::metadata(&socket_path) + .expect("stat socket") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(socket_mode, 0o600); + + drop(listener); + let cleanup = SocketCleanup::new(socket_path.clone()); + drop(cleanup); + assert!(!socket_path.exists()); + } + + #[test] + fn bind_private_rejects_symlinked_parent() { + let temp_dir = tempfile::tempdir().expect("create temporary directory"); + let actual_dir = temp_dir.path().join("actual"); + let linked_dir = temp_dir.path().join("linked"); + std::fs::create_dir(&actual_dir).expect("create socket directory"); + symlink(&actual_dir, &linked_dir).expect("create directory symlink"); + + let err = bind_private(&linked_dir.join("compute.sock")) + .expect_err("symlinked socket parent must be rejected"); + + assert!(err.contains("must be a directory, not a symlink")); + } + + #[test] + fn bind_private_rejects_existing_non_socket() { + let temp_dir = tempfile::tempdir().expect("create temporary directory"); + let socket_path = temp_dir.path().join("compute.sock"); + std::fs::write(&socket_path, b"not a socket").expect("create conflicting file"); + + let err = bind_private(&socket_path).expect_err("non-socket path must be rejected"); + + assert!(err.contains("is not an owned Unix socket")); + assert_eq!( + std::fs::read(&socket_path).expect("read conflicting file"), + b"not a socket" + ); + } + + #[tokio::test] + async fn bind_private_replaces_existing_owned_socket() { + let temp_dir = tempfile::tempdir().expect("create temporary directory"); + let socket_path = temp_dir.path().join("compute.sock"); + let stale_listener = + std::os::unix::net::UnixListener::bind(&socket_path).expect("bind stale Unix socket"); + drop(stale_listener); + + let listener = bind_private(&socket_path).expect("replace owned Unix socket"); + + assert!( + std::fs::symlink_metadata(&socket_path) + .expect("stat replacement socket") + .file_type() + .is_socket() + ); + drop(listener); + } + + #[tokio::test] + async fn same_uid_incoming_accepts_current_user() { + let temp_dir = tempfile::tempdir().expect("create temporary directory"); + let socket_path = temp_dir.path().join("compute.sock"); + let listener = bind_private(&socket_path).expect("bind private socket"); + let mut incoming = SameUidUnixIncoming::new(listener); + + let client = UnixStream::connect(&socket_path) + .await + .expect("connect to private socket"); + let accepted = incoming + .next() + .await + .expect("incoming stream ended") + .expect("accept same-UID client"); + + assert_eq!( + accepted.peer_cred().expect("read client credentials").uid(), + rustix::process::geteuid().as_raw() + ); + drop(client); + } +} diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index 1d97174d99..a17edbdee2 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -745,11 +745,11 @@ pub fn resolve_ssh_gateway( // Remote cluster: use the remote host but keep the cluster URL port. return (host.to_string(), cluster_port); } - // Both endpoints loopback. The unspecified addresses (0.0.0.0 / ::) - // are bind-only — they aren't valid connect targets and aren't in TLS - // cert SANs, so fall back to the cluster URL's host (which the CLI - // is already using to reach the gateway). - if gateway_host == "0.0.0.0" || gateway_host == "::" { + // 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. + if matches!(gateway_host, "0.0.0.0" | "::" | "::1") { return (host.to_string(), cluster_port); } return (gateway_host.to_string(), cluster_port); @@ -1026,6 +1026,13 @@ mod tests { assert_eq!(port, 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); + } + #[test] fn resolve_ssh_gateway_swaps_zeros_for_loopback_cluster_host() { // The gateway binds 0.0.0.0 but advertises that bind address via the @@ -1358,6 +1365,7 @@ mod tests { ); } + #[cfg(not(target_os = "windows"))] #[test] fn check_port_available_occupied_ipv6_wildcard() { // Bind on [::]:0 (IPv6 wildcard) — this simulates a server like diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 579ee4a5b3..54f0db6902 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -23,15 +23,16 @@ use std::sync::{Arc, OnceLock, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::proto::{ - DenialSummary, GetDraftPolicyRequest, GetInferenceBundleRequest, GetInferenceBundleResponse, - GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, - NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, - ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, UpdateConfigRequest, inference_client::InferenceClient, - open_shell_client::OpenShellClient, + DenialSummary, ExchangeProviderSubjectTokenRequest, GetDraftPolicyRequest, + GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxConfigRequest, + GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, NetworkActivitySummary, + PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, ReportPolicyStatusRequest, + SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; use crate::sandbox_env; use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_extension_core::{BearerTokenSlot, ExtensionCredentialStore}; use tonic::Status; use tonic::metadata::AsciiMetadataValue; use tonic::service::interceptor::InterceptedService; @@ -146,6 +147,10 @@ async fn build_plain_channel(endpoint: &str) -> Result { let tls_enabled = endpoint.starts_with("https://"); + // TODO: TLS certs are loaded once here and never re-read. The gateway + // server side supports hot-reload (ArcSwap + notify in tls.rs). The + // supervisor should do the same so that cert-manager rotations take + // effect without restarting the sandbox. if tls_enabled { let ca_path = std::env::var(sandbox_env::TLS_CA) .into_diagnostic() @@ -167,6 +172,17 @@ async fn build_plain_channel(endpoint: &str) -> Result { .into_diagnostic() .wrap_err_with(|| format!("failed to read client key from {key_path}"))?; + // Trust only the configured CA — this is the chart's internal CA + // that signs both the gateway's internal server certificate and + // this client's identity certificate. The gateway uses SNI-based + // certificate selection to present this internal cert to supervisor + // connections, so no public root trust is needed here. + // + // Do NOT add `.with_native_roots()` or `.with_webpki_roots()` here: + // the supervisor runs inside the user-selected sandbox image + // (Docker/Podman drivers), and broadening the trust store would let + // an attacker who controls the image + DNS present a publicly valid + // certificate and intercept the supervisor→gateway TLS connection. let mut tls_config = ClientTlsConfig::new() .ca_certificate(Certificate::from_pem(ca_pem)) .identity(Identity::from_pem(cert_pem, key_pem)); @@ -338,7 +354,9 @@ async fn refresh_token_loop( let sleep = compute_refresh_delay(&slot); tokio::time::sleep(sleep).await; match client - .refresh_sandbox_token(RefreshSandboxTokenRequest {}) + .refresh_sandbox_token(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }) .await { Ok(resp) => { @@ -403,6 +421,91 @@ async fn refresh_token_loop( } } +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }) +} + +/// Registration names to request credentials for. +/// +/// Registrations the operator opted out of extension authentication have no +/// credential to request; asking for one is rejected by the gateway. +fn authenticated_service_names( + services: &[crate::proto::SupervisorMiddlewareService], +) -> Vec { + services + .iter() + .filter(|service| !service.allow_insecure_transport) + .map(|service| service.name.clone()) + .collect() +} + +async fn refresh_extension_credentials_with_client( + client: &mut OpenShellClient, + store: &ExtensionCredentialStore, + services: &[crate::proto::SupervisorMiddlewareService], +) -> Result> { + let names = authenticated_service_names(services); + if names.is_empty() { + return Ok(HashMap::new()); + } + + let response = client + .refresh_sandbox_token(RefreshSandboxTokenRequest { + extension_service_names: names.clone(), + }) + .await + .into_diagnostic() + .wrap_err("failed to refresh extension service credentials")? + .into_inner(); + + // The same refresh response renews the gateway credential. Install it + // before returning so all process-wide gateway clients stay current. This + // is a superset of what the dedicated renewal loop would do, so letting it + // land early is harmless. + install_token_slot(&response.token)?; + + // Validate the whole response before mutating any slot, so a malformed or + // partial reply cannot leave the store half-rotated. + let expected = names + .iter() + .map(String::as_str) + .collect::>(); + let mut validated = HashMap::with_capacity(response.extension_credentials.len()); + for credential in response.extension_credentials { + if !expected.contains(credential.service_name.as_str()) + || validated.contains_key(&credential.service_name) + { + return Err(miette::miette!( + "gateway returned an unexpected or duplicate extension credential" + )); + } + validated.insert( + credential.service_name, + (credential.token, credential.expires_at_ms), + ); + } + if validated.len() != expected.len() { + return Err(miette::miette!( + "gateway omitted one or more requested extension credentials" + )); + } + + let now_ms = now_ms(); + let mut selected = HashMap::with_capacity(validated.len()); + for (name, (token, expires_at_ms)) in validated { + let slot = store + .install(&name, &token, expires_at_ms, now_ms) + .into_diagnostic() + .wrap_err("gateway returned an invalid extension credential")?; + selected.insert(name, slot); + } + Ok(selected) +} + /// Compute the next refresh delay: 80 % of the time remaining until the /// current token's `exp`, plus up to 10 % jitter, with a small lower bound /// for already-expired tokens and capped at 12 h. If the token can't be parsed @@ -741,6 +844,7 @@ pub async fn fetch_provider_environment( let response = client .get_sandbox_provider_environment(GetSandboxProviderEnvironmentRequest { sandbox_id: sandbox_id.to_string(), + supports_static_credential_bindings: true, }) .await .into_diagnostic()?; @@ -751,9 +855,60 @@ pub async fn fetch_provider_environment( provider_env_revision: inner.provider_env_revision, credential_expires_at_ms: inner.credential_expires_at_ms, dynamic_credentials: inner.dynamic_credentials, + static_credential_bindings: inner.static_credential_bindings, + non_secret_environment_keys: inner.non_secret_environment_keys, + }) +} + +pub async fn exchange_provider_subject_token( + endpoint: &str, + sandbox_id: &str, + provider: &str, + credential_key: &str, + supervisor_jwt_svid: &str, +) -> Result { + debug!( + endpoint = %endpoint, + sandbox_id = %sandbox_id, + provider = %provider, + credential_key = %credential_key, + "Exchanging provider subject token through gateway" + ); + + let mut client = connect(endpoint).await?; + let response = client + .exchange_provider_subject_token(ExchangeProviderSubjectTokenRequest { + sandbox_id: sandbox_id.to_string(), + provider: provider.to_string(), + credential_key: credential_key.to_string(), + supervisor_jwt_svid: supervisor_jwt_svid.to_string(), + }) + .await + .map_err(provider_subject_token_exchange_status)?; + let inner = response.into_inner(); + Ok(ProviderSubjectTokenExchangeResult { + access_token: inner.access_token, + expires_in: inner.expires_in, + token_type: inner.token_type, }) } +fn provider_subject_token_exchange_status(status: Status) -> miette::Report { + let message = status.message(); + if message.is_empty() { + miette::miette!( + "gateway ExchangeProviderSubjectToken failed with status {}", + status.code() + ) + } else { + miette::miette!( + "gateway ExchangeProviderSubjectToken failed with status {}: {}", + status.code(), + message + ) + } +} + /// A reusable gRPC client for the `OpenShell` service. /// /// Wraps a tonic channel connected once and reused for policy polling @@ -762,6 +917,10 @@ pub async fn fetch_provider_environment( pub struct CachedOpenShellClient { client: OpenShellClient, workspace: Arc>, + /// Extension credentials for this supervisor. Cloning the client shares + /// the store, so the middleware registry and the polling loop that rotates + /// it observe the same slots. + extension_credentials: ExtensionCredentialStore, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -782,6 +941,8 @@ pub struct SettingsPollResult { pub workspace: String, /// Gateway-configured posture for rejected policy generations. pub policy_validation_failure_mode: crate::PolicyValidationFailureMode, + /// Whether the gateway can mint authenticated extension credentials. + pub extension_authentication_enabled: bool, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -801,6 +962,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin .policy_validation_failure_mode .parse() .unwrap_or_default(), + extension_authentication_enabled: inner.extension_authentication_enabled, } } @@ -833,6 +995,18 @@ mod settings_poll_tests { PolicyValidationFailureMode::FailClosed ); } + + #[test] + fn extension_authentication_capability_round_trips_and_defaults_disabled() { + let enabled = settings_poll_result(GetSandboxConfigResponse { + extension_authentication_enabled: true, + ..Default::default() + }); + assert!(enabled.extension_authentication_enabled); + + let legacy = settings_poll_result(GetSandboxConfigResponse::default()); + assert!(!legacy.extension_authentication_enabled); + } } pub struct ProviderEnvironmentResult { @@ -840,15 +1014,37 @@ pub struct ProviderEnvironmentResult { pub provider_env_revision: u64, pub credential_expires_at_ms: HashMap, pub dynamic_credentials: HashMap, + pub static_credential_bindings: HashMap, + pub non_secret_environment_keys: Vec, +} + +pub struct ProviderSubjectTokenExchangeResult { + pub access_token: String, + pub expires_in: i64, + pub token_type: String, } impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { + Self::connect_with_credentials(endpoint, ExtensionCredentialStore::new()).await + } + + /// Connect while sharing an existing credential store. + /// + /// The supervisor opens the gateway channel more than once (policy load, + /// then the polling loop). Both must observe the same slots, otherwise the + /// credentials handed to the middleware registry are not the ones the loop + /// rotates. + pub async fn connect_with_credentials( + endpoint: &str, + extension_credentials: ExtensionCredentialStore, + ) -> Result { debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling"); let client = connect(endpoint).await?; Ok(Self { client, workspace: Arc::new(tokio::sync::OnceCell::new()), + extension_credentials, }) } @@ -873,6 +1069,67 @@ impl CachedOpenShellClient { Ok(result) } + /// The credential store backing this connection's extension clients. + #[must_use] + pub fn extension_credentials(&self) -> &ExtensionCredentialStore { + &self.extension_credentials + } + + /// Return the slots for `services`, rotating only when one is missing or + /// due. + /// + /// Configuration polling runs far more often than credentials expire, so + /// rotating unconditionally would mint tokens and re-run gateway policy + /// authorization roughly ninety times per useful rotation. + pub async fn extension_credentials_for( + &self, + services: &[crate::proto::SupervisorMiddlewareService], + ) -> Result> { + let names = authenticated_service_names(services); + if names.is_empty() { + return Ok(HashMap::new()); + } + if !self.extension_credentials.needs_refresh(&names, now_ms()) + && let Some(slots) = self.extension_credentials.slots_for(&names) + { + return Ok(slots); + } + self.refresh_extension_credentials(services).await + } + + /// Rotate credentials for `services` unconditionally. + pub async fn refresh_extension_credentials( + &self, + services: &[crate::proto::SupervisorMiddlewareService], + ) -> Result> { + let mut client = self.client.clone(); + refresh_extension_credentials_with_client( + &mut client, + &self.extension_credentials, + services, + ) + .await + } + + /// Rotate every credential currently retained by the installed registry. + /// This remains available when configuration polling fails independently. + pub async fn refresh_installed_extension_credentials(&self) -> Result<()> { + let names = self.extension_credentials.names(); + if names.is_empty() || !self.extension_credentials.needs_refresh(&names, now_ms()) { + return Ok(()); + } + let services = names + .into_iter() + .map(|name| crate::proto::SupervisorMiddlewareService { + name, + ..Default::default() + }) + .collect::>(); + self.refresh_extension_credentials(&services) + .await + .map(drop) + } + /// Returns the workspace learned from the server, or empty if not yet polled. pub fn workspace(&self) -> String { self.workspace.get().cloned().unwrap_or_default() diff --git a/crates/openshell-core/src/host_pattern.rs b/crates/openshell-core/src/host_pattern.rs index f5d9a099a0..5ded1b21c8 100644 --- a/crates/openshell-core/src/host_pattern.rs +++ b/crates/openshell-core/src/host_pattern.rs @@ -12,13 +12,13 @@ use std::collections::{HashSet, VecDeque}; /// semantics mirror the Rego endpoint glob matching used for network policy /// admission (`glob.match` with a `.` delimiter), so a pattern copied from a /// network endpoint selects exactly the hosts that endpoint admits. -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct HostPattern { source: String, labels: Vec, } -#[derive(Clone)] +#[derive(Debug, Clone)] enum HostLabelPattern { Recursive, Label { @@ -130,13 +130,18 @@ impl HostPattern { #[must_use] pub fn matches(&self, host: &str) -> bool { let host = host.to_ascii_lowercase(); - if host.split('.').any(str::is_empty) { - return false; - } - self.matches_labels(&host.split('.').collect::>()) + self.matches_normalized_labels(&host.split('.').collect::>()) } - fn matches_labels(&self, host: &[&str]) -> bool { + /// Match pre-split lowercase DNS labels. + /// + /// This avoids repeating request-host normalization when several compiled + /// patterns are evaluated against the same destination. + #[must_use] + pub(crate) fn matches_normalized_labels(&self, host: &[&str]) -> bool { + if host.iter().any(|label| label.is_empty()) { + return false; + } let mut pending = vec![(0, 0)]; let mut visited = HashSet::new(); while let Some((pattern_idx, host_idx)) = pending.pop() { diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 56ffda38c4..7acb72dd6f 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -12,10 +12,15 @@ pub mod activity; pub mod auth; pub mod config; +pub mod container_paths; pub mod denial; pub mod driver_mounts; pub mod driver_utils; +pub mod dynamic_string_allowlist; +pub mod endpoint_path; pub mod error; +#[cfg(unix)] +pub mod external_driver_socket; pub mod forward; pub mod google_cloud; pub mod gpu; @@ -24,9 +29,12 @@ pub mod host_pattern; pub mod image; pub mod inference; pub mod jwt; +pub mod local_api_socket; pub mod metadata; pub mod middleware; pub mod net; +#[cfg(feature = "oauth")] +pub mod oauth; pub mod paths; pub mod policy; pub mod progress; @@ -37,16 +45,19 @@ pub mod provider_credentials; pub mod sandbox_env; pub mod secrets; pub mod settings; +pub mod shell; +pub mod spiffe; pub mod telemetry; pub mod time; pub mod transport_errors; pub use config::{ - ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, - GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, - MtlsAuthConfig, OidcConfig, PolicyValidationFailureMode, TlsConfig, + Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, + PolicyValidationFailureMode, TlsConfig, }; +pub use dynamic_string_allowlist::DynamicStringAllowlist; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{ GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, @@ -54,15 +65,19 @@ pub use metadata::{ /// Build version string derived from git metadata. /// -/// For local builds this is computed by `build.rs` via `git describe` using -/// the guess-next-dev scheme (e.g. `0.0.4-dev.6+g2bf9969`). In Docker/CI -/// builds where `.git` is absent, falls back to `CARGO_PKG_VERSION` which -/// is already set correctly by the build pipeline's sed patch. +/// For local builds this is computed by `build.rs` from the exact release tag +/// or the latest merged stable tag using the guess-next-dev scheme (e.g. +/// `0.0.4-dev.6+g2bf9969ab`). In Docker/CI builds where `.git` is absent, it +/// falls back to `CARGO_PKG_VERSION`, which the build pipeline already stamps. pub const VERSION: &str = match option_env!("OPENSHELL_GIT_VERSION") { Some(v) => v, None => env!("CARGO_PKG_VERSION"), }; +#[cfg(test)] +#[path = "../build_version.rs"] +mod build_version; + /// Encoded protobuf `FileDescriptorSet` for every proto in `proto/`. /// /// Emitted by `build.rs` via `tonic_build::configure().file_descriptor_set_path(...)`. diff --git a/crates/openshell-core/src/local_api_socket.rs b/crates/openshell-core/src/local_api_socket.rs new file mode 100644 index 0000000000..6804ae513a --- /dev/null +++ b/crates/openshell-core/src/local_api_socket.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generic discovery and probing for local HTTP APIs over Unix sockets. + +use std::path::{Path, PathBuf}; + +/// Return the first candidate whose HTTP ping response is accepted. +#[must_use] +pub fn first_responsive_socket( + candidates: &[PathBuf], + accepts_response: impl Fn(&[u8]) -> bool, +) -> Option { + candidates + .iter() + .find(|path| socket_responds(path, &accepts_response)) + .cloned() +} + +/// Return whether a byte slice contains another, ignoring ASCII case. +#[must_use] +pub fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +/// Return whether an HTTP response starts with a successful status line. +#[must_use] +pub fn http_response_is_success(response: &[u8]) -> bool { + response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200") +} + +#[cfg(unix)] +fn socket_responds(path: &Path, accepts_response: &impl Fn(&[u8]) -> bool) -> bool { + use std::io::{Read as _, Write as _}; + use std::os::unix::fs::FileTypeExt as _; + use std::time::Duration; + + const PROBE_TIMEOUT: Duration = Duration::from_secs(1); + const PING_REQUEST: &[u8] = + b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + + if !path + .metadata() + .is_ok_and(|metadata| metadata.file_type().is_socket()) + { + return false; + } + let Ok(mut stream) = std::os::unix::net::UnixStream::connect(path) else { + return false; + }; + if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err() + || stream.write_all(PING_REQUEST).is_err() + { + return false; + } + + let mut response = [0_u8; 512]; + let mut total = 0; + while total < response.len() { + let Ok(read) = stream.read(&mut response[total..]) else { + return false; + }; + if read == 0 { + break; + } + total += read; + if contains_ascii(&response[..total], b"\r\n\r\n") { + break; + } + } + total > 0 && accepts_response(&response[..total]) +} + +#[cfg(not(unix))] +fn socket_responds(path: &Path, accepts_response: &impl Fn(&[u8]) -> bool) -> bool { + let _ = (path, accepts_response); + false +} diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index 8794c11d5d..f885812c4e 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -6,8 +6,9 @@ //! These traits provide uniform access to `ObjectMeta` fields across all resource types. use crate::proto::{ - InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession, - StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, WorkspaceMember, + InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, SandboxWorkloadTemplate, + ServiceEndpoint, SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, + Workspace, WorkspaceMember, }; use std::collections::HashMap; @@ -104,6 +105,49 @@ impl Sandbox { } } +// Implementations for SandboxWorkloadTemplate +impl ObjectId for SandboxWorkloadTemplate { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for SandboxWorkloadTemplate { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for SandboxWorkloadTemplate { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for SandboxWorkloadTemplate { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for SandboxWorkloadTemplate { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for SandboxWorkloadTemplate { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + + fn requires_workspace() -> bool { + true + } +} + // Implementations for Workspace impl ObjectId for Workspace { fn object_id(&self) -> &str { diff --git a/crates/openshell-core/src/middleware.rs b/crates/openshell-core/src/middleware.rs index 75ed66003b..2b3fb18982 100644 --- a/crates/openshell-core/src/middleware.rs +++ b/crates/openshell-core/src/middleware.rs @@ -1,16 +1,267 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Platform-wide supervisor middleware limits. +//! Supervisor middleware contracts and platform-wide limits. +use std::pin::Pin; use std::time::Duration; +use miette::Result; +use tokio::sync::mpsc; +use tonic::{Request, Response, Status}; + +use crate::proto::{ + HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, MiddlewareManifest, + RequestContext, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, + WebSocketSessionEvent, WebSocketSessionEventResult, +}; + +/// Transport-neutral response stream for one WebSocket middleware stage. +pub type WebSocketResponseStream = Pin< + Box< + dyn tokio_stream::Stream> + + Send + + 'static, + >, +>; + +/// A middleware implementation reachable either in-process or over a transport. +/// +/// Capability is defined by the implementation's manifest. The endpoint hides +/// whether invocations are direct calls or serialized gRPC requests. +#[tonic::async_trait] +pub trait SupervisorMiddlewareEndpoint: Send + Sync { + async fn describe(&self, request: Request<()>) -> Result, Status>; + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status>; + + async fn evaluate_http_request( + &self, + request: Request, + ) -> Result, Status>; + + async fn open_websocket_session( + &self, + requests: mpsc::Receiver, + ) -> Result; +} + +/// Borrowed request state exposed to one in-process middleware invocation. +/// +/// The view reflects every transformation applied by earlier stages. It is valid +/// only for the current invocation and cannot be retained by the middleware. +#[derive(Clone, Copy)] +pub struct HttpRequestView<'a> { + phase: SupervisorMiddlewarePhase, + context: &'a RequestContext, + config: &'a prost_types::Struct, + target: &'a HttpRequestTarget, + headers: &'a [HttpHeader], + body: &'a [u8], + middleware_name: &'a str, +} + +impl<'a> HttpRequestView<'a> { + /// Create a view over the chain's current request state for one stage. + #[must_use] + pub fn new( + phase: SupervisorMiddlewarePhase, + context: &'a RequestContext, + config: &'a prost_types::Struct, + target: &'a HttpRequestTarget, + headers: &'a [HttpHeader], + body: &'a [u8], + middleware_name: &'a str, + ) -> Self { + Self { + phase, + context, + config, + target, + headers, + body, + middleware_name, + } + } + + /// Return the typed middleware phase selected for this invocation. + #[must_use] + pub fn phase(self) -> SupervisorMiddlewarePhase { + self.phase + } + + /// Return the request and sandbox identity shared by every chain stage. + #[must_use] + pub fn context(self) -> &'a RequestContext { + self.context + } + + /// Return the validated configuration for this policy-selected stage. + #[must_use] + pub fn config(self) -> &'a prost_types::Struct { + self.config + } + + /// Return the admitted destination and HTTP request target. + #[must_use] + pub fn target(self) -> &'a HttpRequestTarget { + self.target + } + + /// Return visible request headers in wire order, including repeated names. + #[must_use] + pub fn headers(self) -> &'a [HttpHeader] { + self.headers + } + + /// Return the current body, including replacements made by earlier stages. + #[must_use] + pub fn body(self) -> &'a [u8] { + self.body + } + + /// Return the in-process middleware manifest or attachment name selected by + /// policy, including custom implementation names. + #[must_use] + pub fn middleware_name(self) -> &'a str { + self.middleware_name + } +} + +/// Asynchronous contract for supervisor middleware that runs in-process. +/// +/// Remote services use the protobuf `SupervisorMiddleware` contract instead. +/// The borrowed view remains valid for the evaluation future, so implementations +/// can yield without constructing an owned protobuf request envelope. +/// WebSocket sessions already use bounded channel and stream ownership, so that +/// operation is shared with the transport-neutral endpoint contract. +/// +/// Downstream implementations must apply `#[async_trait::async_trait]` to each +/// `impl InProcessMiddleware` block. The macro's default expansion creates +/// `Send` futures, matching this trait's generated method signatures; do not +/// use the `?Send` form. +/// +/// An implementing crate must declare `async-trait` as a direct dependency +/// because `openshell-core`'s dependency does not make the procedural macro +/// available in downstream source. The example also names `miette` and +/// `prost-types`, so standalone crates must declare those dependencies when +/// using those paths. +/// +/// # Examples +/// +/// ``` +/// use std::sync::Arc; +/// +/// use miette::Result; +/// use openshell_core::middleware::{HttpRequestView, InProcessMiddleware}; +/// use openshell_core::proto::{ +/// Decision, HttpRequestResult, MiddlewareBinding, MiddlewareManifest, +/// SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, +/// }; +/// use prost_types::Struct; +/// +/// struct Service; +/// +/// #[async_trait::async_trait] +/// impl InProcessMiddleware for Service { +/// async fn describe(&self) -> MiddlewareManifest { +/// MiddlewareManifest { +/// name: "example/audit".into(), +/// service_version: "1".into(), +/// bindings: vec![MiddlewareBinding { +/// operation: SupervisorMiddlewareOperation::HttpRequest as i32, +/// phase: SupervisorMiddlewarePhase::PreCredentials as i32, +/// max_payload_bytes: 1024, +/// timeout: String::new(), +/// }], +/// expected_audience: String::new(), +/// } +/// } +/// +/// async fn validate_config( +/// &self, +/// _middleware_name: &str, +/// _config: &Struct, +/// ) -> Result<()> { +/// Ok(()) +/// } +/// +/// async fn evaluate_http_request( +/// &self, +/// _request: HttpRequestView<'_>, +/// ) -> Result { +/// Ok(HttpRequestResult { +/// decision: Decision::Allow as i32, +/// ..Default::default() +/// }) +/// } +/// } +/// +/// let service: Arc = Arc::new(Service); +/// assert_eq!(Arc::strong_count(&service), 1); +/// ``` +#[async_trait::async_trait] +pub trait InProcessMiddleware: Send + Sync { + /// Return the immutable manifest describing this implementation. + async fn describe(&self) -> MiddlewareManifest; + + /// Validate implementation-owned configuration for one policy attachment. + /// + /// # Errors + /// + /// Returns an error when the implementation name is unknown or the + /// configuration is malformed or unsupported. + async fn validate_config( + &self, + middleware_name: &str, + config: &prost_types::Struct, + ) -> Result<()>; + + /// Evaluate one request using borrowed chain state. + /// + /// # Errors + /// + /// Returns an error when the selected implementation cannot evaluate the + /// request or its validated configuration. + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> Result; + + /// Open one persistent WebSocket middleware session. + /// + /// HTTP-only implementations may keep the default unsupported response. + async fn open_websocket_session( + &self, + _requests: mpsc::Receiver, + ) -> std::result::Result { + Err(Status::unimplemented( + "middleware does not implement WebSocket sessions", + )) + } +} + /// Default timeout for one supervisor middleware RPC. pub const DEFAULT_MIDDLEWARE_TIMEOUT: Duration = Duration::from_millis(500); /// Smallest operator-configured supervisor middleware RPC timeout. pub const MIN_MIDDLEWARE_TIMEOUT: Duration = Duration::from_millis(10); /// Largest operator-configured supervisor middleware RPC timeout. pub const MAX_MIDDLEWARE_TIMEOUT: Duration = Duration::from_secs(30); +/// Maximum time a complete message may spend in all middleware stages. +pub const MAX_MIDDLEWARE_CHAIN_TIMEOUT: Duration = Duration::from_secs(30); +/// Maximum time WebSocket preflight may delay an upstream handshake. +pub const MAX_MIDDLEWARE_PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(1); +/// Process-wide safety valve for concurrently buffered middleware work. +pub const MAX_CONCURRENT_MIDDLEWARE_WORK: usize = 32; +/// Process-wide safety valve for retained streaming middleware sessions. +/// +/// A session consumes one permit regardless of its protocol or stage fan-out. +/// Persistent middleware protocols must acquire from this shared budget before +/// opening streams and retain the permit while any stage remains active. +pub const MAX_CONCURRENT_MIDDLEWARE_SESSIONS: usize = 32; /// Largest number of middleware configurations accepted in one sandbox policy. pub const MAX_MIDDLEWARE_CONFIGS: usize = 10; diff --git a/crates/openshell-core/src/net.rs b/crates/openshell-core/src/net.rs index 3f14a397b8..a9bbc23217 100644 --- a/crates/openshell-core/src/net.rs +++ b/crates/openshell-core/src/net.rs @@ -1,17 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Network IP classification utilities shared across `OpenShell` crates. +//! Shared networking utilities for `OpenShell` crates. //! -//! These helpers enforce the always-blocked IP invariant (loopback, link-local, -//! unspecified) and the broader internal-IP classification (adds RFC 1918 and -//! ULA). They are used by: +//! The IP-classification helpers enforce the always-blocked IP invariant +//! (loopback, link-local, unspecified) and the broader internal-IP +//! classification (adds RFC 1918 and ULA). They are used by: //! - The sandbox proxy for runtime SSRF enforcement //! - The mechanistic mapper for proposal filtering //! - The gateway server for defense-in-depth validation on approval +//! +//! The socket tuning helpers ([`set_tcp_nodelay_best_effort`], +//! [`connect_tcp_nodelay_best_effort`]) help avoid the known latency +//! imposed by conflict between Nagle's algorithm and delayed ACK behaviors. +//! use ipnet::{IpNet, Ipv4Net, Ipv6Net}; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use tokio::net::TcpStream; /// Check if a hostname is a known cloud metadata hostname that resolves to an /// always-blocked metadata service. @@ -279,6 +285,27 @@ fn is_internal_v4(v4: Ipv4Addr) -> bool { false } +/// Enable `TCP_NODELAY` on a stream, logging (not returning) any failure. +/// +/// Disabling Nagle's algorithm keeps small writes from waiting on delayed ACKs. +/// It's a latency optimization: if it fails the connection still works, just a +/// bit slower, so there is nothing for the caller to act on — we log and move on. +pub fn set_tcp_nodelay_best_effort(stream: &TcpStream) { + if let Err(e) = stream.set_nodelay(true) { + tracing::debug!(error = %e, "failed to set TCP_NODELAY"); + } +} + +/// Connect to `addrs`, then enable `TCP_NODELAY` on a best-effort basis, propagating +/// any errors from `TcpStream::connect`. +/// +/// The returned stream is not *guaranteed* to have `TCP_NODELAY` set. +pub async fn connect_tcp_nodelay_best_effort(addrs: &[SocketAddr]) -> std::io::Result { + let stream = TcpStream::connect(addrs).await?; + set_tcp_nodelay_best_effort(&stream); + Ok(stream) +} + #[cfg(test)] mod tests { use super::*; @@ -699,4 +726,31 @@ mod tests { let v6 = Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped(); assert!(is_internal_ip(IpAddr::V6(v6))); } + + // -- tcp_nodelay helpers -- + + #[tokio::test] + async fn set_tcp_nodelay_best_effort_enables_nodelay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let stream = TcpStream::connect(addr).await.expect("connect"); + + set_tcp_nodelay_best_effort(&stream); + assert!(stream.nodelay().expect("query TCP_NODELAY")); + } + + #[tokio::test] + async fn connect_tcp_nodelay_best_effort_sets_nodelay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let stream = connect_tcp_nodelay_best_effort(&[addr]) + .await + .expect("connect"); + assert!(stream.nodelay().expect("query TCP_NODELAY")); + } } diff --git a/crates/openshell-core/src/oauth.rs b/crates/openshell-core/src/oauth.rs new file mode 100644 index 0000000000..c68ecfa6b4 --- /dev/null +++ b/crates/openshell-core/src/oauth.rs @@ -0,0 +1,766 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared `OAuth2` token request helpers. +//! +//! Provides [`post_oauth_token_grant`] (client credentials) and +//! [`post_oauth_token_exchange`] (RFC 8693 token exchange) — typed functions +//! that assemble form parameters, POST to a validated `OAuth2` token endpoint, +//! parse the JSON response, and validate the returned access token. + +use std::net::IpAddr; + +use miette::{IntoDiagnostic, Result, WrapErr}; +use serde::Deserialize; + +pub const DEFAULT_CLIENT_ASSERTION_TYPE: &str = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; +pub const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; +const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; + +const MAX_OAUTH_ERROR_FIELD_LEN: usize = 256; + +/// `OAuth2` token response. +#[derive(Debug, Clone)] +pub struct OAuthTokenResponse { + pub access_token: String, + pub expires_in: i64, + pub token_type: String, +} + +#[derive(Debug, Deserialize)] +struct RawTokenResponse { + access_token: String, + #[serde(default)] + expires_in: i64, + #[serde(default)] + token_type: String, +} + +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: Option, + error_description: Option, +} + +async fn post_oauth_token_request( + client: &reqwest::Client, + token_endpoint: &str, + mut form_params: Vec<(&str, &str)>, + audience: &str, + scopes: &[String], +) -> Result { + let token_endpoint_url = parse_token_endpoint_url(token_endpoint)?; + + let audience_param; + if !audience.is_empty() { + audience_param = audience.to_string(); + form_params.push(("audience", &audience_param)); + } + + let scope_param; + if !scopes.is_empty() { + scope_param = scopes.join(" "); + form_params.push(("scope", &scope_param)); + } + + let response = client + .post(token_endpoint_url) + .form(&form_params) + .send() + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to POST to token endpoint {token_endpoint}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(miette::miette!("{}", failure_message(status, &body))); + } + + let raw = response + .json::() + .await + .into_diagnostic() + .wrap_err("failed to parse token response as JSON")?; + validate_access_token(&raw.access_token)?; + Ok(OAuthTokenResponse { + access_token: raw.access_token, + expires_in: raw.expires_in, + token_type: raw.token_type, + }) +} + +/// Client credentials grant form fields. +pub struct TokenGrantParams<'a> { + pub client_assertion: &'a str, + pub client_assertion_type: &'a str, + pub audience: &'a str, + pub scopes: &'a [String], +} + +/// POST a client credentials grant request to an `OAuth2` token endpoint. +/// +/// Applies the default for `client_assertion_type` and conditionally includes +/// `audience` and `scope`. +pub async fn post_oauth_token_grant( + client: &reqwest::Client, + token_endpoint: &str, + params: &TokenGrantParams<'_>, +) -> Result { + let client_assertion_type = effective_client_assertion_type(params.client_assertion_type); + let form_params = vec![ + ("grant_type", "client_credentials"), + ("client_assertion_type", client_assertion_type), + ("client_assertion", params.client_assertion), + ]; + post_oauth_token_request( + client, + token_endpoint, + form_params, + params.audience, + params.scopes, + ) + .await +} + +/// RFC 8693 token-exchange form fields. +pub struct TokenExchangeParams<'a> { + pub client_assertion: &'a str, + pub client_assertion_type: &'a str, + pub subject_token: &'a str, + pub subject_token_type: &'a str, + pub audience: &'a str, + pub scopes: &'a [String], + pub requested_token_type: &'a str, +} + +/// POST an RFC 8693 token-exchange request to an `OAuth2` token endpoint. +/// +/// Assembles the standard token-exchange form fields, applies defaults for +/// `client_assertion_type`, `subject_token_type`, and `requested_token_type`, +/// and conditionally includes `audience` and `scope`. +pub async fn post_oauth_token_exchange( + client: &reqwest::Client, + token_endpoint: &str, + params: &TokenExchangeParams<'_>, +) -> Result { + let client_assertion_type = effective_client_assertion_type(params.client_assertion_type); + let subject_token_type = effective_token_type(params.subject_token_type); + let requested_token_type = effective_token_type(params.requested_token_type); + let form_params = vec![ + ("grant_type", TOKEN_EXCHANGE_GRANT_TYPE), + ("client_assertion_type", client_assertion_type), + ("client_assertion", params.client_assertion), + ("subject_token", params.subject_token), + ("subject_token_type", subject_token_type), + ("requested_token_type", requested_token_type), + ]; + post_oauth_token_request( + client, + token_endpoint, + form_params, + params.audience, + params.scopes, + ) + .await +} + +pub fn effective_client_assertion_type(client_assertion_type: &str) -> &str { + if client_assertion_type.trim().is_empty() { + DEFAULT_CLIENT_ASSERTION_TYPE + } else { + client_assertion_type + } +} + +pub fn effective_token_type(token_type: &str) -> &str { + if token_type.trim().is_empty() { + ACCESS_TOKEN_TYPE + } else { + token_type + } +} + +fn parse_token_endpoint_url(token_endpoint: &str) -> Result { + let url = reqwest::Url::parse(token_endpoint) + .into_diagnostic() + .wrap_err("token_endpoint must be an absolute URL")?; + if token_endpoint_transport_allowed(&url) { + return Ok(url); + } + Err(miette::miette!( + "token_endpoint must use https, except http for loopback or in-cluster service hosts" + )) +} + +fn token_endpoint_transport_allowed(url: &reqwest::Url) -> bool { + match url.scheme() { + "https" => true, + "http" => url + .host_str() + .is_some_and(|host| is_loopback_host(host) || is_kubernetes_service_host(host)), + _ => false, + } +} + +fn is_loopback_host(host: &str) -> bool { + let host = host.trim_matches(['[', ']']); + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.parse::() { + Ok(IpAddr::V4(v4)) => v4.is_loopback(), + Ok(IpAddr::V6(v6)) => { + v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + Err(_) => false, + } +} + +fn is_kubernetes_service_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let labels = host.split('.').collect::>(); + let is_service_name = labels.len() == 3 && labels[2] == "svc"; + let is_cluster_local_service = + labels.len() == 5 && labels[2] == "svc" && labels[3] == "cluster" && labels[4] == "local"; + (is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty()) +} + +pub fn validate_access_token(token: &str) -> Result<()> { + if token.is_empty() || !is_token68(token) { + return Err(miette::miette!( + "token grant returned a malformed access token" + )); + } + Ok(()) +} + +fn is_token68(token: &str) -> bool { + let mut padding_started = false; + let mut saw_value = false; + for byte in token.bytes() { + if byte == b'=' { + padding_started = true; + continue; + } + if padding_started || !is_token68_value_byte(byte) { + return false; + } + saw_value = true; + } + saw_value +} + +fn is_token68_value_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/') +} + +fn failure_message(status: reqwest::StatusCode, body: &str) -> String { + let Ok(error_response) = serde_json::from_str::(body) else { + return format!("token grant failed with status {status}"); + }; + let error = error_response + .error + .as_deref() + .map(sanitize_oauth_error_field) + .filter(|value| !value.is_empty()); + let description = error_response + .error_description + .as_deref() + .map(sanitize_oauth_error_field) + .filter(|value| !value.is_empty()); + match (error, description) { + (Some(error), Some(description)) => { + format!( + "token grant failed with status {status}: error={error}; error_description={description}" + ) + } + (Some(error), None) => { + format!("token grant failed with status {status}: error={error}") + } + (None, Some(description)) => { + format!("token grant failed with status {status}: error_description={description}") + } + (None, None) => format!("token grant failed with status {status}"), + } +} + +fn sanitize_oauth_error_field(value: &str) -> String { + value + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .take(MAX_OAUTH_ERROR_FIELD_LEN) + .collect::() + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + #[derive(Debug)] + struct CapturedRequest { + form: HashMap, + } + + fn test_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("test client") + } + + async fn token_endpoint_once( + status: &str, + body: &str, + ) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token endpoint"); + let addr = listener.local_addr().expect("token endpoint local addr"); + let status = status.to_string(); + let body = body.to_string(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut buf = Vec::new(); + let mut chunk = [0u8; 512]; + let mut expected_len = None; + loop { + let n = stream.read(&mut chunk).await.expect("read"); + assert!(n > 0); + buf.extend_from_slice(&chunk[..n]); + if expected_len.is_none() + && let Some(header_end) = header_end(&buf) + { + let headers = String::from_utf8_lossy(&buf[..header_end]); + let cl = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + expected_len = Some(header_end + cl); + } + if expected_len.is_some_and(|len| buf.len() >= len) { + break; + } + } + let header_end = header_end(&buf).unwrap(); + let form_body = String::from_utf8_lossy(&buf[header_end..]).to_string(); + let form = parse_form_body(&form_body); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).await.expect("write"); + CapturedRequest { form } + }); + (format!("http://{addr}/token"), handle) + } + + async fn token_endpoint_redirect_once(location: &str) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token endpoint"); + let addr = listener.local_addr().expect("token endpoint local addr"); + let location = location.to_string(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut buf = vec![0u8; 512]; + let _ = stream.read(&mut buf).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + stream.write_all(response.as_bytes()).await.expect("write"); + }); + (format!("http://{addr}/token"), handle) + } + + fn header_end(buf: &[u8]) -> Option { + buf.windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|idx| idx + 4) + } + + fn parse_form_body(body: &str) -> HashMap { + body.split('&') + .filter(|part| !part.is_empty()) + .filter_map(|part| { + let (name, value) = part.split_once('=')?; + Some((decode_form_component(name), decode_form_component(value))) + }) + .collect() + } + + fn decode_form_component(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut idx = 0; + while idx < bytes.len() { + match bytes[idx] { + b'+' => { + decoded.push(b' '); + idx += 1; + } + b'%' if idx + 2 < bytes.len() => { + let hex = &value[idx + 1..idx + 3]; + if let Ok(byte) = u8::from_str_radix(hex, 16) { + decoded.push(byte); + idx += 3; + } else { + decoded.push(bytes[idx]); + idx += 1; + } + } + byte => { + decoded.push(byte); + idx += 1; + } + } + } + String::from_utf8(decoded).expect("form body should be UTF-8") + } + + fn empty_grant_params() -> TokenGrantParams<'static> { + TokenGrantParams { + client_assertion: "jwt-svid-token", + client_assertion_type: "", + audience: "", + scopes: &[], + } + } + + #[tokio::test] + async fn posts_form_params_and_parses_success_response() { + let (endpoint, request) = token_endpoint_once( + "200 OK", + r#"{"access_token":"access-123","token_type":"Bearer","expires_in":42}"#, + ) + .await; + let client = test_client(); + + let response = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect("should succeed"); + let request = request.await.expect("endpoint task"); + + assert_eq!(response.access_token, "access-123"); + assert_eq!(response.expires_in, 42); + assert_eq!(response.token_type, "Bearer"); + assert_eq!( + request.form.get("grant_type").map(String::as_str), + Some("client_credentials") + ); + assert_eq!( + request.form.get("client_assertion").map(String::as_str), + Some("jwt-svid-token") + ); + } + + #[tokio::test] + async fn rejects_malformed_access_token() { + let (endpoint, request) = token_endpoint_once( + "200 OK", + r#"{"access_token":"access-123\r\nX-Injected: yes"}"#, + ) + .await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("malformed token should fail"); + let _ = request.await; + + assert_eq!( + err.to_string(), + "token grant returned a malformed access token" + ); + } + + #[tokio::test] + async fn does_not_follow_redirects() { + let (endpoint, handle) = token_endpoint_redirect_once("http://127.0.0.1:1/stolen").await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("redirect should fail"); + let _ = handle.await; + + assert_eq!(err.to_string(), "token grant failed with status 302 Found"); + } + + #[tokio::test] + async fn reports_sanitized_oauth_error() { + let (endpoint, request) = token_endpoint_once( + "401 Unauthorized", + r#"{"error":"invalid_client","error_description":"bad assertion"}"#, + ) + .await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("should fail on OAuth error"); + let _ = request.await; + + assert_eq!( + err.to_string(), + "token grant failed with status 401 Unauthorized: error=invalid_client; error_description=bad assertion" + ); + } + + #[tokio::test] + async fn does_not_echo_unstructured_error_body() { + let (endpoint, request) = token_endpoint_once( + "500 Internal Server Error", + "internal stack trace with implementation details", + ) + .await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("should fail on server error"); + let _ = request.await; + let message = err.to_string(); + + assert_eq!( + message, + "token grant failed with status 500 Internal Server Error" + ); + assert!(!message.contains("stack trace")); + } + + #[tokio::test] + async fn reports_malformed_success_json() { + let (endpoint, request) = token_endpoint_once("200 OK", r#"{"access_token":42"#).await; + let client = test_client(); + + let err = post_oauth_token_grant(&client, &endpoint, &empty_grant_params()) + .await + .expect_err("should fail on malformed JSON"); + let _ = request.await; + + assert!( + err.to_string() + .contains("failed to parse token response as JSON") + ); + } + + #[tokio::test] + async fn token_exchange_posts_rfc8693_form_fields() { + let (endpoint, request) = token_endpoint_once( + "200 OK", + r#"{"access_token":"exchanged-token","token_type":"Bearer","expires_in":60}"#, + ) + .await; + let client = test_client(); + let scopes = vec!["read".to_string(), "write".to_string()]; + + let response = post_oauth_token_exchange( + &client, + &endpoint, + &TokenExchangeParams { + client_assertion: "jwt-svid-token", + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + subject_token: "subject-access-token", + subject_token_type: ACCESS_TOKEN_TYPE, + audience: "api://resource", + scopes: &scopes, + requested_token_type: "urn:ietf:params:oauth:token-type:id_token", + }, + ) + .await + .expect("token exchange should succeed"); + let request = request.await.expect("endpoint task"); + + assert_eq!(response.access_token, "exchanged-token"); + assert_eq!(response.expires_in, 60); + assert_eq!( + request.form.get("grant_type").map(String::as_str), + Some(TOKEN_EXCHANGE_GRANT_TYPE) + ); + assert_eq!( + request + .form + .get("client_assertion_type") + .map(String::as_str), + Some("urn:ietf:params:oauth:client-assertion-type:jwt-spiffe") + ); + assert_eq!( + request.form.get("client_assertion").map(String::as_str), + Some("jwt-svid-token") + ); + assert_eq!( + request.form.get("subject_token").map(String::as_str), + Some("subject-access-token") + ); + assert_eq!( + request.form.get("subject_token_type").map(String::as_str), + Some(ACCESS_TOKEN_TYPE) + ); + assert_eq!( + request.form.get("audience").map(String::as_str), + Some("api://resource") + ); + assert_eq!( + request.form.get("scope").map(String::as_str), + Some("read write") + ); + assert_eq!( + request.form.get("requested_token_type").map(String::as_str), + Some("urn:ietf:params:oauth:token-type:id_token") + ); + } + + #[tokio::test] + async fn token_exchange_applies_defaults_for_empty_type_fields() { + let (endpoint, request) = + token_endpoint_once("200 OK", r#"{"access_token":"exchanged-token"}"#).await; + let client = test_client(); + + post_oauth_token_exchange( + &client, + &endpoint, + &TokenExchangeParams { + client_assertion: "jwt-svid-token", + client_assertion_type: "", + subject_token: "subject-token", + subject_token_type: "", + audience: "", + scopes: &[], + requested_token_type: "", + }, + ) + .await + .expect("token exchange should succeed"); + let request = request.await.expect("endpoint task"); + + assert_eq!( + request + .form + .get("client_assertion_type") + .map(String::as_str), + Some(DEFAULT_CLIENT_ASSERTION_TYPE) + ); + assert_eq!( + request.form.get("subject_token_type").map(String::as_str), + Some(ACCESS_TOKEN_TYPE) + ); + assert_eq!( + request.form.get("requested_token_type").map(String::as_str), + Some(ACCESS_TOKEN_TYPE) + ); + assert!(!request.form.contains_key("audience")); + assert!(!request.form.contains_key("scope")); + } + + #[test] + fn token_endpoint_url_allows_https_loopback_and_in_cluster_http() { + for endpoint in [ + "https://auth.example.com/token", + "http://127.0.0.1:8080/token", + "http://[::1]:8080/token", + "http://token-issuer.default.svc.cluster.local/token", + "http://token-issuer.default.svc/token", + ] { + parse_token_endpoint_url(endpoint).expect("should be allowed"); + } + } + + #[test] + fn token_endpoint_url_rejects_plain_http_non_cluster_hosts() { + for endpoint in [ + "http://auth.example.com/token", + "http://keycloak/realms/openshell/protocol/openid-connect/token", + "http://token-issuer.default.svc.evil.com/token", + "ftp://auth.example.com/token", + "/relative/token", + ] { + assert!( + parse_token_endpoint_url(endpoint).is_err(), + "should be rejected: {endpoint}" + ); + } + } + + #[test] + fn validate_access_token_accepts_token68_values() { + for token in [ + "abcXYZ123-._~+/", + "eyJhbGciOiJSUzI1NiJ9.payload.sig", + "token==", + ] { + validate_access_token(token).expect("should be accepted"); + } + } + + #[test] + fn validate_access_token_rejects_non_token68_values() { + for token in [ + "", + "token with spaces", + "token\r\nX-Injected: yes", + "token\u{7f}", + "tokené", + "token=continued", + "==", + ] { + let err = validate_access_token(token).expect_err("should be rejected"); + assert_eq!( + err.to_string(), + "token grant returned a malformed access token" + ); + } + } + + #[test] + fn failure_message_reports_oauth_error_fields() { + let message = failure_message( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":"invalid_client","error_description":"Invalid client credentials"}"#, + ); + assert_eq!( + message, + "token grant failed with status 401 Unauthorized: error=invalid_client; error_description=Invalid client credentials" + ); + } + + #[test] + fn failure_message_omits_unstructured_response_body() { + let message = failure_message( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "internal error containing implementation details", + ); + assert_eq!( + message, + "token grant failed with status 500 Internal Server Error" + ); + } + + #[test] + fn failure_message_sanitizes_oauth_error_fields() { + let long_description = "a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 20); + let body = + format!(r#"{{"error":"invalid_client\n","error_description":"{long_description}"}}"#); + let message = failure_message(reqwest::StatusCode::UNAUTHORIZED, &body); + assert!(!message.contains('\n')); + assert!(message.contains("error=invalid_client")); + assert!(message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN))); + assert!(!message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 1))); + } +} diff --git a/crates/openshell-core/src/paths.rs b/crates/openshell-core/src/paths.rs index 9445347c79..2501958270 100644 --- a/crates/openshell-core/src/paths.rs +++ b/crates/openshell-core/src/paths.rs @@ -20,6 +20,10 @@ pub fn xdg_config_dir() -> Result { if let Ok(path) = std::env::var("XDG_CONFIG_HOME") { return Ok(PathBuf::from(path)); } + #[cfg(target_os = "windows")] + if let Ok(path) = std::env::var("APPDATA") { + return Ok(PathBuf::from(path)); + } let home = std::env::var("HOME") .into_diagnostic() .wrap_err("HOME is not set")?; @@ -38,6 +42,10 @@ pub fn xdg_state_dir() -> Result { if let Ok(path) = std::env::var("XDG_STATE_HOME") { return Ok(PathBuf::from(path)); } + #[cfg(target_os = "windows")] + if let Ok(path) = std::env::var("LOCALAPPDATA") { + return Ok(PathBuf::from(path)); + } let home = std::env::var("HOME") .into_diagnostic() .wrap_err("HOME is not set")?; @@ -56,6 +64,10 @@ pub fn xdg_data_dir() -> Result { if let Ok(path) = std::env::var("XDG_DATA_HOME") { return Ok(PathBuf::from(path)); } + #[cfg(target_os = "windows")] + if let Ok(path) = std::env::var("LOCALAPPDATA") { + return Ok(PathBuf::from(path)); + } let home = std::env::var("HOME") .into_diagnostic() .wrap_err("HOME is not set")?; @@ -133,7 +145,8 @@ pub fn is_file_permissions_too_open(path: &Path) -> bool { /// /// This is a lexical normalization only — it does NOT resolve symlinks or /// check the filesystem. `..` components are preserved verbatim; callers that -/// need to reject parent traversal must validate separately. +/// need to reject parent traversal must validate separately. The normalized +/// representation always uses `/` so sandbox policy paths are host-independent. pub fn normalize_path(path: &str) -> String { use std::path::Component; @@ -152,7 +165,15 @@ pub fn normalize_path(path: &str) -> String { Component::Normal(c) => normalized.push(c), } } - normalized.to_string_lossy().to_string() + let normalized = normalized.to_string_lossy(); + #[cfg(target_os = "windows")] + { + normalized.replace('\\', "/") + } + #[cfg(not(target_os = "windows"))] + { + normalized.into_owned() + } } #[cfg(test)] diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 3695e9f239..d3b3405813 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -5,8 +5,6 @@ //! //! This module re-exports the generated protobuf types and service definitions. -#![allow(unexpected_cfgs)] - #[allow( clippy::all, clippy::pedantic, @@ -17,10 +15,6 @@ rust_2018_idioms )] mod generated { - #[cfg(bazel)] - include!(env!("OPENSHELL_PROTO_PATH")); - - #[cfg(not(bazel))] include!(concat!(env!("OUT_DIR"), "/openshell.rs")); } @@ -46,6 +40,21 @@ pub mod compute { pub use super::generated::openshell::compute::v1; } +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + dead_code, + unused_imports, + unused_qualifications, + rust_2018_idioms +)] +pub mod credentials { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/openshell.credentials.v1.rs")); + } +} + pub mod test { pub use super::generated::openshell::test::v1::*; } diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index d0b7b38ad5..2b1537a21b 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -4,7 +4,13 @@ //! Runtime provider credential snapshots. use crate::secrets::SecretResolver; +use crate::{ + endpoint_path::EndpointPathPattern, + host_pattern::HostPattern, + proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}, +}; use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; use std::sync::{Arc, RwLock}; const MAX_RETAINED_CREDENTIAL_GENERATIONS: usize = 8; @@ -23,6 +29,30 @@ struct ProviderCredentialStateInner { current_resolver: Option>, combined_resolver: Option>, suppressed_keys: HashSet, + non_secret_environment_keys: HashSet, + static_credential_bindings: HashMap, + known_static_credential_keys: HashSet, + static_credential_identity_epochs: HashMap, +} + +#[derive(Debug)] +struct StaticCredentialIdentityEpoch { + identity: String, + revisions: Arc>, +} + +#[derive(Debug, Clone)] +struct CompiledStaticCredentialBinding { + endpoints: Vec, + credential_identity: String, + workload_credential_handle: String, +} + +#[derive(Debug, Clone)] +struct CompiledStaticCredentialEndpointBinding { + host: HostPattern, + port: u16, + path: EndpointPathPattern, } #[derive(Debug, Clone)] @@ -30,6 +60,19 @@ pub struct ProviderCredentialState { inner: Arc>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StaticCredentialBindingError { + message: String, +} + +impl fmt::Display for StaticCredentialBindingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for StaticCredentialBindingError {} + impl ProviderCredentialState { pub fn from_environment( revision: u64, @@ -59,10 +102,66 @@ impl ProviderCredentialState { current_resolver, combined_resolver, suppressed_keys: HashSet::new(), + non_secret_environment_keys: HashSet::new(), + static_credential_bindings: HashMap::new(), + known_static_credential_keys: HashSet::new(), + static_credential_identity_epochs: HashMap::new(), })), } } + pub fn from_bound_environment( + revision: u64, + env: HashMap, + credential_expires_at_ms: HashMap, + dynamic_credentials: HashMap, + static_credential_bindings: HashMap, + non_secret_environment_keys: Vec, + ) -> Result { + let static_credential_bindings = compile_static_credential_bindings( + &env, + static_credential_bindings, + &non_secret_environment_keys, + )?; + let stable_handles = static_credential_stable_handles(&static_credential_bindings); + let (child_env, generation_resolver, current_resolver) = + SecretResolver::from_provider_env_for_current_revision_with_stable_handles( + env, + credential_expires_at_ms, + revision, + &stable_handles, + ); + let snapshot = Arc::new(ProviderCredentialSnapshot { + revision, + child_env, + dynamic_credentials, + }); + let generations = generation_resolver.map(Arc::new).into_iter().collect(); + let current_resolver = current_resolver.map(Arc::new); + let combined_resolver = merge_resolvers(&generations, current_resolver.as_ref()); + let known_static_credential_keys = static_credential_bindings.keys().cloned().collect(); + let mut static_credential_identity_epochs = HashMap::new(); + update_static_credential_identity_epochs( + &mut static_credential_identity_epochs, + revision, + &static_credential_bindings, + ); + + Ok(Self { + inner: Arc::new(RwLock::new(ProviderCredentialStateInner { + current: snapshot, + generations, + current_resolver, + combined_resolver, + suppressed_keys: HashSet::new(), + non_secret_environment_keys: non_secret_environment_keys.into_iter().collect(), + static_credential_bindings, + known_static_credential_keys, + static_credential_identity_epochs, + })), + }) + } + /// Build a static provider state from an already-prepared child /// environment snapshot. /// @@ -85,6 +184,10 @@ impl ProviderCredentialState { current_resolver: None, combined_resolver: None, suppressed_keys: HashSet::new(), + non_secret_environment_keys: HashSet::new(), + static_credential_bindings: HashMap::new(), + known_static_credential_keys: HashSet::new(), + static_credential_identity_epochs: HashMap::new(), })), } } @@ -117,6 +220,10 @@ impl ProviderCredentialState { inner.generations.clear(); inner.current_resolver = None; inner.combined_resolver = None; + inner.non_secret_environment_keys.clear(); + inner.static_credential_bindings.clear(); + inner.known_static_credential_keys.clear(); + inner.static_credential_identity_epochs.clear(); inner.current.child_env.len() } @@ -136,6 +243,88 @@ impl ProviderCredentialState { .clone() } + /// Resolve provider placeholders only for credentials bound to this + /// concrete request endpoint. The view is created from one atomic state + /// snapshot and shares underlying resolver material. + #[must_use] + pub fn resolver_for_endpoint( + &self, + host: &str, + port: u16, + path: &str, + ) -> Option> { + self.resolver_for_endpoint_with_revision(host, port, path).0 + } + + /// Resolve provider placeholders for one endpoint and return the provider + /// revision observed from the same locked state snapshot. + /// + /// Callers that materialize credential-bearing requests asynchronously + /// use the revision to reject stale material immediately before its first + /// upstream write. + #[must_use] + pub fn resolver_for_endpoint_with_revision( + &self, + host: &str, + port: u16, + path: &str, + ) -> (Option>, u64) { + let request_path = path.split_once('?').map_or(path, |(path, _)| path); + let request_path = crate::secrets::redact_target_for_policy(request_path); + let normalized_host = host.to_ascii_lowercase(); + let host_labels = normalized_host.split('.').collect::>(); + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + let revision = inner.current.revision; + let Ok(request_path) = request_path else { + // Binding authorization must not depend on real credential + // material. Malformed placeholder syntax cannot be normalized + // safely, so expose no endpoint-scoped resolver. + return (None, revision); + }; + let allowed: HashSet = inner + .static_credential_bindings + .iter() + .filter(|(_, binding)| { + binding.endpoints.iter().any(|endpoint| { + static_credential_endpoint_matches(endpoint, &host_labels, port, &request_path) + }) + }) + .map(|(key, _)| key.clone()) + .collect(); + let resolver = inner.combined_resolver.as_ref().map(|resolver| { + let revision_fallback_allowed_revisions = inner + .static_credential_identity_epochs + .iter() + .filter(|(key, epoch)| { + allowed.contains(*key) + && inner + .static_credential_bindings + .get(*key) + .is_some_and(|binding| binding.credential_identity == epoch.identity) + }) + .map(|(key, epoch)| (key.clone(), epoch.revisions.clone())) + .collect(); + Arc::new(resolver.scoped_to_env_keys( + &inner.known_static_credential_keys, + &allowed, + revision_fallback_allowed_revisions, + )) + }); + (resolver, revision) + } + + #[must_use] + pub fn revision(&self) -> u64 { + self.inner + .read() + .expect("provider credential state poisoned") + .current + .revision + } + /// Remove a key from the credential snapshot's child env. /// /// Used when a sandbox-side service (e.g., metadata server) fails to start @@ -178,10 +367,13 @@ impl ProviderCredentialState { .expect("provider credential state poisoned"); let mut env = inner.current.child_env.clone(); - let has_gcp_metadata = env.contains_key("GCE_METADATA_HOST"); + let has_gcp_metadata = env.contains_key("GCE_METADATA_HOST") + && inner + .non_secret_environment_keys + .contains("GCE_METADATA_HOST"); let has_gcp_config = google_cloud::STATIC_CONFIG_KEYS .iter() - .any(|k| env.contains_key(*k)); + .any(|key| env.contains_key(*key) && inner.non_secret_environment_keys.contains(*key)); if !has_gcp_metadata && !has_gcp_config { return env; @@ -211,7 +403,10 @@ impl ProviderCredentialState { // Un-placeholderize non-secret config vars so SDKs can read them // at process startup before any HTTP flows through the proxy. if let Some(ref resolver) = inner.combined_resolver { - for key in google_cloud::STATIC_CONFIG_KEYS { + for key in google_cloud::STATIC_CONFIG_KEYS + .iter() + .filter(|key| inner.non_secret_environment_keys.contains(**key)) + { let placeholder = crate::secrets::placeholder_for_env_key(key); if let Some(value) = resolver.resolve_placeholder(&placeholder) { env.insert(key.to_string(), value.to_string()); @@ -230,9 +425,15 @@ impl ProviderCredentialState { /// expired. The `expires_in` defaults to 3600 when expiry is unknown. pub fn gcp_token_response(&self) -> Option<(String, i64)> { const DEFAULT_EXPIRES_IN: i64 = 3600; - let resolver = self.resolver()?; + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + let resolver = inner.current_resolver.as_ref()?; for key in crate::google_cloud::TOKEN_ENV_KEYS { - let placeholder = crate::secrets::placeholder_for_env_key(key); + let Some(placeholder) = inner.current.child_env.get(*key).cloned() else { + continue; + }; if resolver.resolve_placeholder(&placeholder).is_none() { continue; } @@ -292,8 +493,292 @@ impl ProviderCredentialState { } inner.combined_resolver = merge_resolvers(&inner.generations, inner.current_resolver.as_ref()); + inner.non_secret_environment_keys.clear(); inner.current.child_env.len() } + + /// Install one gateway provider-environment snapshot. + /// + /// Callers must serialize this operation with other bound-environment + /// installs and revocations. The sandbox settings refresh loop is the sole + /// writer today. The internal lock makes each mutation memory-safe, but it + /// does not establish revision ordering between concurrent snapshots. + pub fn install_bound_environment( + &self, + revision: u64, + env: HashMap, + credential_expires_at_ms: HashMap, + dynamic_credentials: HashMap, + static_credential_bindings: HashMap, + non_secret_environment_keys: Vec, + ) -> Result { + let static_credential_bindings = match compile_static_credential_bindings( + &env, + static_credential_bindings, + &non_secret_environment_keys, + ) { + Ok(bindings) => bindings, + Err(error) => { + self.revoke_static_provider_environment_inner(revision, Some(dynamic_credentials)); + return Err(error); + } + }; + + let stable_handles = static_credential_stable_handles(&static_credential_bindings); + let (mut child_env, generation_resolver, current_resolver) = + SecretResolver::from_provider_env_for_current_revision_with_stable_handles( + env, + credential_expires_at_ms, + revision, + &stable_handles, + ); + let mut inner = self + .inner + .write() + .expect("provider credential state poisoned"); + + for key in &inner.suppressed_keys { + child_env.remove(key); + } + inner.current = Arc::new(ProviderCredentialSnapshot { + revision, + child_env, + dynamic_credentials, + }); + inner.current_resolver = current_resolver.map(Arc::new); + if static_credential_identities(&inner.static_credential_bindings) + != static_credential_identities(&static_credential_bindings) + { + inner.generations.clear(); + } + if let Some(resolver) = generation_resolver { + inner.generations.push_back(Arc::new(resolver)); + while inner.generations.len() > MAX_RETAINED_CREDENTIAL_GENERATIONS { + inner.generations.pop_front(); + } + } + inner.combined_resolver = + merge_resolvers(&inner.generations, inner.current_resolver.as_ref()); + inner + .known_static_credential_keys + .extend(static_credential_bindings.keys().cloned()); + update_static_credential_identity_epochs( + &mut inner.static_credential_identity_epochs, + revision, + &static_credential_bindings, + ); + inner.non_secret_environment_keys = non_secret_environment_keys.into_iter().collect(); + inner.static_credential_bindings = static_credential_bindings; + Ok(inner.current.child_env.len()) + } + + /// Atomically remove static provider material after a failed refresh. + /// + /// Dynamic token grants retain their independently endpoint-bound state + /// unless the caller supplies a newer dynamic snapshot. Identity-only + /// revision membership remains as a tombstone so a later successful + /// refresh can restore placeholders issued by the same provider identity. + /// With no resolver or active bindings, the tombstone cannot resolve + /// credentials while the refresh is failed. A successful empty provider + /// environment removes it through the normal epoch update path. + pub fn revoke_static_provider_environment(&self, revision: u64) { + self.revoke_static_provider_environment_inner(revision, None); + } + + fn revoke_static_provider_environment_inner( + &self, + revision: u64, + dynamic_credentials: Option>, + ) { + let mut inner = self + .inner + .write() + .expect("provider credential state poisoned"); + let dynamic_credentials = + dynamic_credentials.unwrap_or_else(|| inner.current.dynamic_credentials.clone()); + inner.current = Arc::new(ProviderCredentialSnapshot { + revision, + child_env: HashMap::new(), + dynamic_credentials, + }); + inner.generations.clear(); + inner.current_resolver = None; + inner.combined_resolver = None; + inner.non_secret_environment_keys.clear(); + inner.static_credential_bindings.clear(); + } +} + +fn compile_static_credential_bindings( + env: &HashMap, + bindings: HashMap, + non_secret_environment_keys: &[String], +) -> Result, StaticCredentialBindingError> { + let non_secret_keys = non_secret_environment_keys + .iter() + .cloned() + .collect::>(); + if non_secret_keys.len() != non_secret_environment_keys.len() { + return Err(binding_error( + "provider environment repeats a non-secret environment key", + )); + } + 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", + )); + } + if env + .keys() + .any(|key| !bindings.contains_key(key) && !non_secret_keys.contains(key)) + { + return Err(binding_error( + "provider environment contains an unclassified credential key", + )); + } + if bindings.keys().any(|key| !env.contains_key(key)) + || non_secret_keys.iter().any(|key| !env.contains_key(key)) + { + return Err(binding_error( + "provider environment metadata references a missing environment key", + )); + } + for binding in bindings.values() { + if binding.credential_identity.is_empty() { + return Err(binding_error( + "static credential binding has no provider credential identity", + )); + } + if binding.endpoints.is_empty() { + return Err(binding_error( + "static credential binding has no authorized endpoints", + )); + } + if !binding.workload_credential_handle.is_empty() + && !is_valid_workload_credential_handle(&binding.workload_credential_handle) + { + return Err(binding_error( + "static credential binding has an invalid workload credential handle", + )); + } + for endpoint in &binding.endpoints { + if endpoint.port == 0 || endpoint.port > u32::from(u16::MAX) { + return Err(binding_error( + "static credential binding contains an invalid endpoint", + )); + } + } + } + + bindings + .into_iter() + .map(|(key, binding)| { + let endpoints = binding + .endpoints + .into_iter() + .map(compile_static_credential_endpoint) + .collect::>()?; + Ok(( + key, + CompiledStaticCredentialBinding { + endpoints, + credential_identity: binding.credential_identity, + workload_credential_handle: binding.workload_credential_handle, + }, + )) + }) + .collect() +} + +fn compile_static_credential_endpoint( + endpoint: StaticCredentialEndpointBinding, +) -> Result { + let host = HostPattern::new(&endpoint.host) + .map_err(|_| binding_error("static credential binding contains an invalid endpoint"))?; + Ok(CompiledStaticCredentialEndpointBinding { + host, + port: u16::try_from(endpoint.port) + .map_err(|_| binding_error("static credential binding contains an invalid endpoint"))?, + path: EndpointPathPattern::new(&endpoint.path), + }) +} + +fn static_credential_identities( + bindings: &HashMap, +) -> HashMap<&str, &str> { + bindings + .iter() + .map(|(key, binding)| (key.as_str(), binding.credential_identity.as_str())) + .collect() +} + +fn static_credential_stable_handles( + bindings: &HashMap, +) -> HashMap { + bindings + .iter() + .filter(|(_, binding)| !binding.workload_credential_handle.is_empty()) + .map(|(key, binding)| (key.clone(), binding.workload_credential_handle.clone())) + .collect() +} + +fn is_valid_workload_credential_handle(handle: &str) -> bool { + handle.len() == 64 + && handle + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn update_static_credential_identity_epochs( + epochs: &mut HashMap, + revision: u64, + bindings: &HashMap, +) { + epochs.retain(|key, _| { + bindings + .get(key) + .is_some_and(|binding| binding.workload_credential_handle.is_empty()) + }); + for (key, binding) in bindings { + if !binding.workload_credential_handle.is_empty() { + continue; + } + match epochs.get_mut(key) { + Some(epoch) if epoch.identity == binding.credential_identity => { + Arc::make_mut(&mut epoch.revisions).insert(revision); + } + Some(epoch) => { + epoch.identity.clone_from(&binding.credential_identity); + epoch.revisions = Arc::new(HashSet::from([revision])); + } + None => { + epochs.insert( + key.clone(), + StaticCredentialIdentityEpoch { + identity: binding.credential_identity.clone(), + revisions: Arc::new(HashSet::from([revision])), + }, + ); + } + } + } +} + +fn binding_error(message: &str) -> StaticCredentialBindingError { + StaticCredentialBindingError { + message: message.to_string(), + } +} + +fn static_credential_endpoint_matches( + endpoint: &CompiledStaticCredentialEndpointBinding, + host_labels: &[&str], + port: u16, + path: &str, +) -> bool { + endpoint.port == port + && endpoint.host.matches_normalized_labels(host_labels) + && endpoint.path.matches(path) } fn merge_resolvers( @@ -314,6 +799,847 @@ mod tests { use super::*; use crate::google_cloud; + fn binding(host: &str, port: u32, path: &str) -> StaticCredentialBinding { + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: host.to_string(), + port, + path: path.to_string(), + }], + credential_identity: "provider-a:API_KEY".to_string(), + workload_credential_handle: String::new(), + } + } + + fn stable_binding(host: &str, port: u32, path: &str, handle: &str) -> StaticCredentialBinding { + let mut binding = binding(host, port, path); + binding.credential_identity = format!("refresh:{handle}"); + binding.workload_credential_handle = handle.to_string(); + binding + } + + fn assert_binding_validation_error( + env: HashMap, + bindings: HashMap, + non_secret_environment_keys: Vec, + expected: &str, + ) { + let error = + compile_static_credential_bindings(&env, bindings, &non_secret_environment_keys) + .expect_err("malformed provider metadata must fail validation"); + assert_eq!(error.to_string(), expected); + } + + #[test] + fn rejects_each_malformed_static_credential_binding_shape() { + let credential_env = || HashMap::from([("API_KEY".to_string(), "secret".to_string())]); + let credential_binding = || { + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]) + }; + + assert_binding_validation_error( + HashMap::from([("PROJECT_ID".to_string(), "project".to_string())]), + HashMap::new(), + vec!["PROJECT_ID".to_string(), "PROJECT_ID".to_string()], + "provider environment repeats a non-secret environment key", + ); + assert_binding_validation_error( + credential_env(), + credential_binding(), + vec!["API_KEY".to_string()], + "provider environment classifies a key as both credential and non-secret configuration", + ); + assert_binding_validation_error( + credential_env(), + HashMap::new(), + Vec::new(), + "provider environment contains an unclassified credential key", + ); + assert_binding_validation_error( + HashMap::new(), + credential_binding(), + Vec::new(), + "provider environment metadata references a missing environment key", + ); + + let mut missing_identity = binding("api.example.com", 443, "/**"); + missing_identity.credential_identity.clear(); + assert_binding_validation_error( + credential_env(), + HashMap::from([("API_KEY".to_string(), missing_identity)]), + Vec::new(), + "static credential binding has no provider credential identity", + ); + + let mut missing_endpoints = binding("api.example.com", 443, "/**"); + missing_endpoints.endpoints.clear(); + assert_binding_validation_error( + credential_env(), + HashMap::from([("API_KEY".to_string(), missing_endpoints)]), + Vec::new(), + "static credential binding has no authorized endpoints", + ); + + let mut invalid_handle = binding("api.example.com", 443, "/**"); + invalid_handle.workload_credential_handle = "not-an-opaque-handle".to_string(); + assert_binding_validation_error( + credential_env(), + HashMap::from([("API_KEY".to_string(), invalid_handle)]), + Vec::new(), + "static credential binding has an invalid workload credential handle", + ); + + for (host, port) in [ + ("api.example.com", 0), + ("api.example.com", u32::from(u16::MAX) + 1), + ("invalid host", 443), + ] { + assert_binding_validation_error( + credential_env(), + HashMap::from([("API_KEY".to_string(), binding(host, port, "/**"))]), + Vec::new(), + "static credential binding contains an invalid endpoint", + ); + } + } + + #[test] + fn bound_credentials_resolve_only_at_matching_endpoint() { + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("*.example.com", 443, "/v1/**"), + )]), + Vec::new(), + ) + .expect("valid bindings"); + let placeholder = "openshell:resolve:env:v7_API_KEY"; + + let allowed = state + .resolver_for_endpoint("api.example.com", 443, "/v1/messages?stream=true") + .expect("resolver"); + assert_eq!(allowed.resolve_placeholder(placeholder), Some("secret")); + + for (host, port, path) in [ + ("example.com", 443, "/v1/messages"), + ("api.example.com", 80, "/v1/messages"), + ("api.example.com", 443, "/v2/messages"), + ] { + let denied = state + .resolver_for_endpoint(host, port, path) + .expect("resolver"); + let error = denied + .rewrite_header_value(placeholder) + .expect_err("endpoint mismatch must fail closed"); + assert!(error.is_endpoint_mismatch(), "{host}:{port}{path}"); + } + } + + #[test] + fn multiple_credentials_resolve_only_at_their_own_endpoints() { + let mut binding_a = binding("a.example.com", 443, "/a/**"); + binding_a.credential_identity = "provider-a:KEY_A".to_string(); + let mut binding_b = binding("b.example.com", 443, "/b/**"); + binding_b.credential_identity = "provider-b:KEY_B".to_string(); + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([ + ("KEY_A".to_string(), "secret-a".to_string()), + ("KEY_B".to_string(), "secret-b".to_string()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::from([ + ("KEY_A".to_string(), binding_a), + ("KEY_B".to_string(), binding_b), + ]), + Vec::new(), + ) + .expect("valid bindings"); + + let resolver_a = state + .resolver_for_endpoint("a.example.com", 443, "/a/check") + .expect("endpoint A resolver"); + assert_eq!( + resolver_a.resolve_placeholder("openshell:resolve:env:v7_KEY_A"), + Some("secret-a") + ); + assert_eq!( + resolver_a.resolve_placeholder("openshell:resolve:env:v7_KEY_B"), + None, + "credential B must not resolve at endpoint A" + ); + + let resolver_b = state + .resolver_for_endpoint("b.example.com", 443, "/b/check") + .expect("endpoint B resolver"); + assert_eq!( + resolver_b.resolve_placeholder("openshell:resolve:env:v7_KEY_B"), + Some("secret-b") + ); + assert_eq!( + resolver_b.resolve_placeholder("openshell:resolve:env:v7_KEY_A"), + None, + "credential A must not resolve at endpoint B" + ); + } + + #[test] + fn refresh_replaces_compiled_endpoint_patterns() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("old.example.com", 443, "/v1/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + state + .install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("new.example.com", 443, "/v2/**"), + )]), + Vec::new(), + ) + .expect("replacement bindings"); + + let replacement = state + .resolver_for_endpoint("new.example.com", 443, "/v2/messages") + .expect("replacement endpoint resolver"); + assert_eq!( + replacement.resolve_placeholder("openshell:resolve:env:v2_API_KEY"), + Some("new") + ); + + let removed = state + .resolver_for_endpoint("old.example.com", 443, "/v1/messages") + .expect("restricted resolver"); + let error = removed + .rewrite_header_value("openshell:resolve:env:v2_API_KEY") + .expect_err("replaced endpoint binding must no longer authorize the credential"); + assert!(error.is_endpoint_mismatch()); + } + + #[test] + fn revisioned_path_placeholder_matches_exact_redacted_binding() { + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/bot[CREDENTIAL]/sendMessage"), + )]), + Vec::new(), + ) + .expect("valid bindings"); + let placeholder = "openshell:resolve:env:v7_API_KEY"; + + let resolver = state + .resolver_for_endpoint( + "api.example.com", + 443, + &format!("/bot{placeholder}/sendMessage?stream=true"), + ) + .expect("syntax-redacted path should select the binding"); + assert_eq!(resolver.resolve_placeholder(placeholder), Some("secret")); + } + + #[test] + fn provider_alias_path_placeholder_matches_glob_redacted_binding() { + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/v1/*/messages"), + )]), + Vec::new(), + ) + .expect("valid bindings"); + + let resolver = state + .resolver_for_endpoint( + "api.example.com", + 443, + "/v1/vendor-OPENSHELL-RESOLVE-ENV-API_KEY/messages", + ) + .expect("syntax-redacted alias path should select the binding"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v7_API_KEY"), + Some("secret") + ); + } + + #[test] + fn malformed_path_placeholder_exposes_no_endpoint_resolver() { + let state = ProviderCredentialState::from_bound_environment( + 7, + 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(), + ) + .expect("valid bindings"); + + assert!( + state + .resolver_for_endpoint( + "api.example.com", + 443, + "/v1/openshell:resolve:env:/messages", + ) + .is_none(), + "malformed placeholder syntax must fail closed before path matching" + ); + } + + #[test] + fn non_secret_provider_config_is_not_endpoint_scoped() { + let state = ProviderCredentialState::from_bound_environment( + 3, + HashMap::from([("GCP_PROJECT_ID".to_string(), "project".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["GCP_PROJECT_ID".to_string()], + ) + .expect("classified non-secret environment"); + let resolver = state + .resolver_for_endpoint("unrelated.example", 1234, "/") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v3_GCP_PROJECT_ID"), + Some("project") + ); + } + + #[test] + fn incomplete_refresh_revokes_previous_credentials_atomically() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + let dynamic_credentials = HashMap::from([( + "dynamic".to_string(), + crate::proto::ProviderProfileCredential::default(), + )]); + let result = state.install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + dynamic_credentials, + HashMap::new(), + Vec::new(), + ); + assert!(result.is_err()); + assert!(state.snapshot().child_env.is_empty()); + assert!(state.resolver().is_none()); + assert!(state.snapshot().dynamic_credentials.contains_key("dynamic")); + + state + .install_bound_environment( + 3, + HashMap::from([("API_KEY".to_string(), "recovered".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("same-identity recovery"); + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + Some("recovered"), + "a metadata failure must not permanently strand placeholders from the same provider identity" + ); + } + + #[test] + fn failed_fetch_revokes_secrets_then_same_identity_retry_recovers_running_placeholder() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + state.revoke_static_provider_environment(2); + + assert!( + state.resolver().is_none(), + "no static secret resolver may remain active during the failed refresh" + ); + assert!( + state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .is_none(), + "an identity tombstone must not authorize requests without active bindings and secrets" + ); + + state + .install_bound_environment( + 3, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("same-identity retry"); + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + Some("new"), + "the running process placeholder should recover against the current same-identity secret" + ); + } + + #[test] + fn retained_generation_survives_rotation_of_same_provider_credential() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + state + .install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("rotated bindings"); + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + Some("old") + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v2_API_KEY"), + Some("new") + ); + } + + #[test] + fn stable_handle_resolves_current_token_after_initial_token_expires() { + let handle = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + 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 binding = stable_binding("api.example.com", 443, "/**", handle); + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "expired".to_string())]), + HashMap::from([("API_KEY".to_string(), now_ms - 1)]), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding.clone())]), + Vec::new(), + ) + .expect("initial stable binding"); + let workload_placeholder = state.snapshot().child_env["API_KEY"].clone(); + assert!(workload_placeholder.contains(handle)); + assert_eq!( + state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver") + .resolve_placeholder(&workload_placeholder), + None, + "the expired access token must fail closed" + ); + + state + .install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "current".to_string())]), + HashMap::from([("API_KEY".to_string(), now_ms + 60_000)]), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding)]), + Vec::new(), + ) + .expect("refreshed stable binding"); + + assert_eq!(state.snapshot().child_env["API_KEY"], workload_placeholder); + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder(&workload_placeholder), + Some("current") + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v2_API_KEY"), + None, + "refresh-managed credentials must not expose revision aliases" + ); + } + + #[test] + fn stable_handle_survives_twelve_rotations_and_state_reconstruction() { + let handle = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let binding = stable_binding("api.example.com", 443, "/**", handle); + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "token-1".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding.clone())]), + Vec::new(), + ) + .expect("initial stable binding"); + let workload_placeholder = state.snapshot().child_env["API_KEY"].clone(); + + for revision in 2..=13 { + let token = format!("token-{revision}"); + state + .install_bound_environment( + revision, + HashMap::from([("API_KEY".to_string(), token.clone())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding.clone())]), + Vec::new(), + ) + .expect("same-epoch rotation"); + assert_eq!(state.snapshot().child_env["API_KEY"], workload_placeholder); + assert_eq!( + state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver") + .resolve_placeholder(&workload_placeholder), + Some(token.as_str()) + ); + } + + let reconstructed = ProviderCredentialState::from_bound_environment( + 14, + HashMap::from([("API_KEY".to_string(), "token-14".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding)]), + Vec::new(), + ) + .expect("reconstructed network supervisor state"); + assert_eq!( + reconstructed.snapshot().child_env["API_KEY"], + workload_placeholder + ); + assert_eq!( + reconstructed + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver") + .resolve_placeholder(&workload_placeholder), + Some("token-14") + ); + } + + #[test] + fn authorization_epoch_or_endpoint_change_revokes_stable_handle() { + let old_handle = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let new_handle = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + stable_binding("old.example.com", 443, "/v1/**", old_handle), + )]), + Vec::new(), + ) + .expect("old authorization"); + let old_placeholder = state.snapshot().child_env["API_KEY"].clone(); + + state + .install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + stable_binding("new.example.com", 443, "/v2/**", new_handle), + )]), + Vec::new(), + ) + .expect("replacement authorization"); + + let current = state + .resolver_for_endpoint("new.example.com", 443, "/v2/messages") + .expect("current resolver"); + assert_eq!(current.resolve_placeholder(&old_placeholder), None); + assert_eq!( + current.resolve_placeholder(&state.snapshot().child_env["API_KEY"]), + Some("new") + ); + let wrong_endpoint = state + .resolver_for_endpoint("old.example.com", 443, "/v1/messages") + .expect("endpoint-scoped resolver"); + let error = wrong_endpoint + .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()); + } + + #[test] + fn aged_generation_falls_back_across_non_monotonic_same_identity_rotations() { + let state = ProviderCredentialState::from_bound_environment( + 50, + HashMap::from([("API_KEY".to_string(), "secret-50".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + for revision in [10, 100, 9, 101, 8, 102, 7, 103, 6] { + state + .install_bound_environment( + revision, + HashMap::from([("API_KEY".to_string(), format!("secret-{revision}"))]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("rotated bindings"); + } + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .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" + ); + } + + #[test] + fn replacing_provider_with_reused_key_purges_retained_generation() { + let state = ProviderCredentialState::from_bound_environment( + u64::MAX, + HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("initial bindings"); + let mut replacement_binding = binding("b.example.com", 443, "/**"); + replacement_binding.credential_identity = "provider-b:API_KEY".to_string(); + + state + .install_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "provider-b-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), replacement_binding)]), + Vec::new(), + ) + .expect("replacement bindings"); + + let resolver = state + .resolver_for_endpoint("b.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder(&format!("openshell:resolve:env:v{}_API_KEY", u64::MAX)), + None, + "an opaque revision from another provider identity must fail closed even when it is numerically greater" + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:API_KEY"), + None, + "an identityless canonical placeholder must not resolve a replacement provider" + ); + assert_eq!( + resolver.resolve_placeholder("vendor-OPENSHELL-RESOLVE-ENV-API_KEY"), + None, + "an identityless provider alias must not resolve a replacement provider" + ); + assert_eq!( + resolver + .resolve_current_env_key_checked("API_KEY", "trusted-transform") + .expect("binding authorizes the endpoint"), + Some("provider-b-secret"), + "trusted supervisor transforms may select the current bound credential by key" + ); + } + + #[test] + fn failed_refresh_then_replacement_with_reused_key_rejects_old_placeholder() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("initial bindings"); + state.revoke_static_provider_environment(2); + + let mut replacement_binding = binding("b.example.com", 443, "/**"); + replacement_binding.credential_identity = "provider-b:API_KEY".to_string(); + state + .install_bound_environment( + 3, + HashMap::from([("API_KEY".to_string(), "provider-b-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), replacement_binding)]), + Vec::new(), + ) + .expect("replacement bindings"); + + let resolver = state + .resolver_for_endpoint("b.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + None, + "a placeholder issued before detach must not resolve to a replacement provider" + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:API_KEY"), + None, + "an identityless canonical placeholder must not cross a failed refresh into a replacement identity" + ); + assert_eq!( + resolver.resolve_placeholder("vendor-OPENSHELL-RESOLVE-ENV-API_KEY"), + None, + "an identityless provider alias must not cross a failed refresh into a replacement identity" + ); + } + + #[test] + fn successful_empty_environment_clears_failed_refresh_identity_tombstones() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("initial bindings"); + + state.revoke_static_provider_environment(2); + state + .install_bound_environment( + 3, + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + ) + .expect("successful detached environment"); + state + .install_bound_environment( + 4, + HashMap::from([("API_KEY".to_string(), "reattached".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("reattached environment"); + + let resolver = state + .resolver_for_endpoint("a.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + None, + "a successful empty environment represents detach and must invalidate old membership" + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v4_API_KEY"), + Some("reattached") + ); + } + #[test] fn snapshots_use_revision_scoped_placeholders() { let state = ProviderCredentialState::from_environment( @@ -440,7 +1766,7 @@ mod tests { #[test] fn child_env_with_gcp_resolved_overrides_gcp_static_vars() { - let state = ProviderCredentialState::from_environment( + let state = ProviderCredentialState::from_bound_environment( 1, HashMap::from([ ("GCE_METADATA_HOST".to_string(), "marker".to_string()), @@ -453,7 +1779,17 @@ mod tests { ]), HashMap::new(), HashMap::new(), - ); + HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + binding("oauth2.googleapis.com", 443, "/**"), + )]), + vec![ + "GCE_METADATA_HOST".to_string(), + "GCP_PROJECT_ID".to_string(), + "CLOUD_ML_REGION".to_string(), + ], + ) + .expect("classified GCP environment"); let env = state.child_env_with_gcp_resolved(); assert_eq!( @@ -484,7 +1820,7 @@ mod tests { #[test] fn child_env_with_gcp_resolved_handles_missing_config_keys() { - let state = ProviderCredentialState::from_environment( + let state = ProviderCredentialState::from_bound_environment( 1, HashMap::from([ ("GCE_METADATA_HOST".to_string(), "marker".to_string()), @@ -492,7 +1828,13 @@ mod tests { ]), HashMap::new(), HashMap::new(), - ); + HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + binding("oauth2.googleapis.com", 443, "/**"), + )]), + vec!["GCE_METADATA_HOST".to_string()], + ) + .expect("classified GCP environment"); let env = state.child_env_with_gcp_resolved(); assert_eq!( @@ -521,9 +1863,9 @@ mod tests { HashMap::new(), ); let (placeholder, _) = state.gcp_token_response().expect("should find token"); - assert!( - placeholder.contains("GCP_SA_ACCESS_TOKEN"), - "SA token should win over ADC, got: {placeholder}" + assert_eq!( + placeholder, "openshell:resolve:env:v1_GCP_SA_ACCESS_TOKEN", + "metadata must return the current revision-scoped SA placeholder" ); } @@ -536,7 +1878,10 @@ mod tests { HashMap::new(), ); let (placeholder, _) = state.gcp_token_response().expect("should find ADC token"); - assert!(placeholder.contains("GCP_ADC_ACCESS_TOKEN")); + assert_eq!( + placeholder, "openshell:resolve:env:v1_GCP_ADC_ACCESS_TOKEN", + "metadata must return the current revision-scoped ADC placeholder" + ); } #[test] @@ -610,7 +1955,7 @@ mod tests { #[test] fn child_env_with_gcp_resolved_resolves_vertex_vars_without_metadata_host() { - let state = ProviderCredentialState::from_environment( + let state = ProviderCredentialState::from_bound_environment( 1, HashMap::from([ ("GOOSE_PROVIDER".to_string(), "gcp_vertex_ai".to_string()), @@ -622,7 +1967,14 @@ mod tests { ]), HashMap::new(), HashMap::new(), - ); + HashMap::new(), + vec![ + "GOOSE_PROVIDER".to_string(), + "ANTHROPIC_VERTEX_PROJECT_ID".to_string(), + "VERTEX_LOCATION".to_string(), + ], + ) + .expect("classified Vertex environment"); let env = state.child_env_with_gcp_resolved(); assert_eq!( env.get("GOOSE_PROVIDER").map(String::as_str), @@ -643,6 +1995,66 @@ mod tests { ); } + #[test] + fn child_env_with_gcp_resolved_only_unwraps_explicitly_non_secret_config() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([ + ( + "GOOGLE_CLOUD_PROJECT".to_string(), + "initial-project-config".to_string(), + ), + ( + "GCP_PROJECT_ID".to_string(), + "visible-project-config".to_string(), + ), + ]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec![ + "GOOGLE_CLOUD_PROJECT".to_string(), + "GCP_PROJECT_ID".to_string(), + ], + ) + .expect("classified GCP environment"); + + state + .install_bound_environment( + 2, + HashMap::from([ + ( + "GOOGLE_CLOUD_PROJECT".to_string(), + "bound-project-secret".to_string(), + ), + ( + "GCP_PROJECT_ID".to_string(), + "visible-project-config".to_string(), + ), + ]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "GOOGLE_CLOUD_PROJECT".to_string(), + binding("example.googleapis.com", 443, "/**"), + )]), + vec!["GCP_PROJECT_ID".to_string()], + ) + .expect("refreshed GCP environment"); + + let env = state.child_env_with_gcp_resolved(); + assert_eq!( + env.get("GCP_PROJECT_ID").map(String::as_str), + Some("visible-project-config"), + "explicitly non-secret GCP config should be visible to the workload" + ); + assert_eq!( + env.get("GOOGLE_CLOUD_PROJECT").map(String::as_str), + Some("openshell:resolve:env:v2_GOOGLE_CLOUD_PROJECT"), + "a reserved GCP name classified as a bound credential must stay placeholderized" + ); + } + #[test] fn suppressed_keys_survive_install_environment() { let state = ProviderCredentialState::from_environment( diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 1549258fa3..2ce8e4b058 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -8,6 +8,9 @@ //! supervisor process (which reads them on startup). Using constants here //! prevents typos from producing silently broken sandboxes. +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + /// Name of the sandbox (used for policy sync and identification). pub const SANDBOX: &str = "OPENSHELL_SANDBOX"; @@ -23,8 +26,106 @@ pub const SSH_SOCKET_PATH: &str = "OPENSHELL_SSH_SOCKET_PATH"; /// Log level for the sandbox supervisor (e.g. `"debug"`, `"info"`, `"warn"`). pub const LOG_LEVEL: &str = "OPENSHELL_LOG_LEVEL"; -/// Shell command to run inside the sandbox. -pub const SANDBOX_COMMAND: &str = "OPENSHELL_SANDBOX_COMMAND"; +/// Versioned specification for the exact canonical main process. +/// +/// Most drivers use JSON directly. Transports that cannot preserve spaces in +/// environment values may use the `base64url:`-prefixed representation. +pub const MAIN_PROCESS_SPEC: &str = "OPENSHELL_MAIN_PROCESS_SPEC"; + +const MAIN_PROCESS_SPEC_BASE64URL_PREFIX: &str = "base64url:"; + +/// Lossless driver-to-supervisor representation of the canonical process. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MainProcessConfig { + pub version: u32, + /// Canonical command. Empty means "no command supplied": the supervisor + /// resolves the default login shell against the sandbox image. A non-empty + /// command is the exact program+args and is run verbatim. + pub command: Vec, + pub tty: bool, + #[serde(default)] + pub await_main_process_attachment: bool, +} + +impl MainProcessConfig { + pub const VERSION: u32 = 1; + + /// Default config for a sandbox created without a command. The command is + /// left empty on purpose: the supervisor picks a login shell that exists in + /// the sandbox image (bash when present, otherwise `/bin/sh`). A TTY is + /// requested because the default is an interactive login shell. + #[must_use] + pub fn scratch() -> Self { + Self { + version: Self::VERSION, + command: Vec::new(), + tty: true, + await_main_process_attachment: false, + } + } + + #[must_use] + pub fn from_driver_spec(spec: Option<&crate::proto::compute::v1::DriverSandboxSpec>) -> Self { + match spec { + Some(spec) if !spec.command.is_empty() => Self { + version: Self::VERSION, + command: spec.command.clone(), + tty: spec.tty, + await_main_process_attachment: spec.await_main_process_attachment, + }, + None | Some(_) => Self::scratch(), + } + } + + /// Decode the versioned transport without shell interpretation. + pub fn decode(encoded: &str) -> Result { + let decoded; + let json = if let Some(payload) = encoded.strip_prefix(MAIN_PROCESS_SPEC_BASE64URL_PREFIX) { + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .map_err(|error| format!("invalid {MAIN_PROCESS_SPEC} base64url: {error}"))?; + decoded = String::from_utf8(bytes) + .map_err(|error| format!("invalid {MAIN_PROCESS_SPEC} UTF-8: {error}"))?; + decoded.as_str() + } else { + encoded + }; + let config: Self = serde_json::from_str(json) + .map_err(|error| format!("invalid {MAIN_PROCESS_SPEC}: {error}"))?; + if config.version != Self::VERSION { + return Err(format!( + "unsupported {MAIN_PROCESS_SPEC} version {}", + config.version + )); + } + // An empty command is valid: it means "no command supplied", and the + // supervisor resolves the default login shell. Only a present-but-blank + // program is rejected. + if !config.command.is_empty() && config.command[0].is_empty() { + return Err(format!( + "{MAIN_PROCESS_SPEC} command program must not be empty" + )); + } + Ok(config) + } + + /// Encode the versioned driver-to-supervisor transport. + pub fn encode_driver_spec( + spec: Option<&crate::proto::compute::v1::DriverSandboxSpec>, + ) -> Result { + serde_json::to_string(&Self::from_driver_spec(spec)) + } + + /// Encode the versioned transport without whitespace for constrained + /// environment-variable transports used by embedded runtimes. + pub fn encode_driver_spec_base64url( + spec: Option<&crate::proto::compute::v1::DriverSandboxSpec>, + ) -> Result { + let json = Self::encode_driver_spec(spec)?; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json); + Ok(format!("{MAIN_PROCESS_SPEC_BASE64URL_PREFIX}{payload}")) + } +} /// Deployment-controlled telemetry toggle propagated to the sandbox supervisor. pub const TELEMETRY_ENABLED: &str = "OPENSHELL_TELEMETRY_ENABLED"; @@ -36,6 +137,14 @@ pub const SUPERVISOR_TOPOLOGY: &str = "OPENSHELL_SUPERVISOR_TOPOLOGY"; /// Network enforcement backend selected by the compute driver. pub const NETWORK_ENFORCEMENT_MODE: &str = "OPENSHELL_NETWORK_ENFORCEMENT_MODE"; +/// Comma-separated runtime networking capabilities supplied by the compute +/// driver. Capabilities describe substrate the shared supervisor may activate; +/// they never move policy evaluation into the driver. +pub const NETWORK_RUNTIME_CAPABILITIES: &str = "OPENSHELL_NETWORK_RUNTIME_CAPABILITIES"; + +/// Driver capability for policy-gated DNS and transparent TCP interception. +pub const POLICY_DNS_TRANSPARENT_TCP_CAPABILITY: &str = "policy-dns-transparent-tcp"; + /// Whether network policy evaluation must bind requests to the peer binary. /// /// The default when unset is `"required"`. Kubernetes sidecar experiments may @@ -130,3 +239,96 @@ pub const OCI_IMAGE_USER: &str = "OPENSHELL_OCI_IMAGE_USER"; // environment variables: it travels on the supervisor's argv // (`--upstream-proxy` and friends), which a sandbox image cannot forge the // way it could bake `ENV` values. + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn main_process_transport_preserves_argument_boundaries() { + let spec = crate::proto::compute::v1::DriverSandboxSpec { + command: vec!["/bin/sh".into(), "-c".into(), "printf '%s' 'a b'".into()], + tty: false, + await_main_process_attachment: true, + ..Default::default() + }; + let encoded = MainProcessConfig::encode_driver_spec(Some(&spec)).unwrap(); + let decoded = MainProcessConfig::decode(&encoded).unwrap(); + assert_eq!(decoded.command, spec.command); + assert!(!decoded.tty); + assert!(decoded.await_main_process_attachment); + } + + #[test] + fn base64url_main_process_transport_preserves_spaces() { + let spec = crate::proto::compute::v1::DriverSandboxSpec { + command: vec![ + "/bin/sh".into(), + "-c".into(), + "echo ready; while true; do sleep 1; done".into(), + ], + tty: false, + ..Default::default() + }; + let encoded = MainProcessConfig::encode_driver_spec_base64url(Some(&spec)).unwrap(); + + assert!(!encoded.contains(char::is_whitespace)); + let decoded = MainProcessConfig::decode(&encoded).unwrap(); + assert_eq!(decoded.command, spec.command); + assert!(!decoded.tty); + } + + #[test] + fn main_process_transport_rejects_unknown_version() { + let error = + MainProcessConfig::decode(r#"{"version":2,"command":["/bin/true"],"tty":false}"#) + .unwrap_err(); + assert!(error.contains("unsupported")); + } + + #[test] + fn legacy_driver_spec_without_command_uses_scratch_main() { + let legacy = crate::proto::compute::v1::DriverSandboxSpec::default(); + let config = MainProcessConfig::from_driver_spec(Some(&legacy)); + + assert_eq!(config, MainProcessConfig::scratch()); + let encoded = serde_json::to_string(&config).unwrap(); + assert_eq!(MainProcessConfig::decode(&encoded).unwrap(), config); + } + + #[test] + fn omitted_command_stays_empty_for_supervisor_resolution() { + // No command supplied → empty command; the supervisor resolves the + // default login shell against the sandbox image. + let empty = crate::proto::compute::v1::DriverSandboxSpec::default(); + assert!( + MainProcessConfig::from_driver_spec(Some(&empty)) + .command + .is_empty() + ); + assert!(MainProcessConfig::from_driver_spec(None).command.is_empty()); + + // An explicit command is preserved verbatim and never rewritten. + let explicit = crate::proto::compute::v1::DriverSandboxSpec { + command: vec!["/bin/bash".into(), "-l".into()], + tty: true, + ..Default::default() + }; + assert_eq!( + MainProcessConfig::from_driver_spec(Some(&explicit)).command, + vec!["/bin/bash".to_string(), "-l".to_string()] + ); + + // An empty command survives the transport round-trip. + let encoded = serde_json::to_string(&MainProcessConfig::scratch()).unwrap(); + assert!( + MainProcessConfig::decode(&encoded) + .unwrap() + .command + .is_empty() + ); + + // A present-but-blank program is still rejected. + assert!(MainProcessConfig::decode(r#"{"version":1,"command":[""],"tty":false}"#).is_err()); + } +} diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index e93bdc5390..0ec130bd8e 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -3,8 +3,9 @@ use crate::time::now_ms; use base64::Engine as _; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; +use std::sync::Arc; const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:"; const PROVIDER_ALIAS_MARKER: &str = "OPENSHELL-RESOLVE-ENV-"; @@ -12,6 +13,24 @@ const PROVIDER_ALIAS_MARKER: &str = "OPENSHELL-RESOLVE-ENV-"; /// Public access to the placeholder prefix for fail-closed scanning in other modules. pub const PLACEHOLDER_PREFIX_PUBLIC: &str = PLACEHOLDER_PREFIX; pub const PROVIDER_ALIAS_MARKER_PUBLIC: &str = PROVIDER_ALIAS_MARKER; +/// Longest wire form of a reserved marker: percent-encoding expands every +/// marker byte to three bytes (`%XX`), and detection decodes in a single pass. +const LONGEST_RESERVED_MARKER_WIRE_BYTES: usize = + 3 * if PLACEHOLDER_PREFIX.len() > PROVIDER_ALIAS_MARKER.len() { + PLACEHOLDER_PREFIX.len() + } else { + PROVIDER_ALIAS_MARKER.len() + }; + +/// Retain this many trailing bytes when scanning a streamed request body so a +/// reserved marker split across reads cannot be forwarded before detection. +/// +/// A marker is only detected while all of its wire bytes sit in the scan buffer +/// at once, so the retained window must hold every byte of the longest form but +/// the last. A window shorter than that lets a caller split a fully +/// percent-encoded marker so its leading bytes are forwarded before the rest +/// arrives, and the reassembled remainder no longer decodes to the marker. +pub const CREDENTIAL_MARKER_SCAN_TAIL_BYTES: usize = LONGEST_RESERVED_MARKER_WIRE_BYTES; /// Characters that are valid in an env var key name (used to extract /// placeholder boundaries within concatenated strings like path segments). @@ -35,27 +54,102 @@ pub fn contains_reserved_credential_marker(value: &str) -> bool { contains_raw_reserved_marker(&decoded) } +/// Return whether an HTTP header value contains syntax reserved for credential +/// placeholder rewriting, including the decoded value of Basic authentication. +pub fn header_value_contains_reserved_credential_marker(value: &str) -> bool { + let trimmed = value.trim(); + if contains_reserved_credential_marker(trimmed) { + return true; + } + + let Some(decoded) = decode_basic_auth_value(trimmed) else { + return false; + }; + contains_raw_reserved_marker(&decoded) +} + +fn basic_auth_token(value: &str) -> Option<&str> { + value + .strip_prefix("Basic ") + .or_else(|| value.strip_prefix("basic ")) + .map(str::trim) +} + +fn decode_basic_auth_value(value: &str) -> Option { + decode_basic_auth_token(basic_auth_token(value)?) +} + +fn decode_basic_auth_token(encoded: &str) -> Option { + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + String::from_utf8(decoded).ok() +} + +pub fn contains_reserved_credential_marker_bytes(value: &[u8]) -> bool { + if value.is_empty() { + return false; + } + String::from_utf8_lossy(value) + .split('\0') + .any(contains_reserved_credential_marker) +} + // --------------------------------------------------------------------------- // Error and result types // --------------------------------------------------------------------------- /// Error returned when a placeholder cannot be resolved or a resolved secret /// contains prohibited characters. -#[derive(Debug)] +#[derive(Debug, miette::Diagnostic)] pub struct UnresolvedPlaceholderError { pub location: &'static str, // "header", "query_param", "path" + reason: UnresolvedPlaceholderReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnresolvedPlaceholderReason { + Unavailable, + EndpointMismatch, +} + +impl UnresolvedPlaceholderError { + #[must_use] + pub fn unavailable(location: &'static str) -> Self { + unresolved(location) + } + + #[must_use] + pub fn is_endpoint_mismatch(&self) -> bool { + self.reason == UnresolvedPlaceholderReason::EndpointMismatch + } } impl fmt::Display for UnresolvedPlaceholderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "unresolved credential placeholder in {}: detected reserved credential token that could not be resolved", - self.location + "{} in {}", + match self.reason { + UnresolvedPlaceholderReason::Unavailable => + "credential placeholder could not be resolved", + UnresolvedPlaceholderReason::EndpointMismatch => + "credential is not authorized for the request endpoint", + }, + self.location, ) } } +impl std::error::Error for UnresolvedPlaceholderError {} + +fn unresolved(location: &'static str) -> UnresolvedPlaceholderError { + UnresolvedPlaceholderError { + location, + reason: UnresolvedPlaceholderReason::Unavailable, + } +} + /// Result of rewriting an HTTP header block with credential resolution. #[derive(Debug)] pub struct RewriteResult { @@ -86,11 +180,14 @@ pub struct RewriteTargetResult { #[derive(Clone, Default)] pub struct SecretResolver { by_placeholder: HashMap, + denied_env_keys: HashSet, + identity_bound_env_keys: HashSet, + revision_fallback_allowed_revisions: HashMap>>, } #[derive(Clone)] struct SecretValue { - value: String, + value: Arc, expires_at_ms: i64, } @@ -135,32 +232,73 @@ impl SecretResolver { credential_expires_at_ms: HashMap, revision: u64, ) -> (HashMap, Option, Option) { - if revision == 0 { - let (child_env, current_resolver) = - Self::from_provider_env_for_revision_with_current_aliases( - provider_env, - credential_expires_at_ms, - 0, - true, + Self::from_provider_env_for_current_revision_with_stable_handles( + provider_env, + credential_expires_at_ms, + revision, + &HashMap::new(), + ) + } + + /// Build workload environment and resolver snapshots with gateway-issued + /// stable handles for selected refresh-managed 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. + pub(crate) fn from_provider_env_for_current_revision_with_stable_handles( + provider_env: HashMap, + credential_expires_at_ms: HashMap, + revision: u64, + stable_handles: &HashMap, + ) -> (HashMap, Option, Option) { + let mut child_env = HashMap::with_capacity(provider_env.len()); + let mut generation_values = HashMap::new(); + let mut current_values = HashMap::new(); + + for (key, value) in provider_env { + if uses_reserved_revision_namespace(&key) { + tracing::warn!( + provider_env_key = %key, + "skipping provider credential env var in reserved placeholder namespace" ); - return (child_env, None, current_resolver); - } - let provider_env_for_current = provider_env.clone(); - let credential_expires_at_ms_for_current = credential_expires_at_ms.clone(); - let (child_env, revision_resolver) = - Self::from_provider_env_for_revision_with_current_aliases( - provider_env, - credential_expires_at_ms, - revision, - false, + continue; + } + let secret = SecretValue { + value: Arc::from(value), + expires_at_ms: credential_expires_at_ms + .get(&key) + .copied() + .unwrap_or_default(), + }; + let placeholder = stable_handles.get(&key).map_or_else( + || { + let placeholder = placeholder_for_env_key_for_revision(&key, revision); + if revision != 0 { + generation_values.insert(placeholder.clone(), secret.clone()); + } + placeholder + }, + |handle| placeholder_for_env_key_for_stable_handle(&key, handle), ); - let (_, current_resolver) = Self::from_provider_env_for_revision_with_current_aliases( - provider_env_for_current, - credential_expires_at_ms_for_current, - revision, - true, - ); - (child_env, revision_resolver, current_resolver) + child_env.insert(key.clone(), placeholder.clone()); + current_values.insert(placeholder, secret.clone()); + current_values.insert(placeholder_for_env_key(&key), secret); + } + + let resolver = |by_placeholder: HashMap| { + (!by_placeholder.is_empty()).then_some(Self { + by_placeholder, + denied_env_keys: HashSet::new(), + identity_bound_env_keys: HashSet::new(), + revision_fallback_allowed_revisions: HashMap::new(), + }) + }; + ( + child_env, + resolver(generation_values), + resolver(current_values), + ) } fn from_provider_env_for_revision_with_current_aliases( @@ -186,7 +324,7 @@ impl SecretResolver { } let placeholder = placeholder_for_env_key_for_revision(&key, revision); let secret = SecretValue { - value, + value: Arc::from(value), expires_at_ms: credential_expires_at_ms .get(&key) .copied() @@ -202,55 +340,171 @@ impl SecretResolver { if by_placeholder.is_empty() { (child_env, None) } else { - (child_env, Some(Self { by_placeholder })) + ( + child_env, + Some(Self { + by_placeholder, + denied_env_keys: HashSet::new(), + identity_bound_env_keys: HashSet::new(), + revision_fallback_allowed_revisions: HashMap::new(), + }), + ) } } pub fn merge<'a>(resolvers: impl IntoIterator) -> Option { let mut by_placeholder = HashMap::new(); + let mut denied_env_keys = HashSet::new(); + let mut identity_bound_env_keys = HashSet::new(); + let mut revision_fallback_allowed_revisions = HashMap::new(); for resolver in resolvers { by_placeholder.extend(resolver.by_placeholder.clone()); + denied_env_keys.extend(resolver.denied_env_keys.iter().cloned()); + identity_bound_env_keys.extend(resolver.identity_bound_env_keys.iter().cloned()); + revision_fallback_allowed_revisions + .extend(resolver.revision_fallback_allowed_revisions.clone()); } if by_placeholder.is_empty() { None } else { - Some(Self { by_placeholder }) + Some(Self { + by_placeholder, + denied_env_keys, + identity_bound_env_keys, + revision_fallback_allowed_revisions, + }) + } + } + + /// Return a cheap endpoint-scoped resolver view. + /// + /// Secret strings are shared through cloned resolver entries. Credential + /// keys listed in `bound_keys` resolve only when also present in + /// `allowed_bound_keys`; unbound provider configuration remains available. + #[must_use] + pub fn scoped_to_env_keys( + &self, + bound_keys: &HashSet, + allowed_bound_keys: &HashSet, + revision_fallback_allowed_revisions: HashMap>>, + ) -> Self { + let denied_env_keys = bound_keys + .difference(allowed_bound_keys) + .cloned() + .collect::>(); + let by_placeholder = self + .by_placeholder + .iter() + .filter(|(placeholder, _)| { + placeholder_env_key(placeholder).is_none_or(|key| !denied_env_keys.contains(key)) + }) + .map(|(placeholder, secret)| (placeholder.clone(), secret.clone())) + .collect(); + Self { + by_placeholder, + denied_env_keys, + identity_bound_env_keys: bound_keys.clone(), + revision_fallback_allowed_revisions, } } + fn unresolved_for( + &self, + location: &'static str, + placeholder: &str, + ) -> UnresolvedPlaceholderError { + let reason = if placeholder_env_key(placeholder) + .is_some_and(|key| self.denied_env_keys.contains(key)) + { + UnresolvedPlaceholderReason::EndpointMismatch + } else { + UnresolvedPlaceholderReason::Unavailable + }; + UnresolvedPlaceholderError { location, reason } + } + /// Resolve a placeholder string to the real secret value. /// /// 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> { + 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. + return None; + } let secret = if let Some(secret) = self.by_placeholder.get(value) { secret } else { - // Once an old generation ages out, the revision number is only a - // namespace marker. Fall back by key to the current credential so - // long-running child processes survive provider credential refresh. + // Once an old generation ages out, fall back by key to the current + // credential so long-running child processes survive provider + // 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))?; + if let Some((revision, key)) = revisioned_placeholder_parts(value) + && self.identity_bound_env_keys.contains(key) + && self + .revision_fallback_allowed_revisions + .get(key) + .is_none_or(|revisions| !revisions.contains(&revision)) + { + return None; + } let canonical = placeholder_for_env_key(key); self.by_placeholder.get(&canonical)? }; - if secret.expires_at_ms > 0 && secret.expires_at_ms <= now_ms() { - tracing::warn!( - location = "resolve_placeholder", - "credential resolution rejected: credential is expired" - ); - return None; + resolve_secret_value(secret) + } + + /// Resolve the current value for an environment key selected by trusted + /// supervisor code. + /// + /// Unlike [`Self::resolve_placeholder_checked`], this method accepts an + /// environment key rather than a user-provided placeholder token. Internal + /// request transforms such as `SigV4` can therefore select the endpoint-bound + /// current credential without making identityless placeholder aliases + /// available to sandbox request input. + pub fn resolve_current_env_key_checked( + &self, + key: &str, + location: &'static str, + ) -> Result, UnresolvedPlaceholderError> { + if self.denied_env_keys.contains(key) { + return Err(UnresolvedPlaceholderError { + location, + reason: UnresolvedPlaceholderReason::EndpointMismatch, + }); } - match validate_resolved_secret(&secret.value) { - Ok(s) => Some(s), - Err(reason) => { - tracing::warn!( - location = "resolve_placeholder", - reason, - "credential resolution rejected: resolved value contains prohibited characters" - ); - None - } + let placeholder = placeholder_for_env_key(key); + Ok(self + .by_placeholder + .get(&placeholder) + .and_then(resolve_secret_value)) + } + + /// Resolve a placeholder while preserving endpoint-denial information. + /// + /// `None` means the credential is genuinely unavailable. An endpoint-bound + /// key denied by the scoped resolver returns a typed mismatch so callers + /// can emit the security denial instead of treating it as missing config. + pub fn resolve_placeholder_checked( + &self, + value: &str, + location: &'static str, + ) -> Result, UnresolvedPlaceholderError> { + if placeholder_env_key(value).is_some_and(|key| self.denied_env_keys.contains(key)) { + return Err(UnresolvedPlaceholderError { + location, + reason: UnresolvedPlaceholderReason::EndpointMismatch, + }); } + Ok(self.resolve_placeholder(value)) } pub fn expires_at_ms_for_placeholder(&self, placeholder: &str) -> Option { @@ -272,10 +526,7 @@ impl SecretResolver { // Basic auth decoding: `Basic ` where the decoded content // contains a placeholder (e.g. `user:openshell:resolve:env:PASS`). - if let Some(encoded) = trimmed - .strip_prefix("Basic ") - .or_else(|| trimmed.strip_prefix("basic ")) - .map(str::trim) + if let Some(encoded) = basic_auth_token(trimmed) && let Some(rewritten) = self.rewrite_basic_auth_token(encoded)? { return Ok(Some(format!("Basic {rewritten}"))); @@ -284,7 +535,7 @@ impl SecretResolver { // Prefixed placeholder: `Bearer openshell:resolve:env:KEY` let Some(split_at) = trimmed.find(char::is_whitespace) else { if contains_reserved_credential_marker(trimmed) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(self.unresolved_for("header", trimmed)); } return Ok(None); }; @@ -295,7 +546,7 @@ impl SecretResolver { } if contains_reserved_credential_marker(candidate) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(self.unresolved_for("header", candidate)); } Ok(None) @@ -329,10 +580,10 @@ impl SecretResolver { if text[abs_start..].starts_with(PLACEHOLDER_PREFIX) { let Some((token_end, token)) = self.credential_token_at(text, abs_start) else { - return Err(UnresolvedPlaceholderError { location }); + return Err(unresolved(location)); }; let Some(secret) = self.resolve_placeholder(token) else { - return Err(UnresolvedPlaceholderError { location }); + return Err(self.unresolved_for(location, token)); }; rewritten.push_str(secret); replacements += 1; @@ -342,7 +593,7 @@ impl SecretResolver { if let Some((token_end, token)) = alias_token_at(text, abs_start) { let Some(secret) = self.resolve_placeholder(token) else { - return Err(UnresolvedPlaceholderError { location }); + return Err(self.unresolved_for(location, token)); }; rewritten.push_str(secret); replacements += 1; @@ -350,11 +601,11 @@ impl SecretResolver { continue; } - return Err(UnresolvedPlaceholderError { location }); + return Err(unresolved(location)); } if contains_raw_reserved_marker(&rewritten) { - return Err(UnresolvedPlaceholderError { location }); + return Err(unresolved(location)); } *text = rewritten; @@ -411,18 +662,15 @@ impl SecretResolver { encoded: &str, ) -> Result, UnresolvedPlaceholderError> { let b64 = base64::engine::general_purpose::STANDARD; - let Some(decoded_bytes) = b64.decode(encoded.trim()).ok() else { - return Ok(None); - }; - let Some(decoded) = std::str::from_utf8(&decoded_bytes).ok() else { + let Some(decoded) = decode_basic_auth_token(encoded.trim()) else { return Ok(None); }; - if !contains_raw_reserved_marker(decoded) { + if !contains_raw_reserved_marker(&decoded) { return Ok(None); } - let mut rewritten = decoded.to_string(); + let mut rewritten = decoded; let replacements = self.rewrite_text_placeholders(&mut rewritten, "header")?; if replacements == 0 { @@ -433,6 +681,27 @@ impl SecretResolver { } } +fn resolve_secret_value(secret: &SecretValue) -> Option<&str> { + if secret.expires_at_ms > 0 && secret.expires_at_ms <= now_ms() { + tracing::warn!( + location = "resolve_placeholder", + "credential resolution rejected: credential is expired" + ); + return None; + } + match validate_resolved_secret(secret.value.as_ref()) { + Ok(s) => Some(s), + Err(reason) => { + tracing::warn!( + location = "resolve_placeholder", + reason, + "credential resolution rejected: resolved value contains prohibited characters" + ); + None + } + } +} + fn alias_start_for_marker(text: &str, marker_abs: usize) -> usize { let mut start = marker_abs; let bytes = text.as_bytes(); @@ -490,13 +759,31 @@ fn alias_env_key(token: &str) -> Option<&str> { } fn revisioned_placeholder_env_key(token: &str) -> Option<&str> { + revisioned_placeholder_parts(token).map(|(_, key)| key) +} + +fn revisioned_placeholder_parts(token: &str) -> Option<(u64, &str)> { + let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?; + let (revision, key) = split_revisioned_env_key(suffix)?; + Some((revision.parse().ok()?, key)) +} + +fn stable_placeholder_parts(token: &str) -> Option<(&str, &str)> { let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?; - let (_, key) = split_revisioned_env_key(suffix)?; - Some(key) + let (handle, key) = split_stable_env_key(suffix)?; + Some((handle, key)) +} + +fn placeholder_env_key(token: &str) -> Option<&str> { + stable_placeholder_parts(token) + .map(|(_, key)| key) + .or_else(|| revisioned_placeholder_env_key(token)) + .or_else(|| token.strip_prefix(PLACEHOLDER_PREFIX)) + .or_else(|| alias_env_key(token)) } pub fn uses_reserved_revision_namespace(key: &str) -> bool { - split_revisioned_env_key(key).is_some() + split_revisioned_env_key(key).is_some() || split_stable_env_key(key).is_some() } fn split_revisioned_env_key(key: &str) -> Option<(&str, &str)> { @@ -512,6 +799,21 @@ fn split_revisioned_env_key(key: &str) -> Option<(&str, &str)> { Some((revision, env_key)) } +fn split_stable_env_key(key: &str) -> Option<(&str, &str)> { + let suffix = key.strip_prefix('s')?; + let (handle, env_key) = suffix.split_once('_')?; + if handle.len() != 64 + || !handle + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || env_key.is_empty() + || !env_key.bytes().all(is_env_key_char) + { + return None; + } + Some((handle, env_key)) +} + fn token_boundary_ok(text: &str, abs_start: usize, token_end: usize, token: &str) -> bool { if token.starts_with(PLACEHOLDER_PREFIX) { return token_end == text.len() @@ -535,6 +837,10 @@ pub fn placeholder_for_env_key_for_revision(key: &str, revision: u64) -> String } } +pub(crate) fn placeholder_for_env_key_for_stable_handle(key: &str, handle: &str) -> String { + format!("{PLACEHOLDER_PREFIX}s{handle}_{key}") +} + // --------------------------------------------------------------------------- // Secret validation (F1 — CWE-113) // --------------------------------------------------------------------------- @@ -836,7 +1142,7 @@ fn rewrite_path_segment( let Some((token_end, full_placeholder)) = canonical_token_at(segment, abs_start) .or_else(|| alias_token_at(segment, abs_start)) else { - return Err(UnresolvedPlaceholderError { location: "path" }); + return Err(unresolved("path")); }; if let Some(secret) = resolver.resolve_placeholder(full_placeholder) { validate_credential_for_path(secret).map_err(|reason| { @@ -845,12 +1151,12 @@ fn rewrite_path_segment( %reason, "credential resolution rejected: resolved value unsafe for path" ); - UnresolvedPlaceholderError { location: "path" } + unresolved("path") })?; resolved.push_str(secret); redacted.push_str("[CREDENTIAL]"); } else { - return Err(UnresolvedPlaceholderError { location: "path" }); + return Err(resolver.unresolved_for("path", full_placeholder)); } pos = token_end; } else { @@ -887,9 +1193,7 @@ fn rewrite_uri_query_params( let replacements = resolver.rewrite_text_placeholders(&mut rewritten, "query_param")?; if replacements == 0 || contains_raw_reserved_marker(&rewritten) { - return Err(UnresolvedPlaceholderError { - location: "query_param", - }); + return Err(unresolved("query_param")); } resolved_params.push(format!("{key}={}", percent_encode_query(&rewritten))); redacted_params.push(format!("{key}=[CREDENTIAL]")); @@ -941,7 +1245,7 @@ pub fn rewrite_http_header_block( .map_or(raw.len(), |p| raw.len().min(p + 4 + 256)); let header_region = String::from_utf8_lossy(&raw[..scan_end]); if contains_reserved_credential_marker(&header_region) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(unresolved("header")); } return Ok(RewriteResult { rewritten: raw.to_vec(), @@ -988,7 +1292,7 @@ pub fn rewrite_http_header_block( // provider-shaped aliases in both raw and percent-decoded header bytes. let output_header = String::from_utf8_lossy(&output[..output.len().min(header_end + 256)]); if contains_reserved_credential_marker(&output_header) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(unresolved("header")); } Ok(RewriteResult { @@ -1066,6 +1370,48 @@ pub fn rewrite_target_for_eval( Ok(RewriteTargetResult { resolved, redacted }) } +/// Produce the policy/logging representation of a request target without +/// consulting or materializing credential values. +/// +/// This validates placeholder syntax using the same URI-aware rewrite path as +/// upstream injection, but resolves every referenced key to a fixed redaction +/// marker. Callers can therefore select routes and evaluate policy before the +/// real endpoint-scoped resolver is touched. +pub fn redact_target_for_policy(target: &str) -> Result { + if !contains_reserved_credential_marker(target) { + return Ok(target.to_string()); + } + + let decoded = percent_decode(target); + let mut provider_env = HashMap::new(); + let mut pos = 0; + while pos < decoded.len() { + let next_canonical = decoded[pos..].find(PLACEHOLDER_PREFIX).map(|p| pos + p); + let next_alias = decoded[pos..] + .find(PROVIDER_ALIAS_MARKER) + .map(|marker_pos| alias_start_for_marker(&decoded, pos + marker_pos)); + let Some(start) = [next_canonical, next_alias].into_iter().flatten().min() else { + break; + }; + let Some((end, token)) = + canonical_token_at(&decoded, start).or_else(|| alias_token_at(&decoded, start)) + else { + return Err(unresolved("request_target")); + }; + let Some(key) = placeholder_env_key(token) else { + return Err(unresolved("request_target")); + }; + provider_env.insert(key.to_string(), "[CREDENTIAL]".to_string()); + pos = end; + } + + let (_, resolver) = SecretResolver::from_provider_env(provider_env); + let Some(resolver) = resolver else { + return Err(unresolved("request_target")); + }; + rewrite_target_for_eval(target, &resolver).map(|result| result.redacted) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1080,6 +1426,67 @@ mod tests { // === Existing tests (preserved) === + #[test] + fn byte_marker_detection_handles_raw_encoded_and_binary_input() { + assert!(contains_reserved_credential_marker_bytes( + b"openshell:resolve:env:API_TOKEN" + )); + assert!(contains_reserved_credential_marker_bytes( + b"openshell%3Aresolve%3Aenv%3AAPI_TOKEN" + )); + assert!(!contains_reserved_credential_marker_bytes(&[ + 0xff, 0x00, 0x01, 0x02 + ])); + } + + #[test] + fn header_value_marker_detection_covers_rewrite_forms() { + let basic = + base64::engine::general_purpose::STANDARD.encode("user:openshell:resolve:env:API_KEY"); + + for value in [ + "openshell:resolve:env:API_KEY".to_string(), + "Bearer openshell:resolve:env:API_KEY".to_string(), + "provider-OPENSHELL-RESOLVE-ENV-API_KEY".to_string(), + "openshell%3Aresolve%3Aenv%3AAPI_KEY".to_string(), + format!("Basic {basic}"), + ] { + assert!( + header_value_contains_reserved_credential_marker(&value), + "reserved credential marker in {value:?}" + ); + } + assert!(!header_value_contains_reserved_credential_marker( + "Bearer ordinary-token" + )); + } + + fn fully_percent_encoded(marker: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(marker.len() * 3); + for byte in marker.bytes() { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded + } + + #[test] + fn scan_tail_window_covers_longest_encoded_marker_form() { + for marker in [PLACEHOLDER_PREFIX, PROVIDER_ALIAS_MARKER] { + let encoded = fully_percent_encoded(marker); + assert!( + contains_reserved_credential_marker(&encoded), + "fully encoded {marker} must be detected" + ); + assert!( + CREDENTIAL_MARKER_SCAN_TAIL_BYTES >= encoded.len() - 1, + "scan window must retain every byte of {encoded} but the last" + ); + } + } + #[test] fn provider_env_is_replaced_with_placeholders() { let (child_env, resolver) = SecretResolver::from_provider_env( @@ -1117,8 +1524,12 @@ mod tests { fn reserved_revision_namespace_requires_version_and_key() { assert!(uses_reserved_revision_namespace("v10_GITHUB_TOKEN")); assert!(uses_reserved_revision_namespace("v999999_very_unlikely")); + assert!(uses_reserved_revision_namespace( + "saaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_GITHUB_TOKEN" + )); assert!(!uses_reserved_revision_namespace("v_GITHUB_TOKEN")); assert!(!uses_reserved_revision_namespace("v10_")); + assert!(!uses_reserved_revision_namespace("sshort_GITHUB_TOKEN")); assert!(!uses_reserved_revision_namespace("very_unlikely")); assert!(!uses_reserved_revision_namespace("GITHUB_TOKEN")); } @@ -2201,4 +2612,12 @@ mod tests { assert_eq!(result.resolved, "/bottok123/method?key=key456"); assert_eq!(result.redacted, "/bot[CREDENTIAL]/method?key=[CREDENTIAL]"); } + + #[test] + fn policy_target_redaction_never_requires_real_secret_material() { + let target = "/v1/openshell:resolve:env:v9_API_KEY/messages?token=provider-OPENSHELL-RESOLVE-ENV-API_KEY"; + let redacted = redact_target_for_policy(target).expect("valid placeholder syntax"); + assert_eq!(redacted, "/v1/[CREDENTIAL]/messages?token=[CREDENTIAL]"); + assert!(!redacted.contains("API_KEY")); + } } diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 156e4c3845..b7bf72c3d3 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -65,7 +65,7 @@ impl RegisteredSetting { /// /// 1. Add a [`RegisteredSetting`] entry to this array with the key name and /// [`SettingValueKind`]. -/// 2. Recompile `openshell-server` (gateway) and `openshell-sandbox` +/// 2. Recompile `openshell-gateway` and `openshell-sandbox` /// (supervisor). No database migration is needed -- new keys are stored in /// the existing settings JSON blob. /// 3. Add sandbox-side consumption in `openshell-sandbox` to read and act on @@ -74,8 +74,7 @@ impl RegisteredSetting { /// settable via `settings set`. The server validates that only registered /// keys are accepted. /// 5. Add a unit test in this module's `tests` section to cover the new key. -pub const PROVIDERS_V2_ENABLED_KEY: &str = "providers_v2_enabled"; - +/// /// Sandbox-level opt-in for the agent-driven policy proposal surface. /// /// When true, the supervisor installs the `policy_advisor` skill, serves @@ -108,14 +107,7 @@ pub const PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode"; pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"]; pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ - // Gateway-level opt-in for provider profile policy composition. Defaults - // to false when unset. - RegisteredSetting { - key: PROVIDERS_V2_ENABLED_KEY, - kind: SettingValueKind::Bool, - allowed_string_values: None, - }, - // When true the sandbox writes OCSF v1.7.0 JSONL records to + // When true the sandbox writes OCSF v1.8.0 JSONL records to // `/var/log/openshell-ocsf*.log` (daily rotation, 3 files) in addition // to the human-readable shorthand log. Defaults to false (no JSONL written). RegisteredSetting { @@ -168,9 +160,8 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, PROVIDERS_V2_ENABLED_KEY, - REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, - registered_keys_csv, setting_for_key, + PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, REGISTERED_SETTINGS, + RegisteredSetting, SettingValueKind, parse_bool_like, registered_keys_csv, setting_for_key, }; #[test] @@ -185,18 +176,16 @@ mod tests { } #[test] - fn setting_for_key_returns_providers_v2_enabled() { - let setting = setting_for_key(PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled should be registered"); - assert_eq!(setting.kind, SettingValueKind::Bool); + fn setting_for_key_rejects_removed_providers_v2_enabled() { + assert!(setting_for_key("providers_v2_enabled").is_none()); } // ---- RegisteredSetting::validate_string_value ---- #[test] fn validate_string_value_accepts_anything_when_unconstrained() { - let setting = setting_for_key(PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled should be registered"); + let setting = + setting_for_key("ocsf_json_enabled").expect("ocsf_json_enabled should be registered"); // Bool-kind entries currently leave `allowed_string_values = None`; // the helper still returns Ok for arbitrary strings. assert!(setting.validate_string_value("anything").is_ok()); diff --git a/crates/openshell-core/src/shell.rs b/crates/openshell-core/src/shell.rs new file mode 100644 index 0000000000..09610afe1a --- /dev/null +++ b/crates/openshell-core/src/shell.rs @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Login-shell resolution for sandbox images. +//! +//! The default sandbox command and the interactive SSH session need a shell, +//! but not every base image ships the same one. Debian-based images provide +//! `bash`; minimal images such as Alpine only provide `/bin/sh` (`BusyBox` +//! `ash`). Hard-coding `/bin/bash` makes sandbox startup fail on those images +//! with an opaque `No such file or directory`. +//! +//! These helpers resolve a shell that actually exists in the current root +//! filesystem. They must run inside the sandbox (i.e. in the supervisor), not +//! on the gateway, because the answer depends on the sandbox image's contents. + +/// Preferred interactive shell when the image provides it. +pub const BASH: &str = "/bin/bash"; + +/// Preferred interactive shell on `usr`-merged images where `/bin` is not a +/// top-level directory. +pub const USR_BASH: &str = "/usr/bin/bash"; + +/// POSIX shell. Guaranteed on virtually every image, including Alpine/`BusyBox`. +/// Used as the ultimate fallback. +pub const POSIX_SH: &str = "/bin/sh"; + +/// Shell paths tried, in preference order, by [`detect_login_shell`]. +pub const SHELL_CANDIDATES: &[&str] = &[BASH, USR_BASH, POSIX_SH]; + +/// Return `true` if `path` is a regular, executable file in the current root +/// filesystem. +#[must_use] +pub fn is_executable(path: &str) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// Resolve a login shell that exists in the current root filesystem. +/// +/// Tries [`SHELL_CANDIDATES`] in order and falls back to [`POSIX_SH`]. Because +/// this inspects the filesystem, call it from the supervisor (inside the +/// sandbox), never on the gateway. +/// +/// `$SHELL` is intentionally not consulted: it is image/user-controlled, the +/// result is later invoked with `-lc`, and an executable that is not a +/// compatible shell (e.g. `SHELL=/bin/false`) would pass the executable check +/// yet break command execution. Resolving only from known shell paths avoids +/// that footgun. +#[must_use] +pub fn detect_login_shell() -> String { + SHELL_CANDIDATES + .iter() + .find(|candidate| is_executable(candidate)) + .map_or_else( + || POSIX_SH.to_string(), + |candidate| (*candidate).to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // These assume a Unix root filesystem (`/bin/sh`, POSIX permission bits) and + // are skipped on the Windows workspace lane where openshell-core also builds. + #[cfg(unix)] + #[test] + fn posix_sh_exists_on_test_host() { + // /bin/sh is present on all supported Unix CI hosts (Linux and macOS). + assert!(is_executable(POSIX_SH)); + } + + #[test] + fn missing_path_is_not_executable() { + assert!(!is_executable("/nonexistent/definitely/not/here")); + } + + #[cfg(unix)] + #[test] + fn non_file_is_not_executable() { + // A directory is not an executable shell. + assert!(!is_executable("/")); + } + + #[cfg(unix)] + #[test] + fn detect_returns_an_executable_shell() { + let shell = detect_login_shell(); + assert!( + is_executable(&shell), + "detected shell {shell} is not executable" + ); + } +} diff --git a/crates/openshell-core/src/spiffe.rs b/crates/openshell-core/src/spiffe.rs new file mode 100644 index 0000000000..c288dfd81a --- /dev/null +++ b/crates/openshell-core/src/spiffe.rs @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared SPIFFE helpers used by the gateway and sandbox supervisor. + +use std::path::Path; + +use base64::Engine as _; +use serde::Deserialize; + +/// SPIFFE JWT-SVID claims used by `OpenShell` token exchange flows. +#[derive(Debug, Clone, Deserialize)] +pub struct SpiffeJwtClaims { + pub iss: String, + pub sub: String, + pub aud: AudienceClaim, + #[serde(default)] + pub exp: i64, +} + +/// JWT `aud` claim representation accepted by SPIFFE JWT-SVIDs. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum AudienceClaim { + One(String), + Many(Vec), +} + +impl AudienceClaim { + pub fn contains(&self, expected: &str) -> bool { + match self { + Self::One(value) => value == expected, + Self::Many(values) => values.iter().any(|value| value == expected), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum JwtSvidParseError { + #[error("invalid JWT-SVID format")] + Format, + #[error("invalid JWT-SVID payload encoding")] + PayloadEncoding, + #[error("invalid JWT-SVID payload")] + Payload, +} + +/// Convert a path to a SPIFFE Workload API endpoint URL. +/// +/// If the path already has a scheme (`unix:` or `tcp:`), use it as-is. +/// Otherwise, assume it is a Unix socket path and prepend `unix:`. +pub fn workload_api_endpoint(path: &Path) -> String { + let path = path.to_string_lossy(); + if path.starts_with("unix:") || path.starts_with("tcp:") { + path.into_owned() + } else { + format!("unix:{path}") + } +} + +/// # Security +/// +/// This function decodes the JWT payload without verifying the signature. +/// Callers must separately verify the JWT signature before trusting the claims. +pub fn parse_unverified_jwt_svid_claims(token: &str) -> Result { + let segments = token.split('.').collect::>(); + if segments.len() != 3 || segments.iter().any(|segment| segment.is_empty()) { + return Err(JwtSvidParseError::Format); + } + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segments[1]) + .map_err(|_| JwtSvidParseError::PayloadEncoding)?; + serde_json::from_slice::(&decoded).map_err(|_| JwtSvidParseError::Payload) +} + +pub fn trust_domain(subject: &str) -> Option<&str> { + let rest = subject.strip_prefix("spiffe://")?; + let (trust_domain, _) = rest.split_once('/').unwrap_or((rest, "")); + (!trust_domain.is_empty()).then_some(trust_domain) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unsigned_svid_fixture(issuer: &str, subject: &str, audience: serde_json::Value) -> String { + let header = serde_json::json!({ "alg": "RS256", "kid": "test-key" }); + let payload = serde_json::json!({ + "iss": issuer, + "sub": subject, + "aud": audience, + "exp": 4_102_444_800_i64 + }); + let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).expect("serialize header")); + let encoded_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).expect("serialize payload")); + format!("{encoded_header}.{encoded_payload}.signature") + } + + #[test] + fn workload_api_endpoint_preserves_explicit_scheme() { + assert_eq!( + workload_api_endpoint(Path::new("unix:/run/spire/agent.sock")), + "unix:/run/spire/agent.sock" + ); + assert_eq!( + workload_api_endpoint(Path::new("tcp:127.0.0.1:8081")), + "tcp:127.0.0.1:8081" + ); + } + + #[test] + fn workload_api_endpoint_defaults_to_unix_socket() { + assert_eq!( + workload_api_endpoint(Path::new("/run/spire/agent.sock")), + "unix:/run/spire/agent.sock" + ); + } + + #[test] + fn parse_unverified_jwt_svid_claims_accepts_string_audience() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/openshell/sandbox/sb-a", + serde_json::json!("https://auth.example.com"), + ); + + let claims = parse_unverified_jwt_svid_claims(&token).expect("valid claims"); + + assert_eq!(claims.iss, "https://spiffe.example.test"); + assert_eq!(claims.sub, "spiffe://openshell/openshell/sandbox/sb-a"); + assert!(claims.aud.contains("https://auth.example.com")); + assert!(!claims.aud.contains("https://other.example.com")); + } + + #[test] + fn parse_unverified_jwt_svid_claims_accepts_array_audience() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/openshell/sandbox/sb-a", + serde_json::json!(["https://auth.example.com", "https://other.example.com"]), + ); + + let claims = parse_unverified_jwt_svid_claims(&token).expect("valid claims"); + + assert!(claims.aud.contains("https://auth.example.com")); + assert!(claims.aud.contains("https://other.example.com")); + } + + #[test] + fn parse_unverified_jwt_svid_claims_rejects_truncated_jwt() { + assert!(matches!( + parse_unverified_jwt_svid_claims("header.payload"), + Err(JwtSvidParseError::Format) + )); + } + + #[test] + fn parse_unverified_jwt_svid_claims_rejects_empty_jwt_segments() { + assert!(matches!( + parse_unverified_jwt_svid_claims("header..signature"), + Err(JwtSvidParseError::Format) + )); + } + + #[test] + fn trust_domain_extracts_domain_from_spiffe_id() { + assert_eq!( + trust_domain("spiffe://openshell/openshell/sandbox/sb-a"), + Some("openshell") + ); + assert_eq!(trust_domain("spiffe://openshell"), Some("openshell")); + assert_eq!(trust_domain("not-a-spiffe-id"), None); + assert_eq!(trust_domain("spiffe:///empty"), None); + } +} diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index 49ce620f4f..63aa5f9181 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -103,6 +103,8 @@ impl LifecycleResource { pub enum LifecycleOperation { Create, Delete, + Stop, + Start, Update, } @@ -112,6 +114,8 @@ impl LifecycleOperation { match self { Self::Create => "create", Self::Delete => "delete", + Self::Stop => "stop", + Self::Start => "start", Self::Update => "update", } } @@ -141,6 +145,7 @@ impl PolicyDecisionOperation { pub enum SandboxTemplateSource { Default, Image, + WorkloadTemplate, Undefined, } @@ -150,52 +155,41 @@ impl SandboxTemplateSource { match self { Self::Default => "default", Self::Image => "image", + Self::WorkloadTemplate => "workload_template", Self::Undefined => "undefined", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TelemetryComputeDriver { - Docker, - Kubernetes, - Podman, - Vm, - Unknown, -} +pub struct TelemetryComputeDriver(&'static str); impl TelemetryComputeDriver { #[must_use] pub const fn as_str(self) -> &'static str { - match self { - Self::Docker => "docker", - Self::Kubernetes => "kubernetes", - Self::Podman => "podman", - Self::Vm => "vm", - Self::Unknown => "unknown", - } + self.0 } + /// Classify an unregistered compute driver without exposing its configured + /// name. #[must_use] - pub fn from_raw(raw: &str) -> Self { - match raw.trim().to_ascii_lowercase().as_str() { - "docker" => Self::Docker, - "k8s" | "kubernetes" => Self::Kubernetes, - "podman" => Self::Podman, - "vm" => Self::Vm, - _ => Self::Unknown, - } + pub const fn custom() -> Self { + Self("custom") } + /// Define a bounded, anonymous category at a binary composition boundary. + /// + /// The category must be a static operational label. Never construct it + /// from user input, configuration, resource names, or other runtime data. #[must_use] - pub const fn from_driver_kind(driver_kind: Option) -> Self { - match driver_kind { - Some(crate::ComputeDriverKind::Docker) => Self::Docker, - Some(crate::ComputeDriverKind::Kubernetes) => Self::Kubernetes, - Some(crate::ComputeDriverKind::Podman) => Self::Podman, - Some(crate::ComputeDriverKind::Vm) => Self::Vm, - None => Self::Unknown, - } + pub const fn anonymous_category(category: &'static str) -> Self { + Self(category) + } +} + +impl Default for TelemetryComputeDriver { + fn default() -> Self { + Self::custom() } } @@ -680,27 +674,11 @@ mod tests { } #[test] - fn compute_driver_values_are_sanitized() { - assert_eq!( - TelemetryComputeDriver::from_raw("docker").as_str(), - "docker" - ); - assert_eq!( - TelemetryComputeDriver::from_raw("k8s").as_str(), - "kubernetes" - ); - assert_eq!( - TelemetryComputeDriver::from_raw("KUBERNETES").as_str(), - "kubernetes" - ); - assert_eq!(TelemetryComputeDriver::from_raw("vm").as_str(), "vm"); - assert_eq!( - TelemetryComputeDriver::from_raw("podman").as_str(), - "podman" - ); + fn compute_driver_values_are_bounded_by_the_composition_boundary() { + assert_eq!(TelemetryComputeDriver::custom().as_str(), "custom"); assert_eq!( - TelemetryComputeDriver::from_raw("private-driver").as_str(), - "unknown" + TelemetryComputeDriver::anonymous_category("first_party").as_str(), + "first_party" ); } @@ -789,7 +767,7 @@ mod disabled_tests { 1, false, SandboxTemplateSource::Default, - TelemetryComputeDriver::Docker, + TelemetryComputeDriver::custom(), ); emit_policy_decision( PolicyDecisionOperation::Approve, diff --git a/crates/openshell-driver-db-credstore/Cargo.toml b/crates/openshell-driver-db-credstore/Cargo.toml new file mode 100644 index 0000000000..805cb1c7d4 --- /dev/null +++ b/crates/openshell-driver-db-credstore/Cargo.toml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-db-credstore" +description = "Encrypted database credential storage driver for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +async-trait = "0.1" +base64 = { workspace = true } +futures = { workspace = true } +ring = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +toml = { workspace = true } +tonic = { workspace = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-driver-db-credstore/src/lib.rs b/crates/openshell-driver-db-credstore/src/lib.rs new file mode 100644 index 0000000000..24c21e993f --- /dev/null +++ b/crates/openshell-driver-db-credstore/src/lib.rs @@ -0,0 +1,1224 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Encrypted database-backed credential storage driver. +//! +//! The driver persists encrypted credential envelopes through a caller-provided +//! object store. `openshell-server` supplies the object-store adapter for the +//! gateway database, while this crate owns the credential driver behavior and +//! envelope cryptography. + +use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as BASE64_NO_PAD}, +}; +use openshell_core::proto::CredentialHandle; +use openshell_core::proto::credentials::v1::{ + DeleteCredentialRequest, ResolveCredentialRequest, ResolvedCredential, StoreCredentialRequest, +}; +use openshell_core::{Error, Result as CoreResult}; +use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; +use ring::rand::{SecureRandom, SystemRandom}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tonic::Status; + +const HANDLE_VERSION: &str = "v1"; +const ENVELOPE_VERSION: u32 = 1; +const KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; +const HANDLE_ID_LEN: usize = 64; +const ALGORITHM: &str = "AES-256-GCM"; +const DEFAULT_KEY_ENCRYPTION_KEY_FILE: &str = "key-encryption-key.bin"; + +pub const DRIVER_NAME: &str = "openshell-driver-db-credstore"; +pub const OBJECT_TYPE: &str = "credential.gateway-encrypted"; +const CONFLICT_RETRY_LIMIT: u32 = 3; + +#[derive(Debug, Clone)] +pub struct DbCredstoreCredentialDriver { + store: Arc, + crypto: EncryptedGatewayCredentialStoreCrypto, +} + +#[async_trait] +pub trait DbCredstoreObjectStore: std::fmt::Debug + Send + Sync { + async fn get_credential_object( + &self, + object_type: &str, + id: &str, + operation: &'static str, + ) -> Result, Status>; + + async fn put_credential_object( + &self, + write: CredentialObjectWrite, + operation: &'static str, + ) -> Result<(), Status>; + + async fn delete_credential_object( + &self, + object_type: &str, + id: &str, + expected_resource_version: u64, + operation: &'static str, + ) -> Result<(), Status>; +} + +#[derive(Debug, Clone)] +pub struct StoredCredentialObject { + pub object_type: String, + pub id: String, + pub payload: Vec, + pub resource_version: u64, +} + +#[derive(Debug, Clone)] +pub struct CredentialObjectWrite { + pub object_type: String, + pub id: String, + pub name: String, + pub payload: Vec, + pub labels: Option, + pub condition: DbCredstoreWriteCondition, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DbCredstoreWriteCondition { + MustCreate, + MatchResourceVersion(u64), +} + +#[derive(Clone)] +pub struct EncryptedGatewayCredentialStoreCrypto { + state: EncryptedGatewayCredentialState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EncryptedGatewayCredentialSettings { + key_encryption_key_path: Option, + key_encryption_key_env: Option, +} + +#[derive(Clone)] +struct EncryptedGatewayCredentialState { + settings: EncryptedGatewayCredentialSettings, + key_encryption_key: [u8; KEY_LEN], + key_encryption_key_id: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct EncryptedGatewayCredentialConfig { + key_encryption_key_path: Option, + key_encryption_key_env: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct EncryptedCredentialEnvelope { + version: u32, + id: String, + provider_name: String, + credential_key: String, + algorithm: String, + key_encryption_key_id: String, + wrapped_dek: EncryptedBytes, + value: EncryptedBytes, +} + +#[derive(Debug, Serialize, Deserialize)] +struct EncryptedBytes { + nonce: String, + ciphertext: String, +} + +impl DbCredstoreCredentialDriver { + pub const NAME: &'static str = DRIVER_NAME; + pub const OBJECT_TYPE: &'static str = OBJECT_TYPE; + + pub fn from_config( + store: Arc, + config: &toml::Table, + ) -> CoreResult { + Ok(Self { + store, + crypto: EncryptedGatewayCredentialStoreCrypto::from_config(config)?, + }) + } + + pub async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + let credential_key = EncryptedGatewayCredentialStoreCrypto::validate_credential_key( + &request.credential_key, + )? + .to_string(); + let provider_name = + EncryptedGatewayCredentialStoreCrypto::validate_provider_name(&request.provider_name)? + .to_string(); + + if let Some(existing_handle) = request.existing_handle.as_ref() { + let id = EncryptedGatewayCredentialStoreCrypto::id_from_handle(existing_handle)?; + let existing = self + .store + .get_credential_object(OBJECT_TYPE, &id, "load existing credential") + .await?; + if let Some(record) = existing { + let envelope = deserialize_credential_envelope(&record)?; + EncryptedGatewayCredentialStoreCrypto::ensure_envelope_owner( + &envelope, + &id, + &provider_name, + &credential_key, + )?; + self.write_envelope( + &id, + &provider_name, + &credential_key, + &request.value, + DbCredstoreWriteCondition::MatchResourceVersion(record.resource_version), + ) + .await?; + } else { + self.write_envelope( + &id, + &provider_name, + &credential_key, + &request.value, + DbCredstoreWriteCondition::MustCreate, + ) + .await?; + } + return self.crypto.credential_handle(&id); + } + + for _ in 0..16 { + let id = EncryptedGatewayCredentialStoreCrypto::new_handle_id()?; + match self + .write_envelope( + &id, + &provider_name, + &credential_key, + &request.value, + DbCredstoreWriteCondition::MustCreate, + ) + .await + { + Ok(()) => return self.crypto.credential_handle(&id), + Err(err) if err.code() == tonic::Code::AlreadyExists => {} + Err(err) => return Err(err), + } + } + + Err(Status::unavailable( + "failed to allocate unused default credential handle", + )) + } + + pub async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + let handle = + EncryptedGatewayCredentialStoreCrypto::handle_from_request("delete", request.handle)?; + let id = EncryptedGatewayCredentialStoreCrypto::id_from_handle(&handle)?; + let provider_name = + EncryptedGatewayCredentialStoreCrypto::validate_provider_name(&request.provider_name)?; + let credential_key = EncryptedGatewayCredentialStoreCrypto::validate_credential_key( + &request.credential_key, + )?; + + for _attempt in 0..CONFLICT_RETRY_LIMIT { + let record = self + .store + .get_credential_object(OBJECT_TYPE, &id, "load credential for deletion") + .await?; + let Some(record) = record else { + return Ok(()); + }; + + let envelope = deserialize_credential_envelope(&record)?; + EncryptedGatewayCredentialStoreCrypto::ensure_envelope_owner( + &envelope, + &id, + provider_name, + credential_key, + )?; + + match self + .store + .delete_credential_object( + OBJECT_TYPE, + &id, + record.resource_version, + "delete credential", + ) + .await + { + Ok(()) => return Ok(()), + Err(err) if err.code() == tonic::Code::Aborted => {} + Err(err) => return Err(err), + } + } + Err(Status::aborted(format!( + "credential '{id}' was modified concurrently; exceeded retry limit" + ))) + } + + pub async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + let futures = requests.into_iter().map(|request| async move { + let handle = EncryptedGatewayCredentialStoreCrypto::handle_from_request( + &request.request_id, + request.handle, + )?; + let id = EncryptedGatewayCredentialStoreCrypto::id_from_handle(&handle)?; + let record = self + .store + .get_credential_object(OBJECT_TYPE, &id, "load credential") + .await? + .ok_or_else(|| { + Status::not_found(format!("default credential '{id}' was not found")) + })?; + let envelope = deserialize_credential_envelope(&record)?; + EncryptedGatewayCredentialStoreCrypto::ensure_envelope_owner( + &envelope, + &id, + EncryptedGatewayCredentialStoreCrypto::validate_provider_name( + &request.provider_name, + )?, + EncryptedGatewayCredentialStoreCrypto::validate_credential_key( + &request.credential_key, + )?, + )?; + let value = self.crypto.decrypt_envelope(&envelope)?; + Ok::<_, Status>(ResolvedCredential { + request_id: request.request_id, + value, + expires_at_ms: 0, + }) + }); + futures::future::try_join_all(futures).await + } + + async fn write_envelope( + &self, + id: &str, + provider_name: &str, + credential_key: &str, + value: &str, + condition: DbCredstoreWriteCondition, + ) -> Result<(), Status> { + let envelope = self + .crypto + .encrypt_envelope(id, provider_name, credential_key, value)?; + let payload = EncryptedGatewayCredentialStoreCrypto::serialize_envelope(&envelope)?; + let labels = credential_labels(provider_name, credential_key)?; + + self.store + .put_credential_object( + CredentialObjectWrite { + object_type: OBJECT_TYPE.to_string(), + id: id.to_string(), + name: id.to_string(), + payload, + labels: Some(labels), + condition, + }, + "persist credential", + ) + .await + } +} + +impl EncryptedGatewayCredentialStoreCrypto { + pub fn from_config(config: &toml::Table) -> CoreResult { + let settings = EncryptedGatewayCredentialSettings::from_table(config)?; + Ok(Self { + state: EncryptedGatewayCredentialState::from_settings(settings)?, + }) + } + + pub fn new_handle_id() -> Result { + new_handle_id() + } + + pub fn credential_handle(&self, id: &str) -> Result { + validate_handle_id(id)?; + Ok(credential_handle(&self.state, id)) + } + + pub fn handle_from_request( + request_id: &str, + handle: Option, + ) -> Result { + let handle = handle.ok_or_else(|| { + Status::invalid_argument(format!( + "default credential storage request '{request_id}' is missing handle" + )) + })?; + validate_handle_owner(&handle)?; + Ok(handle) + } + + pub fn id_from_handle(handle: &CredentialHandle) -> Result { + validate_handle_owner(handle)?; + let id = handle + .handle + .strip_prefix(&format!("{HANDLE_VERSION}:")) + .ok_or_else(|| { + Status::invalid_argument("default credential storage handle is malformed") + })?; + validate_handle_id(id)?; + Ok(id.to_string()) + } + + pub fn encrypt_envelope( + &self, + id: &str, + provider_name: &str, + credential_key: &str, + value: &str, + ) -> Result { + encrypt_envelope(&self.state, id, provider_name, credential_key, value) + } + + pub fn decrypt_envelope( + &self, + envelope: &EncryptedCredentialEnvelope, + ) -> Result { + decrypt_envelope(&self.state, envelope) + } + + pub fn ensure_envelope_owner( + envelope: &EncryptedCredentialEnvelope, + id: &str, + provider_name: &str, + credential_key: &str, + ) -> Result<(), Status> { + ensure_envelope_owner(envelope, id, provider_name, credential_key) + } + + pub fn validate_provider_name(value: &str) -> Result<&str, Status> { + validate_provider_name(value) + } + + pub fn validate_credential_key(value: &str) -> Result<&str, Status> { + validate_credential_key(value) + } + + pub fn serialize_envelope(envelope: &EncryptedCredentialEnvelope) -> Result, Status> { + serialize_envelope(envelope) + } + + pub fn deserialize_envelope( + bytes: &[u8], + description: impl std::fmt::Display, + ) -> Result { + serde_json::from_slice(bytes).map_err(|err| { + Status::data_loss(format!( + "default credential storage object '{description}' has invalid envelope JSON: {err}" + )) + }) + } +} + +impl std::fmt::Debug for EncryptedGatewayCredentialStoreCrypto { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncryptedGatewayCredentialStoreCrypto") + .field("settings", &self.state.settings) + .field("key_encryption_key_id", &self.state.key_encryption_key_id) + .finish_non_exhaustive() + } +} + +impl EncryptedGatewayCredentialSettings { + fn from_table(config: &toml::Table) -> CoreResult { + let config: EncryptedGatewayCredentialConfig = toml::Value::Table(config.clone()) + .try_into() + .map_err(|err| { + Error::config(format!( + "invalid [openshell.gateway.credential_storage]: {err}" + )) + })?; + + if config.key_encryption_key_path.is_some() && config.key_encryption_key_env.is_some() { + return Err(Error::config( + "[openshell.gateway.credential_storage] set only one of key_encryption_key_path or key_encryption_key_env", + )); + } + + let key_encryption_key_path = match config.key_encryption_key_path { + Some(path) => Some(validate_path("key_encryption_key_path", path)?), + None if config.key_encryption_key_env.is_some() => None, + None => Some(default_key_encryption_key_path()?), + }; + let key_encryption_key_env = config + .key_encryption_key_env + .map(|name| validate_env_name("key_encryption_key_env", &name)) + .transpose()?; + + Ok(Self { + key_encryption_key_path, + key_encryption_key_env, + }) + } +} + +impl EncryptedGatewayCredentialState { + fn from_settings(settings: EncryptedGatewayCredentialSettings) -> CoreResult { + let key_encryption_key = load_key_encryption_key(&settings)?; + let key_encryption_key_id = key_id(&key_encryption_key); + Ok(Self { + settings, + key_encryption_key, + key_encryption_key_id, + }) + } +} + +fn default_key_encryption_key_path() -> CoreResult { + let state_dir = openshell_core::paths::openshell_state_dir().map_err(|err| { + Error::config(format!( + "failed to resolve default credential storage key-encryption key path: {err}" + )) + })?; + Ok(state_dir + .join("gateway") + .join("credentials") + .join(DEFAULT_KEY_ENCRYPTION_KEY_FILE)) +} + +fn validate_path(field_name: &str, path: PathBuf) -> CoreResult { + if path.as_os_str().is_empty() { + return Err(Error::config(format!( + "[openshell.gateway.credential_storage] {field_name} must not be empty" + ))); + } + if !path.is_absolute() { + return Err(Error::config(format!( + "[openshell.gateway.credential_storage] {field_name} must be absolute" + ))); + } + Ok(path) +} + +fn validate_env_name(field_name: &str, value: &str) -> CoreResult { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.len() != value.len() { + return Err(Error::config(format!( + "[openshell.gateway.credential_storage] {field_name} must not be empty or contain surrounding whitespace" + ))); + } + if !trimmed + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(Error::config(format!( + "[openshell.gateway.credential_storage] {field_name} must name an environment variable using only letters, digits, and underscores" + ))); + } + Ok(trimmed.to_string()) +} + +fn load_key_encryption_key( + settings: &EncryptedGatewayCredentialSettings, +) -> CoreResult<[u8; KEY_LEN]> { + if let Some(env_name) = &settings.key_encryption_key_env { + let value = std::env::var(env_name).map_err(|_| { + Error::config(format!( + "[openshell.gateway.credential_storage] environment variable '{env_name}' is not set" + )) + })?; + return decode_key_encryption_key_base64(&value).map_err(Error::config); + } + let path = settings + .key_encryption_key_path + .as_ref() + .expect("settings always has key_encryption_key_path unless key_encryption_key_env is set"); + load_or_create_file_key_encryption_key(path) +} + +fn decode_key_encryption_key_base64(value: &str) -> Result<[u8; KEY_LEN], String> { + let trimmed = value.trim(); + let bytes = BASE64 + .decode(trimmed) + .or_else(|_| BASE64_NO_PAD.decode(trimmed)) + .map_err(|err| { + format!("key_encryption_key_env value must be base64-encoded 32-byte key: {err}") + })?; + fixed_bytes::(&bytes) + .map_err(|()| "key_encryption_key_env value must decode to exactly 32 bytes".to_string()) +} + +fn load_or_create_file_key_encryption_key(path: &Path) -> CoreResult<[u8; KEY_LEN]> { + match fs::read(path) { + Ok(bytes) => { + openshell_core::paths::set_file_owner_only(path).map_err(|err| { + Error::config(format!( + "failed to restrict default credential storage key-encryption key '{}': {err}", + path.display() + )) + })?; + return fixed_bytes::(&bytes).map_err(|()| { + Error::config(format!( + "[openshell.gateway.credential_storage] key_encryption_key_path '{}' must contain exactly 32 bytes", + path.display() + )) + }); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(Error::config(format!( + "failed to read default credential storage key-encryption key '{}': {err}", + path.display() + ))); + } + } + + openshell_core::paths::ensure_parent_dir_restricted(path).map_err(|err| { + Error::config(format!( + "failed to prepare default credential storage key-encryption key directory '{}': {err}", + path.display() + )) + })?; + let key_encryption_key = random_bytes_core::()?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + match options.open(path) { + Ok(mut file) => { + if let Err(err) = file.write_all(&key_encryption_key) { + let _ = fs::remove_file(path); + return Err(Error::config(format!( + "failed to write default credential storage key-encryption key '{}': {err}", + path.display() + ))); + } + openshell_core::paths::set_file_owner_only(path).map_err(|err| { + Error::config(format!( + "failed to restrict default credential storage key-encryption key '{}': {err}", + path.display() + )) + })?; + Ok(key_encryption_key) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + load_or_create_file_key_encryption_key(path) + } + Err(err) => Err(Error::config(format!( + "failed to create default credential storage key-encryption key '{}': {err}", + path.display() + ))), + } +} + +fn new_handle_id() -> Result { + Ok(hex_encode(&random_bytes_status::()?)) +} + +fn credential_handle(state: &EncryptedGatewayCredentialState, id: &str) -> CredentialHandle { + CredentialHandle { + driver: DRIVER_NAME.to_string(), + handle: format!("{HANDLE_VERSION}:{id}"), + metadata: [ + ("algorithm".to_string(), ALGORITHM.to_string()), + ( + "key_encryption_key_id".to_string(), + state.key_encryption_key_id.clone(), + ), + ] + .into_iter() + .collect(), + } +} + +fn validate_handle_owner(handle: &CredentialHandle) -> Result<(), Status> { + if handle.driver == DRIVER_NAME { + return Ok(()); + } + Err(Status::invalid_argument(format!( + "default credential storage cannot use handle owned by '{}'", + handle.driver + ))) +} + +fn encrypt_envelope( + state: &EncryptedGatewayCredentialState, + id: &str, + provider_name: &str, + credential_key: &str, + value: &str, +) -> Result { + let dek = random_bytes_status::()?; + let wrapped_dek = encrypt_bytes( + &state.key_encryption_key, + &dek_aad(id, provider_name, credential_key), + &dek, + )?; + let encrypted_value = encrypt_bytes( + &dek, + &value_aad(id, provider_name, credential_key), + value.as_bytes(), + )?; + + Ok(EncryptedCredentialEnvelope { + version: ENVELOPE_VERSION, + id: id.to_string(), + provider_name: provider_name.to_string(), + credential_key: credential_key.to_string(), + algorithm: ALGORITHM.to_string(), + key_encryption_key_id: state.key_encryption_key_id.clone(), + wrapped_dek, + value: encrypted_value, + }) +} + +fn decrypt_envelope( + state: &EncryptedGatewayCredentialState, + envelope: &EncryptedCredentialEnvelope, +) -> Result { + validate_envelope_metadata(envelope)?; + if envelope.key_encryption_key_id != state.key_encryption_key_id { + return Err(Status::failed_precondition( + "default credential storage object was encrypted with a different key-encryption key", + )); + } + let dek = decrypt_bytes( + &state.key_encryption_key, + &dek_aad( + &envelope.id, + &envelope.provider_name, + &envelope.credential_key, + ), + &envelope.wrapped_dek, + )?; + let dek = fixed_bytes::(&dek) + .map_err(|()| Status::data_loss("default credential storage DEK has invalid length"))?; + let plaintext = decrypt_bytes( + &dek, + &value_aad( + &envelope.id, + &envelope.provider_name, + &envelope.credential_key, + ), + &envelope.value, + )?; + String::from_utf8(plaintext) + .map_err(|_| Status::data_loss("default credential storage value is not valid UTF-8")) +} + +fn encrypt_bytes( + key_bytes: &[u8; KEY_LEN], + aad: &[u8], + plaintext: &[u8], +) -> Result { + let nonce = random_bytes_status::()?; + let key = aead_key(key_bytes)?; + let mut in_out = plaintext.to_vec(); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad), + &mut in_out, + ) + .map_err(|_| Status::internal("failed to encrypt default credential storage value"))?; + Ok(EncryptedBytes { + nonce: BASE64.encode(nonce), + ciphertext: BASE64.encode(in_out), + }) +} + +fn decrypt_bytes( + key_bytes: &[u8; KEY_LEN], + aad: &[u8], + encrypted: &EncryptedBytes, +) -> Result, Status> { + let nonce = decode_b64_array::("nonce", &encrypted.nonce)?; + let mut in_out = decode_b64_vec("ciphertext", &encrypted.ciphertext)?; + let key = aead_key(key_bytes)?; + let plaintext = key + .open_in_place( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad), + &mut in_out, + ) + .map_err(|_| Status::data_loss("failed to decrypt default credential storage value"))?; + Ok(plaintext.to_vec()) +} + +fn aead_key(key_bytes: &[u8; KEY_LEN]) -> Result { + let unbound = UnboundKey::new(&AES_256_GCM, key_bytes).map_err(|_| { + Status::internal("failed to initialize default credential storage AEAD key") + })?; + Ok(LessSafeKey::new(unbound)) +} + +fn dek_aad(id: &str, provider_name: &str, credential_key: &str) -> Vec { + format!("openshell:gateway-credential-storage:v1:dek:{id}:{provider_name}:{credential_key}") + .into_bytes() +} + +fn value_aad(id: &str, provider_name: &str, credential_key: &str) -> Vec { + format!("openshell:gateway-credential-storage:v1:value:{id}:{provider_name}:{credential_key}") + .into_bytes() +} + +fn serialize_envelope(envelope: &EncryptedCredentialEnvelope) -> Result, Status> { + serde_json::to_vec(envelope).map_err(|err| { + Status::internal(format!( + "failed to serialize default credential storage envelope: {err}" + )) + }) +} + +fn deserialize_credential_envelope( + record: &StoredCredentialObject, +) -> Result { + EncryptedGatewayCredentialStoreCrypto::deserialize_envelope( + &record.payload, + format!("{}/{}", record.object_type, record.id), + ) +} + +fn credential_labels(provider_name: &str, credential_key: &str) -> Result { + serde_json::to_string(&HashMap::from([ + ("provider_name", provider_name), + ("credential_key", credential_key), + ])) + .map_err(|err| { + Status::internal(format!( + "failed to serialize default credential labels: {err}" + )) + }) +} + +fn validate_envelope_metadata(envelope: &EncryptedCredentialEnvelope) -> Result<(), Status> { + if envelope.version != ENVELOPE_VERSION { + return Err(Status::data_loss(format!( + "default credential storage envelope version {} is unsupported", + envelope.version + ))); + } + validate_handle_id(&envelope.id)?; + if envelope.algorithm != ALGORITHM { + return Err(Status::data_loss(format!( + "default credential storage algorithm '{}' is unsupported", + envelope.algorithm + ))); + } + validate_provider_name(&envelope.provider_name)?; + validate_credential_key(&envelope.credential_key)?; + Ok(()) +} + +fn ensure_envelope_owner( + envelope: &EncryptedCredentialEnvelope, + id: &str, + provider_name: &str, + credential_key: &str, +) -> Result<(), Status> { + validate_envelope_metadata(envelope)?; + if envelope.id == id + && envelope.provider_name == provider_name + && envelope.credential_key == credential_key + { + return Ok(()); + } + Err(Status::failed_precondition( + "default credential storage handle is not managed for this provider credential", + )) +} + +fn validate_handle_id(id: &str) -> Result<(), Status> { + if id.len() == HANDLE_ID_LEN + && id + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Ok(()); + } + Err(Status::invalid_argument( + "default credential storage handle id is invalid", + )) +} + +fn validate_provider_name(value: &str) -> Result<&str, Status> { + validate_request_component("provider_name", value) +} + +fn validate_credential_key(value: &str) -> Result<&str, Status> { + validate_request_component("credential_key", value) +} + +fn validate_request_component<'a>(field_name: &str, value: &'a str) -> Result<&'a str, Status> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(Status::invalid_argument(format!( + "default credential storage request {field_name} is required" + ))); + } + if trimmed.len() != value.len() { + return Err(Status::invalid_argument(format!( + "default credential storage request {field_name} must not contain leading or trailing whitespace" + ))); + } + Ok(trimmed) +} + +fn decode_b64_array(field_name: &str, value: &str) -> Result<[u8; N], Status> { + let bytes = decode_b64_vec(field_name, value)?; + fixed_bytes::(&bytes).map_err(|()| { + Status::data_loss(format!( + "default credential storage envelope {field_name} has invalid length" + )) + }) +} + +fn decode_b64_vec(field_name: &str, value: &str) -> Result, Status> { + BASE64.decode(value).map_err(|err| { + Status::data_loss(format!( + "default credential storage envelope {field_name} is invalid base64: {err}" + )) + }) +} + +fn fixed_bytes(bytes: &[u8]) -> Result<[u8; N], ()> { + bytes.try_into().map_err(|_| ()) +} + +fn random_bytes_core() -> CoreResult<[u8; N]> { + let mut bytes = [0_u8; N]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| Error::config("failed to generate default credential storage key material"))?; + Ok(bytes) +} + +fn random_bytes_status() -> Result<[u8; N], Status> { + let mut bytes = [0_u8; N]; + SystemRandom::new().fill(&mut bytes).map_err(|_| { + Status::internal("failed to generate default credential storage randomness") + })?; + Ok(bytes) +} + +fn key_id(key: &[u8; KEY_LEN]) -> String { + let digest = Sha256::digest(key); + format!("sha256:{}", hex_encode(&digest)) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + use tonic::Code; + + #[derive(Debug, Default)] + struct MemoryObjectStore { + objects: Mutex>, + } + + #[async_trait] + impl DbCredstoreObjectStore for MemoryObjectStore { + async fn get_credential_object( + &self, + _object_type: &str, + id: &str, + _operation: &'static str, + ) -> Result, Status> { + Ok(self.objects.lock().unwrap().get(id).cloned()) + } + + async fn put_credential_object( + &self, + write: CredentialObjectWrite, + _operation: &'static str, + ) -> Result<(), Status> { + let mut objects = self.objects.lock().unwrap(); + match write.condition { + DbCredstoreWriteCondition::MustCreate if objects.contains_key(&write.id) => { + return Err(Status::already_exists("object already exists")); + } + DbCredstoreWriteCondition::MatchResourceVersion(expected) => { + let Some(current) = objects.get(&write.id) else { + return Err(Status::not_found("object not found")); + }; + if current.resource_version != expected { + return Err(Status::aborted("resource version conflict")); + } + } + DbCredstoreWriteCondition::MustCreate => {} + } + + let resource_version = objects + .get(&write.id) + .map_or(1, |current| current.resource_version + 1); + objects.insert( + write.id.clone(), + StoredCredentialObject { + object_type: write.object_type, + id: write.id, + payload: write.payload, + resource_version, + }, + ); + Ok(()) + } + + async fn delete_credential_object( + &self, + _object_type: &str, + id: &str, + expected_resource_version: u64, + _operation: &'static str, + ) -> Result<(), Status> { + let mut objects = self.objects.lock().unwrap(); + let Some(current) = objects.get(id) else { + return Ok(()); + }; + if current.resource_version != expected_resource_version { + return Err(Status::aborted("resource version conflict")); + } + objects.remove(id); + Ok(()) + } + } + + fn crypto_for_key_encryption_key_path(path: &Path) -> EncryptedGatewayCredentialStoreCrypto { + let mut config = toml::Table::new(); + config.insert( + "key_encryption_key_path".to_string(), + toml::Value::String(path.to_string_lossy().to_string()), + ); + EncryptedGatewayCredentialStoreCrypto::from_config(&config).unwrap() + } + + fn driver_config_for_key_encryption_key_path(path: &Path) -> toml::Table { + let mut config = toml::Table::new(); + config.insert( + "key_encryption_key_path".to_string(), + toml::Value::String(path.to_string_lossy().to_string()), + ); + config + } + + fn request( + provider_name: &str, + credential_key: &str, + value: &str, + existing_handle: Option, + ) -> StoreCredentialRequest { + StoreCredentialRequest { + provider_name: provider_name.to_string(), + credential_key: credential_key.to_string(), + value: value.to_string(), + existing_handle, + workspace: "test-workspace".to_string(), + provider_id: "test-provider-id".to_string(), + object_id: "test-provider-id".to_string(), + } + } + + fn resolve_request( + request_id: &str, + provider_name: &str, + credential_key: &str, + handle: CredentialHandle, + ) -> ResolveCredentialRequest { + ResolveCredentialRequest { + request_id: request_id.to_string(), + provider_name: provider_name.to_string(), + credential_key: credential_key.to_string(), + handle: Some(handle), + workspace: "test-workspace".to_string(), + provider_id: "test-provider-id".to_string(), + } + } + + #[tokio::test] + async fn driver_stores_resolves_updates_and_deletes_encrypted_objects() { + let tmp = tempfile::tempdir().unwrap(); + let config = driver_config_for_key_encryption_key_path( + &tmp.path().join(DEFAULT_KEY_ENCRYPTION_KEY_FILE), + ); + let store = Arc::new(MemoryObjectStore::default()); + let object_store: Arc = store.clone(); + let driver = DbCredstoreCredentialDriver::from_config(object_store, &config).unwrap(); + + let first = driver + .store_credential(request( + "openai-local", + "OPENAI_API_KEY", + "sk-original", + None, + )) + .await + .unwrap(); + assert_eq!(first.driver, DbCredstoreCredentialDriver::NAME); + let handle_id = first.handle.strip_prefix("v1:").unwrap(); + let payload = store + .objects + .lock() + .unwrap() + .get(handle_id) + .unwrap() + .payload + .clone(); + assert!(!String::from_utf8_lossy(&payload).contains("sk-original")); + + let resolved = driver + .resolve_credentials(vec![resolve_request( + "credential-0", + "openai-local", + "OPENAI_API_KEY", + first.clone(), + )]) + .await + .unwrap(); + assert_eq!(resolved[0].value, "sk-original"); + + let updated = driver + .store_credential(request( + "openai-local", + "OPENAI_API_KEY", + "sk-updated", + Some(first.clone()), + )) + .await + .unwrap(); + assert_eq!(updated.handle, first.handle); + + let resolved = driver + .resolve_credentials(vec![resolve_request( + "credential-0", + "openai-local", + "OPENAI_API_KEY", + updated.clone(), + )]) + .await + .unwrap(); + assert_eq!(resolved[0].value, "sk-updated"); + + driver + .delete_credential(DeleteCredentialRequest { + provider_name: "openai-local".to_string(), + credential_key: "OPENAI_API_KEY".to_string(), + handle: Some(updated.clone()), + workspace: "test-workspace".to_string(), + provider_id: "test-provider-id".to_string(), + }) + .await + .unwrap(); + + let err = driver + .resolve_credentials(vec![resolve_request( + "credential-0", + "openai-local", + "OPENAI_API_KEY", + updated, + )]) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::NotFound); + } + + #[test] + fn encrypts_decrypts_and_serializes_envelope() { + let tmp = tempfile::tempdir().unwrap(); + let crypto = + crypto_for_key_encryption_key_path(&tmp.path().join(DEFAULT_KEY_ENCRYPTION_KEY_FILE)); + let id = EncryptedGatewayCredentialStoreCrypto::new_handle_id().unwrap(); + let envelope = crypto + .encrypt_envelope(&id, "openai-local", "OPENAI_API_KEY", "sk-original") + .unwrap(); + let serialized = + EncryptedGatewayCredentialStoreCrypto::serialize_envelope(&envelope).unwrap(); + assert!(!String::from_utf8_lossy(&serialized).contains("sk-original")); + + let envelope = + EncryptedGatewayCredentialStoreCrypto::deserialize_envelope(&serialized, "test") + .unwrap(); + assert_eq!(crypto.decrypt_envelope(&envelope).unwrap(), "sk-original"); + } + + #[test] + fn file_key_encryption_key_is_reused_across_instances() { + let tmp = tempfile::tempdir().unwrap(); + let key_encryption_key_path = tmp.path().join(DEFAULT_KEY_ENCRYPTION_KEY_FILE); + let crypto = crypto_for_key_encryption_key_path(&key_encryption_key_path); + let id = EncryptedGatewayCredentialStoreCrypto::new_handle_id().unwrap(); + let envelope = crypto + .encrypt_envelope(&id, "openai-local", "OPENAI_API_KEY", "sk-persisted") + .unwrap(); + + let restarted = crypto_for_key_encryption_key_path(&key_encryption_key_path); + assert_eq!( + restarted.decrypt_envelope(&envelope).unwrap(), + "sk-persisted" + ); + } + + #[test] + fn rejects_handle_for_different_provider() { + let tmp = tempfile::tempdir().unwrap(); + let crypto = + crypto_for_key_encryption_key_path(&tmp.path().join(DEFAULT_KEY_ENCRYPTION_KEY_FILE)); + let id = EncryptedGatewayCredentialStoreCrypto::new_handle_id().unwrap(); + let envelope = crypto + .encrypt_envelope(&id, "openai-local", "OPENAI_API_KEY", "sk-original") + .unwrap(); + + let err = EncryptedGatewayCredentialStoreCrypto::ensure_envelope_owner( + &envelope, + &id, + "other-provider", + "OPENAI_API_KEY", + ) + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + } + + #[test] + fn env_key_encryption_key_must_decode_to_32_bytes() { + let err = decode_key_encryption_key_base64(&BASE64.encode([1_u8; 31])).unwrap_err(); + assert!(err.contains("32 bytes")); + assert!(decode_key_encryption_key_base64(&BASE64.encode([1_u8; KEY_LEN])).is_ok()); + assert!(decode_key_encryption_key_base64(&BASE64_NO_PAD.encode([1_u8; KEY_LEN])).is_ok()); + } + + #[cfg(unix)] + #[test] + fn generated_key_encryption_key_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let key_encryption_key_path = tmp.path().join(DEFAULT_KEY_ENCRYPTION_KEY_FILE); + let _crypto = crypto_for_key_encryption_key_path(&key_encryption_key_path); + let key_encryption_key_mode = fs::metadata(key_encryption_key_path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(key_encryption_key_mode, 0o600); + } +} diff --git a/crates/openshell-driver-docker/BUILD.bazel b/crates/openshell-driver-docker/BUILD.bazel deleted file mode 100644 index 6bc54ce076..0000000000 --- a/crates/openshell-driver-docker/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-driver-docker", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-driver-docker_test", - crate = ":openshell-driver-docker", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-driver-docker", - ":openshell-driver-docker_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index 7e1bc069cb..7a5fa3fe3d 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -10,26 +10,44 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[[bin]] +name = "openshell-driver-docker" +path = "src/main.rs" + [dependencies] -openshell-core = { path = "../openshell-core", default-features = false } +openshell-core = { path = "../openshell-core", default-features = false, features = ["driver-extraction"] } +openshell-otel = { path = "../openshell-otel" } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } +tracing-opentelemetry = { workspace = true } tokio = { workspace = true } -tonic = { workspace = true } +tonic = { workspace = true, features = ["transport"] } futures = { workspace = true } tokio-stream = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } bytes = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } prost-types = { workspace = true } bollard = { version = "0.20" } -tar = "0.4" -tempfile = "3" url = { workspace = true } +clap = { workspace = true } +miette = { workspace = true } +toml = { workspace = true } +tower-http = { workspace = true } +http = { workspace = true } [dev-dependencies] +openshell-otel-test-support = { path = "../openshell-otel-test-support" } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true, features = ["testing"] } prost-types = { workspace = true } +tar = "0.4" temp-env = "0.3" +tempfile = "3" +tracing-subscriber = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index b2e74231ba..bbd7e69b88 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -2,6 +2,21 @@ Docker-backed compute driver for local OpenShell gateways. +When the gateway configures `[openshell.gateway.otlp]`, Docker compute-driver +spans export to the same OTLP/gRPC collector with the service name +`openshell-driver-docker`. The in-process driver preserves the gateway trace +context and emits the compute-driver RPC boundary that a standalone driver +would expose. + +`mise run gateway:docker` enables this export only when a local collector is +listening on `127.0.0.1:4317`. Otherwise, it omits the gateway OTLP configuration +so the development gateway does not repeatedly report export failures. + +The standalone `openshell-driver-docker` binary accepts +`OPENSHELL_OTLP_ENDPOINT`. When set, it exports Docker driver spans to that +collector, continues W3C trace context from gateway RPC metadata, and flushes +spans during graceful shutdown. + The driver manages sandbox containers through the local Docker daemon with the `bollard` client. It is intended for developer environments where Docker is already available and running Kubernetes would be unnecessary. @@ -18,21 +33,64 @@ The gateway runs as a host process. The Docker driver creates one container per sandbox and starts the `openshell-sandbox` supervisor inside that container. The supervisor then creates the nested sandbox namespace for the agent process. +## Stop and Start + +Stop stops the managed container without removing it. Docker retains the +container writable layer, attached volumes, labels, token material, and restart +policy. Start starts that same container, so files in the resolved OCI +workspace remain available. A durably stopped sandbox is excluded from +gateway startup recovery and stays stopped across gateway restarts. Delete +continues to force-remove the container and clean up driver-owned material. +Graceful gateway shutdown sends `StopSandbox` for each sandbox whose persisted +phase requires running compute without changing that persisted intent. On +startup, the gateway sends an idempotent `StartSandbox` request for the same +sandboxes, restarting their retained containers. Explicitly stopped sandboxes +remain excluded. + Before creating the container, the driver inspects the final sandbox image and -captures its immutable image ID and raw OCI `Config.User`. Container creation -uses that image ID, preventing a mutable tag from changing between inspection -and launch. The supervisor runs as root, resolves omitted policy identity fields -from the image declaration, and drops only agent children to the resulting -identity. Named OCI components remain names after validation; a missing group -is filled with the user's numeric primary GID. Explicit `process.run_as_user` -and `process.run_as_group` values take precedence independently. +captures its immutable image ID, raw OCI `Config.User`, and OCI +`Config.WorkingDir`. Container creation uses that image ID, preventing a +mutable tag from changing between inspection and launch. The supervisor runs as +root, resolves omitted policy identity fields from the image declaration, and +drops only agent children to the resulting identity. Named OCI components +remain names after validation; a missing group is filled with the user's +numeric primary GID. Explicit `process.run_as_user` and +`process.run_as_group` values take precedence independently. + +An absolute OCI working directory becomes the agent workspace. An empty, +root (`/`), or explicit `/sandbox` declaration uses `/sandbox`, which OpenShell +creates when necessary and owns as a compatibility workspace. Any other image +workdir must already exist without symlink components. The completed identity, +including supplementary groups, must already be able to traverse every parent +and write and enter the workdir. OpenShell does not change its ownership or +mode. + +OpenShell deliberately asks the Linux kernel to make this access decision +under the completed sandbox identity instead of reproducing permission rules +from ownership and mode bits. Mode-bit inspection alone can reject authority +granted by a POSIX ACL or overlook a denial imposed by a Linux Security Module +such as SELinux or AppArmor. OpenShell does not configure or otherwise manage +ACLs or LSM policy here; the one-shot validator only observes the kernel's +effective decision. This keeps the no-authority-expansion invariant aligned +with the access the eventual workload will receive without adding a separate, +incomplete permission model to OpenShell. + +Image `VOLUME` declarations must not cover the workdir or one of its parents +because Docker would mount the volume before the supervisor could validate the +immutable image path. +Workdirs under the standard OCI runtime namespaces `/proc`, `/sys`, and `/dev` +are rejected, as are paths that overlap concrete OpenShell control resources. +The workspace is the child cwd and `HOME`. The supervisor starts from `/`, then +reports an invalid workdir as a readiness failure. Docker containers join an OpenShell-managed bridge network. The driver injects `host.openshell.internal` and `host.docker.internal` so supervisors have stable names for reaching the gateway host. On Docker Desktop, Colima, Rancher Desktop, OrbStack, and macOS-hosted gateways, those names use Docker's -`host-gateway` alias. On native Linux Docker, the gateway also binds the bridge -gateway IP so containers can call back to the host process. +`host-gateway` alias. The driver requests a separate IPv4 loopback callback +listener when the primary listener does not already cover it. On native Linux +Docker, the gateway also binds the bridge gateway IP so containers can call +back to the host process. ## Container Contract @@ -45,9 +103,10 @@ contract: | `network_mode = openshell` | Places the supervisor on the managed Docker bridge network. | | `cap_add` | Grants supervisor-only capabilities required for namespace setup and process inspection. | | `apparmor=unconfined` | Avoids Docker's default profile blocking required mount operations. | -| `restart_policy = unless-stopped` | Keeps managed sandboxes resumable across daemon or gateway restarts. | +| `restart_policy = no` | A canonical main-process exit remains terminal and is not silently restarted by Docker. | | `PidsLimit` | Enforces the sandbox PID budget at the Docker cgroup layer. Set `[openshell.drivers.docker].sandbox_pids_limit = 0` to inherit the Docker/runtime default. | | CDI GPU request | Uses opaque `driver_config.cdi_devices` values when set; otherwise selects the requested count of NVIDIA CDI GPUs in round-robin order when daemon CDI support is detected. Docker daemon `/info` can permit `nvidia.com/gpu=all` as a WSL2 all-only compatibility fallback, where it counts as one selectable device. Exact CDI device lists must not contain duplicates and must match the effective GPU count. | +| `policy-dns-transparent-tcp` capability | Declares that the combined Docker supervisor can own namespace-local DNS/TCP capture and coupled workload restart. The shared supervisor still owns DNS eligibility, mappings, authorization, pinned dialing, relaying, and OCSF decisions. The marker is stripped from the workload environment. | The agent child process does not retain these supervisor privileges. @@ -77,9 +136,11 @@ optional `selinux_label` of `shared` (applies `:z`) or `private` (applies `subpath`. User-supplied bind and volume mounts are read-only by default; set `read_only: false` to make them writable. Mount `source`, `target`, and `subpath` values must not contain surrounding whitespace. Mount targets must be -absolute container paths and must not replace the workspace root (`/sandbox`) -or overlap OpenShell supervisor files, `/etc/openshell`, `/etc/openshell-tls`, -or `/run/netns`. +absolute container paths and must not replace or contain the resolved workspace +root. Nested workspace mounts remain valid. Mounts also must not overlap the +configured SSH socket or the reserved `/opt/openshell`, `/etc/openshell`, +`/etc/openshell-tls`, `/run/openshell`, `/run/openshell-sidecar`, and network +namespace roots. Example named-volume usage: @@ -135,7 +196,7 @@ overwrites security-critical keys: - `OPENSHELL_SANDBOX_ID` - `OPENSHELL_SANDBOX` - `OPENSHELL_SSH_SOCKET_PATH` -- `OPENSHELL_SANDBOX_COMMAND` +- `OPENSHELL_MAIN_PROCESS_SPEC` - TLS path variables when HTTPS is enabled Do not allow sandbox images or templates to override these values. diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 502f4ae8fe..f19560ef97 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -5,13 +5,15 @@ #![allow(clippy::result_large_err)] +pub mod otel_tracing; + use bollard::Docker; use bollard::errors::Error as BollardError; use bollard::models::{ - ContainerCreateBody, ContainerSummary, ContainerSummaryStateEnum, CreateImageInfo, - DeviceRequest, EndpointSettings, HostConfig, Mount, MountTmpfsOptions, MountTypeEnum, - MountVolumeOptions, NetworkCreateRequest, NetworkingConfig, ProgressDetail, RestartPolicy, - RestartPolicyNameEnum, SystemInfo, + ContainerCreateBody, ContainerState, ContainerStateStatusEnum, ContainerSummary, + ContainerSummaryStateEnum, CreateImageInfo, DeviceRequest, EndpointSettings, HostConfig, Mount, + MountTmpfsOptions, MountTypeEnum, MountVolumeOptions, NetworkCreateRequest, NetworkingConfig, + ProgressDetail, SystemInfo, }; use bollard::query_parameters::{ CreateContainerOptionsBuilder, CreateImageOptions, DownloadFromContainerOptionsBuilder, @@ -19,14 +21,13 @@ use bollard::query_parameters::{ }; use bytes::Bytes; use futures::{Stream, StreamExt}; -use openshell_core::config::{ - DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_SANDBOX_PIDS_LIMIT, DEFAULT_STOP_TIMEOUT_SECS, -}; +use openshell_core::config::{DEFAULT_SANDBOX_PIDS_LIMIT, DEFAULT_STOP_TIMEOUT_SECS}; use openshell_core::driver_mounts; use openshell_core::driver_utils::{ - LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, - supervisor_image_should_refresh, + CONDITION_EXITED, CONDITION_RUNTIME_RESTART, LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, + LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, + SUPERVISOR_IMAGE_BINARY_PATH, extract_first_tar_entry, supervisor_image_should_refresh, + temp_extract_container_name, validate_linux_elf_binary, write_cache_binary_atomic, }; use openshell_core::gpu::{ CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, @@ -38,23 +39,25 @@ use openshell_core::progress::{ }; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, - DriverSandboxTemplate, GatewayListenerRequirement, GetCapabilitiesRequest, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, + DriverSandbox, DriverSandboxStatus, DriverSandboxTemplate, EnsureWorkspaceRequest, + EnsureWorkspaceResponse, GatewayListenerRequirement, GetCapabilitiesRequest, GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, - GpuResourceRequirements, ListSandboxesRequest, ListSandboxesResponse, StopSandboxRequest, - StopSandboxResponse, ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, - WatchSandboxesDeletedEvent, WatchSandboxesEvent, WatchSandboxesPlatformEvent, - WatchSandboxesRequest, WatchSandboxesSandboxEvent, compute_driver_server::ComputeDriver, - gateway_listener_requirement::Selector, watch_sandboxes_event, + GpuResourceRequirements, ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, + StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, + ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, + WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent, + compute_driver_server::ComputeDriver, gateway_listener_requirement::Selector, + watch_sandboxes_event, }; use openshell_core::proto_struct::{ deserialize_optional_non_empty_string_list, struct_to_json_value, }; -use openshell_core::{Config, Error, Result as CoreResult}; +use openshell_core::{Error, Result as CoreResult}; +use opentelemetry::trace::TraceContextExt as _; use std::collections::{HashMap, HashSet}; -use std::io::Read; -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; @@ -63,7 +66,8 @@ use tokio::sync::{Mutex, broadcast, mpsc}; use tokio::task::JoinHandle; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; -use tracing::{info, warn}; +use tracing::{Instrument as _, debug, info, warn}; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; use url::Url; const WATCH_BUFFER: usize = 128; @@ -75,12 +79,33 @@ const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH; const TLS_CERT_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CERT_MOUNT_PATH; const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PATH; const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; -const SANDBOX_COMMAND: &str = "sleep infinity"; const SUPERVISOR_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const HOST_OPENSHELL_INTERNAL: &str = "host.openshell.internal"; const HOST_DOCKER_INTERNAL: &str = "host.docker.internal"; const DOCKER_NETWORK_DRIVER: &str = "bridge"; +fn provisioning_span( + parent: &opentelemetry::Context, + sandbox: &DriverSandbox, + image_ref: &str, +) -> tracing::Span { + let span = tracing::info_span!( + parent: None, + "docker.provision", + otel.name = "docker.provision", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + sandbox.name = %sandbox.name, + image.ref = %image_ref, + ); + let parent_span_context = parent.span().span_context().clone(); + if parent_span_context.is_valid() { + let parent = opentelemetry::Context::new().with_remote_span_context(parent_span_context); + let _ = span.set_parent(parent); + } + span +} + /// Gateway-local configuration for the Docker compute driver. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, deny_unknown_fields)] @@ -154,7 +179,7 @@ impl Default for DockerComputeConfig { guest_tls_key: None, network_name: DEFAULT_DOCKER_NETWORK_NAME.to_string(), host_gateway_ip: String::new(), - ssh_socket_path: "/run/openshell/ssh.sock".to_string(), + ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, enable_bind_mounts: false, } @@ -176,6 +201,7 @@ struct DockerDriverRuntimeConfig { grpc_endpoint: String, network_name: String, gateway_route: DockerGatewayRoute, + gateway_callback_bind_address: Option, ssh_socket_path: String, stop_timeout_secs: u32, log_level: String, @@ -204,6 +230,85 @@ pub struct DockerComputeDriver { events: broadcast::Sender, pending: Arc>>, gpu_selector: Arc, + lifecycle_event_fences: DockerLifecycleEventFences, +} + +/// Per-sandbox container exit timestamps that fence snapshots from an earlier run. +/// +/// Docker's polling loop can observe the stopped container before a restart and +/// publish that snapshot after the gateway has moved the sandbox to `Starting`. +/// Comparing the container's transition timestamp prevents that old observation +/// from regressing the new lifecycle operation to `Error`. +#[derive(Clone, Debug, Default)] +struct DockerLifecycleEventFences { + state: Arc>, +} + +#[derive(Debug, Default)] +struct DockerLifecycleFenceState { + previous_finished_at: HashMap, + starts_in_progress: HashSet, +} + +impl DockerLifecycleEventFences { + fn begin_start(&self, sandbox_id: &str) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .starts_in_progress + .insert(sandbox_id.to_string()); + } + + fn finish_start(&self, sandbox_id: &str) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .starts_in_progress + .remove(sandbox_id); + } + + fn start_in_progress(&self, sandbox_id: &str) -> bool { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .starts_in_progress + .contains(sandbox_id) + } + + fn record_previous_exit(&self, sandbox_id: &str, finished_at: Option<&str>) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match finished_at.filter(|finished_at| !finished_at.is_empty()) { + Some(finished_at) => { + state + .previous_finished_at + .insert(sandbox_id.to_string(), finished_at.to_string()); + } + None => { + state.previous_finished_at.remove(sandbox_id); + } + } + } + + fn previous_exit(&self, sandbox_id: &str) -> Option { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .previous_finished_at + .get(sandbox_id) + .cloned() + } + + fn remove(&self, sandbox_id: &str) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.previous_finished_at.remove(sandbox_id); + state.starts_in_progress.remove(sandbox_id); + } } struct PendingSandboxRecord { @@ -221,6 +326,8 @@ struct DockerProvisioningFailure { struct DockerImageMetadata { id: String, user: String, + working_dir: String, + volumes: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -304,12 +411,74 @@ fn default_true() -> bool { type WatchStream = Pin> + Send + 'static>>; +#[cfg(test)] +type TracedWatchStream = openshell_otel::TracedGrpcStream; + +/// Compute-driver service wrapper that preserves the standalone RPC trace +/// boundary while Docker runs in the gateway process. +#[derive(Clone)] +pub struct ComputeDriverService { + driver: DockerComputeDriver, + rpc_tracer: openshell_otel::InProcessRpcTracer, +} + +impl ComputeDriverService { + #[must_use] + pub fn new(driver: DockerComputeDriver) -> Self { + Self { + driver, + rpc_tracer: openshell_otel::InProcessRpcTracer::disabled(), + } + } + + #[must_use] + pub fn new_in_process(driver: DockerComputeDriver) -> Self { + Self { + driver, + rpc_tracer: openshell_otel::InProcessRpcTracer::enabled(), + } + } +} + +/// Return the first responsive local Docker API socket. +#[must_use] +pub fn detect_socket() -> Option { + let mut candidates = Vec::new(); + if let Ok(host) = std::env::var("DOCKER_HOST") + && let Some(path) = host.trim().strip_prefix("unix://") + && !path.is_empty() + { + candidates.push(PathBuf::from(path)); + } + candidates.push(PathBuf::from("/var/run/docker.sock")); + if let Some(home) = std::env::var_os("HOME") { + candidates.push(PathBuf::from(home).join(".docker/run/docker.sock")); + } + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + candidates.push(PathBuf::from(runtime_dir).join("docker.sock")); + } + openshell_core::local_api_socket::first_responsive_socket(&candidates, |response| { + openshell_core::local_api_socket::http_response_is_success(response) + && openshell_core::local_api_socket::contains_ascii(response, b"Api-Version:") + && !openshell_core::local_api_socket::contains_ascii(response, b"Libpod-Api-Version:") + }) +} + +#[must_use] +pub fn is_available() -> bool { + detect_socket().is_some() +} + impl DockerComputeDriver { - pub async fn new(config: &Config, docker_config: &DockerComputeConfig) -> CoreResult { + pub async fn new( + gateway_bind_address: SocketAddr, + gateway_log_level: &str, + docker_config: &DockerComputeConfig, + ) -> CoreResult { let socket_path = docker_config .socket_path .clone() - .or_else(openshell_core::config::detect_docker_socket) + .or_else(detect_socket) .unwrap_or_else(|| PathBuf::from("/var/run/docker.sock")); let socket_path_str = socket_path.to_str().ok_or_else(|| { Error::config(format!( @@ -335,7 +504,7 @@ impl DockerComputeDriver { let cdi_gpu_inventory = docker_cdi_gpu_inventory(&info); let allow_all_default_gpu = docker_info_reports_wsl2(&info); validate_sandbox_pids_limit(docker_config.sandbox_pids_limit)?; - let gateway_port = config.bind_address.port(); + let gateway_port = gateway_bind_address.port(); if gateway_port == 0 { return Err(Error::config( "docker compute driver requires a fixed non-zero gateway bind port", @@ -346,6 +515,8 @@ impl DockerComputeDriver { let host_gateway_ip = parse_optional_host_gateway_ip(&docker_config.host_gateway_ip)?; let gateway_route = docker_gateway_route(&info, bridge_gateway_ip, gateway_port, host_gateway_ip); + let gateway_callback_bind_address = + docker_gateway_callback_bind_address(&gateway_route, gateway_bind_address); let mut docker_config = docker_config.clone(); if docker_config.grpc_endpoint.trim().is_empty() { let scheme = if docker_guest_tls_configured(&docker_config) { @@ -374,9 +545,10 @@ impl DockerComputeDriver { grpc_endpoint, network_name, gateway_route, + gateway_callback_bind_address, ssh_socket_path: docker_config.ssh_socket_path.clone(), stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS, - log_level: config.log_level.clone(), + log_level: gateway_log_level.to_string(), supervisor_bin, guest_tls, daemon_version: version.version.unwrap_or_else(|| "unknown".to_string()), @@ -391,6 +563,7 @@ impl DockerComputeDriver { cdi_gpu_inventory, allow_all_default_gpu, )), + lifecycle_event_fences: DockerLifecycleEventFences::default(), }; let poll_driver = driver.clone(); @@ -402,11 +575,14 @@ impl DockerComputeDriver { } fn capabilities(&self) -> GetCapabilitiesResponse { - openshell_core::driver_utils::build_capabilities_response( - "docker", - &self.config.daemon_version, - &self.config.default_image, - ) + GetCapabilitiesResponse { + driver_name: "docker".to_string(), + driver_version: self.config.daemon_version.clone(), + default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: true, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, + } } #[cfg(test)] @@ -600,10 +776,37 @@ impl DockerComputeDriver { async fn current_snapshots(&self) -> Result, Status> { let containers = self.list_managed_container_summaries().await?; - let container_sandboxes = containers - .iter() - .filter_map(sandbox_from_container_summary) - .collect::>(); + let mut container_sandboxes = Vec::with_capacity(containers.len()); + for summary in &containers { + let Some(mut sandbox) = sandbox_from_container_summary(summary) else { + continue; + }; + // Docker's list summary carries no exit code, so an exited + // container is reported as the generic terminal `ContainerExited`. + // Inspect it to tell a machine/daemon-restart signal kill apart + // from an ordinary application exit, mirroring the Podman driver, + // so startup recovery can revive restart victims while leaving + // crashes terminal. + if summary.state == Some(ContainerSummaryStateEnum::EXITED) + && let Some(container_id) = summary.id.as_deref() + { + match self.docker.inspect_container(container_id, None).await { + Ok(inspected) => { + if let Some(state) = inspected.state.as_ref() { + apply_docker_exit_classification(&mut sandbox, state); + } + } + Err(err) => { + debug!( + container_id, + error = %err, + "Could not inspect exited Docker container to classify its exit" + ); + } + } + } + container_sandboxes.push(sandbox); + } let mut by_id = self.pending_snapshot_map().await; for sandbox in container_sandboxes { by_id.insert(sandbox.id.clone(), sandbox); @@ -618,19 +821,13 @@ impl DockerComputeDriver { Self::validate_sandbox_auth(sandbox)?; self.validate_user_volume_mounts_available(&validated.driver_config) .await?; - let gpu_devices = self + let _ = self .resolve_gpu_cdi_devices( validated.gpu_requirements, &validated.driver_config, CdiGpuDefaultSelector::peek_device_ids, ) .await?; - let _ = build_container_create_body_with_gpu_devices( - sandbox, - &self.config, - &validated.driver_config, - gpu_devices.as_deref(), - )?; if self .find_managed_container_summary(&sandbox.id, &sandbox.name) @@ -646,7 +843,7 @@ impl DockerComputeDriver { &sandbox.id, "Scheduled", format!("Docker sandbox accepted for image \"{image}\""), - HashMap::from([("image_ref".to_string(), image)]), + HashMap::from([("image_ref".to_string(), image.clone())]), ); self.publish_sandbox_snapshot(pending_sandbox_snapshot( sandbox, @@ -658,9 +855,14 @@ impl DockerComputeDriver { let driver = self.clone(); let sandbox_for_task = sandbox.clone(); let sandbox_id = sandbox.id.clone(); - let task = tokio::spawn(async move { - driver.provision_sandbox(sandbox_for_task).await; - }); + let parent = tracing::Span::current().context(); + let provisioning_span = provisioning_span(&parent, sandbox, &image); + let task = tokio::spawn( + async move { + driver.provision_sandbox(sandbox_for_task).await; + } + .instrument(provisioning_span), + ); let mut pending = self.pending.lock().await; if let Some(record) = pending.get_mut(&sandbox_id) { @@ -687,16 +889,28 @@ impl DockerComputeDriver { &self, sandbox: &DriverSandbox, ) -> Result<(), DockerProvisioningFailure> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let validated = Self::validated_sandbox(sandbox, &self.config).map_err(|status| { DockerProvisioningFailure::new("ContainerCreateFailed", status.message()) })?; let template = validated.template; - let image = self - .ensure_image_available(&sandbox.id, &template.image) - .await - .map_err(|status| { - DockerProvisioningFailure::new("ImagePullFailed", status.message()) - })?; + let image = async { + openshell_otel::record_error_result( + self.ensure_image_available(&sandbox.id, &template.image) + .await + .map_err(|status| { + DockerProvisioningFailure::new("ImagePullFailed", status.message()) + }), + ) + } + .instrument(tracing::info_span!( + "docker.prepare_image", + otel.name = "docker.prepare_image", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + image.ref = %template.image, + )) + .await?; let token_file_created = write_sandbox_token_file(sandbox, &self.config) .await .map_err(|status| { @@ -730,25 +944,37 @@ impl DockerComputeDriver { } DockerProvisioningFailure::new("ContainerCreateFailed", status.message()) })?; - self.docker - .create_container( - Some( - CreateContainerOptionsBuilder::default() - .name(container_name.as_str()) - .build(), - ), - create_body, + async { + openshell_otel::record_error_result( + self.docker + .create_container( + Some( + CreateContainerOptionsBuilder::default() + .name(container_name.as_str()) + .build(), + ), + create_body, + ) + .await + .map_err(|err| { + if token_file_created { + cleanup_sandbox_token_file(sandbox, &self.config); + } + DockerProvisioningFailure::from_status( + "ContainerCreateFailed", + create_status_from_docker_error("create docker sandbox container", err), + ) + }), ) - .await - .map_err(|err| { - if token_file_created { - cleanup_sandbox_token_file(sandbox, &self.config); - } - DockerProvisioningFailure::from_status( - "ContainerCreateFailed", - create_status_from_docker_error("create docker sandbox container", err), - ) - })?; + } + .instrument(tracing::info_span!( + "docker.create_container", + otel.name = "docker.create_container", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + container.name = %container_name, + )) + .await?; self.publish_docker_progress( &sandbox.id, "Created", @@ -756,7 +982,20 @@ impl DockerComputeDriver { HashMap::from([("container_name".to_string(), container_name.clone())]), ); - if let Err(err) = self.docker.start_container(&container_name, None).await { + let start_result = async { + openshell_otel::record_error_result( + self.docker.start_container(&container_name, None).await, + ) + } + .instrument(tracing::info_span!( + "docker.start_container", + otel.name = "docker.start_container", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + container.name = %container_name, + )) + .await; + if let Err(err) = start_result { let cleanup = self .docker .remove_container( @@ -797,7 +1036,7 @@ impl DockerComputeDriver { ); } - Ok(()) + span_status.finish(Ok(())) } async fn delete_sandbox_inner( @@ -904,14 +1143,39 @@ impl DockerComputeDriver { } /// Start a managed sandbox container that was previously stopped. Used - /// by the gateway to resume sandboxes after a restart so that running + /// by the gateway to start sandboxes after a restart so that running /// state in the gateway store is matched by an actually-running /// container. /// /// Returns `Ok(true)` when a container existed and was started (or was /// already running), `Ok(false)` when no managed container is found for /// the sandbox, and `Err(...)` for any Docker failure. - pub async fn resume_sandbox( + #[tracing::instrument( + name = "docker.start_sandbox", + skip(self), + fields( + otel.name = "docker.start_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + sandbox.name = %sandbox_name, + ) + )] + pub async fn start_sandbox( + &self, + sandbox_id: &str, + sandbox_name: &str, + ) -> Result { + let span_status = openshell_otel::ErrorStatusGuard::current(); + require_sandbox_identifier(sandbox_id, sandbox_name)?; + self.lifecycle_event_fences.begin_start(sandbox_id); + let result = self + .start_sandbox_with_lifecycle_fence(sandbox_id, sandbox_name) + .await; + self.lifecycle_event_fences.finish_start(sandbox_id); + span_status.finish(result) + } + + async fn start_sandbox_with_lifecycle_fence( &self, sandbox_id: &str, sandbox_name: &str, @@ -926,13 +1190,33 @@ impl DockerComputeDriver { return Ok(false); }; let state = container.state.unwrap_or(ContainerSummaryStateEnum::EMPTY); - if !container_state_needs_resume(state) { + if !container_state_needs_start(state) { return Ok(true); } + // Fence a poll that observed this stopped run but has not published it + // yet. Use Docker's transition timestamp so a later, genuine exit from + // the restarted container remains observable. + let previous_finished_at = if state == ContainerSummaryStateEnum::EXITED { + let inspected = self + .docker + .inspect_container(&target, None) + .await + .map_err(|err| internal_status("inspect docker sandbox before start", err))?; + inspected + .state + .as_ref() + .filter(|state| state.status == Some(ContainerStateStatusEnum::EXITED)) + .and_then(|state| state.finished_at.clone()) + } else { + None + }; + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, previous_finished_at.as_deref()); + match self.docker.start_container(&target, None).await { Ok(()) => Ok(true), - // Already running — race with another resume path or the + // Already running — race with another start path or the // restart policy. Treat as success. Err(err) if is_not_modified_error(&err) => Ok(true), Err(err) if is_not_found_error(&err) => Ok(false), @@ -940,69 +1224,6 @@ impl DockerComputeDriver { } } - pub async fn stop_managed_containers_on_shutdown(&self) -> Result { - let containers = self.list_managed_container_summaries().await?; - let targets = containers - .into_iter() - .filter_map(|container| { - let state = container.state.unwrap_or(ContainerSummaryStateEnum::EMPTY); - if container_state_needs_shutdown_stop(state) { - summary_container_target(&container) - } else { - None - } - }) - .collect::>(); - let target_count = targets.len(); - let mut stopped = 0usize; - let mut failures = Vec::new(); - let stop_timeout_secs = self.config.stop_timeout_secs; - - let mut stop_results = futures::stream::iter(targets.into_iter().map(|target| { - let docker = self.docker.clone(); - async move { - let result = docker - .stop_container( - &target, - Some( - StopContainerOptionsBuilder::default() - .t(docker_stop_timeout_secs(stop_timeout_secs)) - .build(), - ), - ) - .await; - (target, result) - } - })) - .buffer_unordered(16); - - while let Some((target, result)) = stop_results.next().await { - match result { - Ok(()) => { - stopped += 1; - } - Err(err) if is_not_found_error(&err) || is_not_modified_error(&err) => {} - Err(err) => { - warn!( - container = %target, - error = %err, - "Failed to stop Docker sandbox container during shutdown" - ); - failures.push(target); - } - } - } - - if !failures.is_empty() { - return Err(Status::internal(format!( - "failed to stop {} of {target_count} Docker sandbox containers during shutdown", - failures.len() - ))); - } - - Ok(stopped) - } - async fn reserve_pending_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), Status> { let mut pending = self.pending.lock().await; if pending @@ -1180,7 +1401,7 @@ impl DockerComputeDriver { tokio::time::sleep(backoff).await; match self.current_snapshot_map().await { Ok(current) => { - emit_snapshot_diff(&self.events, &previous, ¤t); + self.publish_snapshot_diff(&previous, ¤t).await; previous = current; backoff = WATCH_POLL_INTERVAL; } @@ -1205,6 +1426,78 @@ impl DockerComputeDriver { }) } + async fn publish_snapshot_diff( + &self, + previous: &HashMap, + current: &HashMap, + ) { + for (sandbox_id, sandbox) in current { + if previous.get(sandbox_id) == Some(sandbox) { + continue; + } + if self.stale_polled_exit(sandbox).await { + continue; + } + self.publish_sandbox_snapshot(sandbox.clone()); + } + + for sandbox_id in previous.keys() { + if current.contains_key(sandbox_id) { + continue; + } + self.publish_deleted(sandbox_id.clone()); + } + } + + async fn stale_polled_exit(&self, sandbox: &DriverSandbox) -> bool { + if !driver_sandbox_reports_container_exit(sandbox) { + return false; + } + if self.lifecycle_event_fences.start_in_progress(&sandbox.id) { + debug!( + sandbox_id = %sandbox.id, + "Ignoring Docker container exit snapshot while sandbox start is in progress" + ); + return true; + } + let Some(previous_finished_at) = self.lifecycle_event_fences.previous_exit(&sandbox.id) + else { + return false; + }; + let Some(container_id) = sandbox + .status + .as_ref() + .map(|status| status.instance_id.as_str()) + .filter(|container_id| !container_id.is_empty()) + else { + return false; + }; + + let inspected = match self.docker.inspect_container(container_id, None).await { + Ok(inspected) => inspected, + Err(err) => { + debug!( + sandbox_id = %sandbox.id, + container_id, + error = %err, + "Could not verify whether polled Docker exit predates sandbox start" + ); + return false; + } + }; + if !docker_polled_exit_is_stale(&previous_finished_at, inspected.state.as_ref()) { + return false; + } + + debug!( + sandbox_id = %sandbox.id, + container_id, + previous_finished_at, + "Ignoring Docker container exit snapshot from before the latest sandbox start" + ); + true + } + async fn list_managed_container_summaries(&self) -> Result, Status> { let filters = managed_container_label_filters(&self.config.sandbox_namespace, []); self.docker @@ -1322,11 +1615,22 @@ impl DockerComputeDriver { "docker image '{image}' inspection did not return an immutable image ID" )) })?; - let user = inspect - .config - .and_then(|config| config.user) - .unwrap_or_default(); - Ok(DockerImageMetadata { id, user }) + let (user, working_dir, volumes) = inspect.config.map_or_else( + || (String::new(), String::new(), Vec::new()), + |config| { + ( + config.user.unwrap_or_default(), + config.working_dir.unwrap_or_default(), + config.volumes.unwrap_or_default(), + ) + }, + ); + Ok(DockerImageMetadata { + id, + user, + working_dir, + volumes, + }) } async fn pull_image(&self, sandbox_id: &str, image: &str) -> Result<(), Status> { @@ -1369,8 +1673,186 @@ impl DockerComputeDriver { } } +// Standalone and in-process servers both use this wrapper. Delegating to the +// driver's canonical tonic implementation keeps request validation and Docker +// operation spans identical across both deployment modes. +#[tonic::async_trait] +impl ComputeDriver for ComputeDriverService { + type WatchSandboxesStream = WatchStream; + + async fn authenticate_sandbox( + &self, + request: Request, + ) -> Result, Status> + { + self.rpc_tracer + .trace( + openshell_otel::rpc::AUTHENTICATE_SANDBOX, + ComputeDriver::authenticate_sandbox(&self.driver, request), + ) + .await + } + + async fn get_capabilities( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::GET_CAPABILITIES, + ComputeDriver::get_capabilities(&self.driver, request), + ) + .await + } + + async fn get_gateway_listener_requirements( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::GET_GATEWAY_LISTENER_REQUIREMENTS, + ComputeDriver::get_gateway_listener_requirements(&self.driver, request), + ) + .await + } + + async fn validate_sandbox_create( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::VALIDATE_SANDBOX_CREATE, + ComputeDriver::validate_sandbox_create(&self.driver, request), + ) + .await + } + + async fn get_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::GET_SANDBOX, + ComputeDriver::get_sandbox(&self.driver, request), + ) + .await + } + + async fn list_sandboxes( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::LIST_SANDBOXES, + ComputeDriver::list_sandboxes(&self.driver, request), + ) + .await + } + + async fn create_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::CREATE_SANDBOX, + ComputeDriver::create_sandbox(&self.driver, request), + ) + .await + } + + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::STOP_SANDBOX, + ComputeDriver::stop_sandbox(&self.driver, request), + ) + .await + } + + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::START_SANDBOX, + ComputeDriver::start_sandbox(&self.driver, request), + ) + .await + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::DELETE_SANDBOX, + ComputeDriver::delete_sandbox(&self.driver, request), + ) + .await + } + + async fn watch_sandboxes( + &self, + request: Request, + ) -> Result, Status> { + let create_stream = async { + ComputeDriver::watch_sandboxes(&self.driver, request) + .await + .map(Response::into_inner) + }; + self.rpc_tracer + .trace_stream(openshell_otel::rpc::WATCH_SANDBOXES, create_stream) + .await + .map(Response::new) + } + + async fn ensure_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::ENSURE_WORKSPACE, + ComputeDriver::ensure_workspace(&self.driver, request), + ) + .await + } + + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace( + openshell_otel::rpc::DELETE_WORKSPACE, + ComputeDriver::delete_workspace(&self.driver, request), + ) + .await + } +} + #[tonic::async_trait] impl ComputeDriver for DockerComputeDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Err(Status::unimplemented( + "docker does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = WatchStream; async fn get_capabilities( @@ -1384,15 +1866,19 @@ impl ComputeDriver for DockerComputeDriver { &self, _request: Request, ) -> Result, Status> { - let requirements = match self.config.gateway_route { - DockerGatewayRoute::Bridge { bind_address, .. } => { - vec![GatewayListenerRequirement { - reason: "docker managed bridge gateway".to_string(), - selector: Some(Selector::ExactBindAddress(bind_address.to_string())), - }] - } - DockerGatewayRoute::HostGateway => Vec::new(), - }; + let requirements = + self.config + .gateway_callback_bind_address + .map_or_else(Vec::new, |bind_address| { + vec![GatewayListenerRequirement { + reason: match self.config.gateway_route { + DockerGatewayRoute::Bridge { .. } => "docker managed bridge gateway", + DockerGatewayRoute::HostGateway => "docker host-gateway IPv4 loopback", + } + .to_string(), + selector: Some(Selector::ExactBindAddress(bind_address.to_string())), + }] + }); Ok(Response::new(GetGatewayListenerRequirementsResponse { requirements, })) @@ -1451,34 +1937,82 @@ impl ComputeDriver for DockerComputeDriver { })) } + #[tracing::instrument( + name = "docker.schedule_sandbox", + skip(self, request), + fields( + otel.name = "docker.schedule_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %request.get_ref().sandbox.as_ref().map_or("", |sandbox| sandbox.id.as_str()), + sandbox.name = %request.get_ref().sandbox.as_ref().map_or("", |sandbox| sandbox.name.as_str()), + ) + )] async fn create_sandbox( &self, request: Request, ) -> Result, Status> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let sandbox = request .into_inner() .sandbox .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; self.create_sandbox_inner(&sandbox).await?; - Ok(Response::new(CreateSandboxResponse {})) + span_status.finish(Ok(Response::new(CreateSandboxResponse {}))) } + #[tracing::instrument( + name = "docker.stop_sandbox", + skip(self, request), + fields( + otel.name = "docker.stop_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %request.get_ref().sandbox_id, + sandbox.name = %request.get_ref().sandbox_name, + ) + )] async fn stop_sandbox( &self, request: Request, ) -> Result, Status> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let request = request.into_inner(); require_sandbox_identifier(&request.sandbox_id, &request.sandbox_name)?; self.stop_sandbox_inner(&request.sandbox_id, &request.sandbox_name) .await?; - Ok(Response::new(StopSandboxResponse {})) + self.publish_container_snapshot(&request.sandbox_id, &request.sandbox_name) + .await?; + span_status.finish(Ok(Response::new(StopSandboxResponse {}))) } + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + if !Self::start_sandbox(self, &request.sandbox_id, &request.sandbox_name).await? { + return Err(Status::not_found("sandbox not found")); + } + self.publish_container_snapshot(&request.sandbox_id, &request.sandbox_name) + .await?; + Ok(Response::new(StartSandboxResponse {})) + } + + #[tracing::instrument( + name = "docker.delete_sandbox", + skip(self, request), + fields( + otel.name = "docker.delete_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %request.get_ref().sandbox_id, + sandbox.name = %request.get_ref().sandbox_name, + ) + )] async fn delete_sandbox( &self, request: Request, ) -> Result, Status> { + let span_status = openshell_otel::ErrorStatusGuard::current(); let request = request.into_inner(); require_sandbox_identifier(&request.sandbox_id, &request.sandbox_name)?; @@ -1486,6 +2020,7 @@ impl ComputeDriver for DockerComputeDriver { let deleted = self .delete_sandbox_inner(&request.sandbox_id, &request.sandbox_name) .await?; + self.lifecycle_event_fences.remove(&event_sandbox_id); if deleted && !event_sandbox_id.is_empty() { let _ = self.events.send(WatchSandboxesEvent { payload: Some(watch_sandboxes_event::Payload::Deleted( @@ -1496,7 +2031,7 @@ impl ComputeDriver for DockerComputeDriver { }); } - Ok(Response::new(DeleteSandboxResponse { deleted })) + span_status.finish(Ok(Response::new(DeleteSandboxResponse { deleted }))) } async fn watch_sandboxes( @@ -1543,6 +2078,20 @@ impl ComputeDriver for DockerComputeDriver { Ok(Response::new(Box::pin(ReceiverStream::new(out_rx)))) } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(DeleteWorkspaceResponse {})) + } } impl DockerProvisioningFailure { @@ -2215,14 +2764,21 @@ fn build_environment_for_oci_user( openshell_core::sandbox_env::SSH_SOCKET_PATH.to_string(), config.ssh_socket_path.clone(), ); + let main_process = + openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(sandbox.spec.as_ref()) + .expect("main process config serialization cannot fail"); environment.insert( - openshell_core::sandbox_env::SANDBOX_COMMAND.to_string(), - SANDBOX_COMMAND.to_string(), + openshell_core::sandbox_env::MAIN_PROCESS_SPEC.to_string(), + main_process, ); environment.insert( openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(), openshell_core::telemetry::enabled_env_value().to_string(), ); + environment.insert( + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.to_string(), + ); // The root supervisor executes namespace helpers during bootstrap; keep // their search path driver-owned even when the template/spec set PATH. environment.insert("PATH".to_string(), SUPERVISOR_PATH.to_string()); @@ -2243,6 +2799,11 @@ fn build_environment_for_oci_user( environment.remove(openshell_core::sandbox_env::SANDBOX_TOKEN); environment.remove(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE); + // Prevent user-supplied environment from overriding the TLS server name + // the supervisor verifies — a sandbox user who can redirect the gateway + // hostname could otherwise present a certificate for a name they control + // and intercept the sandbox JWT. + environment.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME); environment.insert( openshell_core::sandbox_env::OCI_IMAGE_USER.to_string(), oci_user.to_string(), @@ -2334,6 +2895,7 @@ fn build_container_create_body( build_container_create_body_with_gpu_devices(sandbox, config, &driver_config, cdi_devices) } +#[cfg(test)] fn build_container_create_body_with_gpu_devices( sandbox: &DriverSandbox, config: &DockerDriverRuntimeConfig, @@ -2353,6 +2915,8 @@ fn build_container_create_body_with_gpu_devices( &DockerImageMetadata { id: template.image.clone(), user: String::new(), + working_dir: String::new(), + volumes: Vec::new(), }, ) } @@ -2373,6 +2937,36 @@ fn build_container_create_body_for_image( .as_ref() .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; let resource_limits = docker_resource_limits(template)?; + let workspace_root = driver_mounts::resolve_oci_workspace_root(&image.working_dir) + .map_err(Status::failed_precondition)?; + driver_mounts::validate_workspace_control_path(&workspace_root, &config.ssh_socket_path) + .map_err(Status::failed_precondition)?; + for volume in &image.volumes { + driver_mounts::validate_container_mount_target(volume).map_err(|error| { + Status::failed_precondition(format!( + "invalid image-declared volume '{volume}': {error}" + )) + })?; + driver_mounts::validate_workspace_mount_target(volume, &workspace_root).map_err(|_| { + Status::failed_precondition(format!( + "image-declared volume '{volume}' masks OCI WorkingDir '{workspace_root}' before workspace validation" + )) + })?; + driver_mounts::validate_mount_control_path(volume, &config.ssh_socket_path) + .map_err(Status::failed_precondition)?; + } + for mount in &driver_config.mounts { + let target = match mount { + DockerDriverMountConfig::Bind { target, .. } + | DockerDriverMountConfig::Volume { target, .. } + | DockerDriverMountConfig::Tmpfs { target, .. } + | DockerDriverMountConfig::Image { target, .. } => target, + }; + driver_mounts::validate_workspace_mount_target(target, &workspace_root) + .map_err(Status::failed_precondition)?; + driver_mounts::validate_mount_control_path(target, &config.ssh_socket_path) + .map_err(Status::failed_precondition)?; + } let user_mounts = docker_driver_mounts(driver_config)?; let user_bind_strings = docker_driver_bind_strings(driver_config)?; let device_requests = gpu_device_ids.map(|device_ids| { @@ -2405,11 +2999,14 @@ fn build_container_create_body_for_image( Ok(ContainerCreateBody { image: Some(image.id.clone()), user: Some("0".to_string()), + // The image workspace may need to be created or rejected by the + // supervisor, so do not let the OCI runtime chdir there first. + working_dir: Some("/".to_string()), env: Some(build_environment_for_oci_user(sandbox, config, &image.user)), entrypoint: Some(vec![SUPERVISOR_MOUNT_PATH.to_string()]), - // Clear the image CMD so Docker does not append inherited args to the - // supervisor entrypoint. - cmd: Some(Vec::new()), + // Replace the image CMD with the supervisor's resolved workspace + // argument so Docker cannot append inherited image arguments. + cmd: Some(vec!["--workdir".to_string(), workspace_root]), labels: Some(labels), host_config: Some(HostConfig { nano_cpus: resource_limits.nano_cpus, @@ -2422,10 +3019,9 @@ fn build_container_create_body_for_image( Some(binds) }, mounts: Some(user_mounts), - restart_policy: Some(RestartPolicy { - name: Some(RestartPolicyNameEnum::UNLESS_STOPPED), - maximum_retry_count: None, - }), + // Canonical main-process exit is terminal. Runtime restart would + // silently create a new process generation behind the gateway. + restart_policy: None, cap_add: Some(vec![ "SYS_ADMIN".to_string(), "NET_ADMIN".to_string(), @@ -2542,6 +3138,22 @@ fn docker_gateway_route_for_host( } } +fn docker_gateway_callback_bind_address( + route: &DockerGatewayRoute, + primary_bind_address: SocketAddr, +) -> Option { + match route { + DockerGatewayRoute::Bridge { bind_address, .. } => Some(*bind_address), + DockerGatewayRoute::HostGateway => match primary_bind_address.ip() { + IpAddr::V4(ip) if ip.is_unspecified() || ip == Ipv4Addr::LOCALHOST => None, + _ => Some(SocketAddr::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + primary_bind_address.port(), + )), + }, + } +} + fn host_runtime_requires_host_gateway_alias() -> bool { cfg!(target_os = "macos") } @@ -2859,6 +3471,34 @@ fn driver_status_from_summary( } } +/// Refine an exited Docker sandbox's `Ready` condition from inspected state. +/// +/// A signal kill (exit 137/143 = SIGKILL/SIGTERM, not OOM) is the signature of +/// a machine/daemon restart terminating a running container. Reclassify it from +/// the generic terminal `ContainerExited` to the recoverable +/// `ContainerRuntimeRestart` so gateway startup can revive it. OOM kills and +/// ordinary application exits stay `ContainerExited` and terminal. +fn apply_docker_exit_classification(sandbox: &mut DriverSandbox, state: &ContainerState) { + if state.oom_killed == Some(true) { + return; + } + let Some(code) = state.exit_code.filter(|&code| matches!(code, 137 | 143)) else { + return; + }; + let Some(condition) = sandbox + .status + .as_mut() + .and_then(|status| status.conditions.iter_mut().find(|c| c.r#type == "Ready")) + else { + return; + }; + if condition.reason != CONDITION_EXITED { + return; + } + condition.reason = CONDITION_RUNTIME_RESTART.to_string(); + condition.message = format!("Container terminated by signal (exit code {code})"); +} + fn container_ready_condition( state: ContainerSummaryStateEnum, ) -> (&'static str, &'static str, &'static str, bool) { @@ -2882,9 +3522,7 @@ fn container_ready_condition( ContainerSummaryStateEnum::PAUSED => { ("False", "ContainerPaused", "Container is paused", false) } - ContainerSummaryStateEnum::EXITED => { - ("False", "ContainerExited", "Container exited", false) - } + ContainerSummaryStateEnum::EXITED => ("False", CONDITION_EXITED, "Container exited", false), ContainerSummaryStateEnum::DEAD => ("False", "ContainerDead", "Container is dead", false), } } @@ -2910,20 +3548,11 @@ fn summary_container_target(summary: &ContainerSummary) -> Option { .or_else(|| summary_container_name(summary)) } -fn container_state_needs_shutdown_stop(state: ContainerSummaryStateEnum) -> bool { - matches!( - state, - ContainerSummaryStateEnum::RUNNING - | ContainerSummaryStateEnum::RESTARTING - | ContainerSummaryStateEnum::PAUSED - ) -} - /// States from which a managed container can be brought back to running by /// `start_container`. Skip `Restarting` (already coming up), `Removing`, /// `Dead` (terminal), `Paused` (needs `unpause`, not `start`), and /// `Running` (nothing to do). -fn container_state_needs_resume(state: ContainerSummaryStateEnum) -> bool { +fn container_state_needs_start(state: ContainerSummaryStateEnum) -> bool { matches!( state, ContainerSummaryStateEnum::EXITED | ContainerSummaryStateEnum::CREATED @@ -2934,36 +3563,31 @@ fn docker_stop_timeout_secs(timeout_secs: u32) -> i32 { i32::try_from(timeout_secs).unwrap_or(i32::MAX) } -fn emit_snapshot_diff( - events: &broadcast::Sender, - previous: &HashMap, - current: &HashMap, -) { - for (sandbox_id, sandbox) in current { - if previous.get(sandbox_id) == Some(sandbox) { - continue; - } - let _ = events.send(WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { - sandbox: Some(sandbox.clone()), - }, - )), - }); - } +fn driver_sandbox_reports_container_exit(sandbox: &DriverSandbox) -> bool { + sandbox.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status.eq_ignore_ascii_case("false") + && condition.reason == "ContainerExited" + }) + }) +} - for sandbox_id in previous.keys() { - if current.contains_key(sandbox_id) { - continue; - } - let _ = events.send(WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Deleted( - WatchSandboxesDeletedEvent { - sandbox_id: sandbox_id.clone(), - }, - )), - }); +fn docker_polled_exit_is_stale( + previous_finished_at: &str, + current_state: Option<&ContainerState>, +) -> bool { + let Some(current_state) = current_state else { + return false; + }; + + if current_state.status != Some(ContainerStateStatusEnum::EXITED) { + // The list response said Exited, but inspect has already observed a + // newer state. Publishing the older list result would regress it. + return true; } + + current_state.finished_at.as_deref() == Some(previous_finished_at) } fn label_filters(values: impl IntoIterator) -> HashMap> { @@ -3073,7 +3697,7 @@ fn resolve_supervisor_bin_source( // Tier 1: explicit supervisor_bin in [openshell.drivers.docker]. if let Some(path) = docker_config.supervisor_bin.clone() { let path = canonicalize_existing_file(&path, "docker supervisor binary")?; - validate_linux_elf_binary(&path)?; + validate_linux_elf_binary(&path).map_err(Error::config)?; return Ok(SupervisorBinSource::Binary(path)); } @@ -3213,25 +3837,14 @@ async fn extract_supervisor_bin_from_image(docker: &Docker, image: &str) -> Core )) })?; - let cache_path = supervisor_cache_path(&digest)?; + let cache_path = + openshell_core::driver_utils::supervisor_cache_path("docker-supervisor", &digest) + .map_err(Error::config)?; if cache_path.is_file() { - validate_linux_elf_binary(&cache_path)?; + validate_linux_elf_binary(&cache_path).map_err(Error::config)?; return Ok(cache_path); } - let cache_dir = cache_path.parent().ok_or_else(|| { - Error::config(format!( - "docker supervisor cache path '{}' has no parent directory", - cache_path.display(), - )) - })?; - std::fs::create_dir_all(cache_dir).map_err(|err| { - Error::config(format!( - "failed to create docker supervisor cache dir '{}': {err}", - cache_dir.display(), - )) - })?; - info!( image = image, digest = digest, @@ -3240,8 +3853,8 @@ async fn extract_supervisor_bin_from_image(docker: &Docker, image: &str) -> Core ); let binary_bytes = extract_supervisor_binary_bytes(docker, image).await?; - write_cache_binary_atomic(&cache_path, &binary_bytes)?; - validate_linux_elf_binary(&cache_path)?; + write_cache_binary_atomic(&cache_path, &binary_bytes).map_err(Error::config)?; + validate_linux_elf_binary(&cache_path).map_err(Error::config)?; Ok(cache_path) } @@ -3334,98 +3947,6 @@ async fn download_binary_from_container( }) } -/// Extract the payload of the first regular-file entry in a tar archive. -/// Docker's `/containers//archive` endpoint returns a single-file tar -/// when `path` points to a file, so we only need the first entry. -fn extract_first_tar_entry(tar_bytes: &[u8]) -> Result, String> { - let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); - let mut entries = archive - .entries() - .map_err(|err| format!("open tar archive: {err}"))?; - let mut entry = entries - .next() - .ok_or_else(|| "tar archive was empty".to_string())? - .map_err(|err| format!("read tar entry: {err}"))?; - let mut bytes = Vec::new(); - entry - .read_to_end(&mut bytes) - .map_err(|err| format!("read tar entry payload: {err}"))?; - Ok(bytes) -} - -fn write_cache_binary_atomic(final_path: &Path, bytes: &[u8]) -> CoreResult<()> { - let dir = final_path.parent().ok_or_else(|| { - Error::config(format!( - "docker supervisor cache path '{}' has no parent directory", - final_path.display(), - )) - })?; - let mut temp = tempfile::Builder::new() - .prefix(".openshell-sandbox-") - .tempfile_in(dir) - .map_err(|err| { - Error::config(format!( - "failed to create temp file for supervisor binary in '{}': {err}", - dir.display(), - )) - })?; - std::io::Write::write_all(&mut temp, bytes).map_err(|err| { - Error::config(format!( - "failed to write supervisor binary to temp file: {err}", - )) - })?; - temp.as_file().sync_all().map_err(|err| { - Error::config(format!("failed to sync supervisor binary temp file: {err}")) - })?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)).map_err( - |err| { - Error::config(format!( - "failed to chmod supervisor binary temp file: {err}", - )) - }, - )?; - } - - temp.persist(final_path).map_err(|err| { - Error::config(format!( - "failed to rename supervisor binary into '{}': {}", - final_path.display(), - err.error, - )) - })?; - Ok(()) -} - -/// Cache path for an extracted supervisor binary, keyed by the image's -/// content-addressable digest (e.g. `sha256:abc123…`). The digest-prefixed -/// directory keeps stale extractions from earlier releases isolated so they -/// can be GC'd without affecting the active binary. -fn supervisor_cache_path(digest: &str) -> CoreResult { - let base = openshell_core::paths::xdg_data_dir() - .map_err(|err| Error::config(format!("failed to resolve XDG data dir: {err}")))?; - Ok(supervisor_cache_path_with_base(&base, digest)) -} - -fn supervisor_cache_path_with_base(base: &Path, digest: &str) -> PathBuf { - let sanitized = digest.replace(':', "-"); - base.join("openshell") - .join("docker-supervisor") - .join(sanitized) - .join("openshell-sandbox") -} - -fn temp_extract_container_name() -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static SEQ: AtomicU64 = AtomicU64::new(0); - let pid = std::process::id(); - let seq = SEQ.fetch_add(1, Ordering::Relaxed); - format!("openshell-supervisor-extract-{pid}-{seq}") -} - fn canonicalize_existing_file(path: &Path, description: &str) -> CoreResult { if !path.is_file() { return Err(Error::config(format!( @@ -3441,29 +3962,6 @@ fn canonicalize_existing_file(path: &Path, description: &str) -> CoreResult CoreResult<()> { - let mut file = std::fs::File::open(path).map_err(|err| { - Error::config(format!( - "failed to open docker supervisor binary '{}': {err}", - path.display() - )) - })?; - let mut magic = [0_u8; 4]; - file.read_exact(&mut magic).map_err(|err| { - Error::config(format!( - "failed to read docker supervisor binary '{}': {err}", - path.display() - )) - })?; - if magic != [0x7f, b'E', b'L', b'F'] { - return Err(Error::config(format!( - "docker supervisor binary '{}' must be a Linux ELF executable", - path.display() - ))); - } - Ok(()) -} - fn docker_guest_tls_configured(docker_config: &DockerComputeConfig) -> bool { docker_config.guest_tls_ca.is_some() && docker_config.guest_tls_cert.is_some() @@ -3571,3 +4069,4 @@ fn internal_status(operation: &str, err: BollardError) -> Status { #[cfg(test)] mod tests; +pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; diff --git a/crates/openshell-driver-docker/src/main.rs b/crates/openshell-driver-docker/src/main.rs new file mode 100644 index 0000000000..a5ca29887a --- /dev/null +++ b/crates/openshell-driver-docker/src/main.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; +use std::path::PathBuf; + +use clap::Parser; +use miette::{IntoDiagnostic, Result}; +use openshell_core::VERSION; +use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; +use openshell_driver_docker::{ComputeDriverService, DockerComputeConfig, DockerComputeDriver}; +use tracing::info; + +#[derive(Debug, Parser)] +#[command(name = "openshell-driver-docker", version = VERSION)] +struct Args { + /// Public compute-driver Unix socket used by the gateway. + #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] + bind_socket: PathBuf, + + /// TOML file containing a serialized `DockerComputeConfig` table. + #[arg(long, env = "OPENSHELL_DOCKER_DRIVER_CONFIG")] + config: PathBuf, + + /// Gateway listener address used to derive sandbox callback routing. + #[arg( + long, + env = "OPENSHELL_GATEWAY_BIND", + default_value = "127.0.0.1:50051" + )] + gateway_bind: SocketAddr, + + #[arg(long, env = "OPENSHELL_LOG_LEVEL", default_value = "info")] + log_level: String, + + #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] + otlp_endpoint: Option, + + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let _tracing = openshell_otel::install_driver_tracing( + openshell_driver_docker::otel_tracing::TRACING, + openshell_otel::DriverTracingConfig { + endpoint: args.otlp_endpoint.as_deref(), + gateway_name: args.gateway_name.as_deref(), + service_version: VERSION, + log_level: &args.log_level, + }, + ); + + let config_source = std::fs::read_to_string(&args.config).into_diagnostic()?; + let docker_config: DockerComputeConfig = toml::from_str(&config_source).into_diagnostic()?; + let driver = DockerComputeDriver::new(args.gateway_bind, &args.log_level, &docker_config) + .await + .into_diagnostic()?; + + let listener = openshell_core::external_driver_socket::bind_private(&args.bind_socket) + .map_err(|err| miette::miette!("{err}"))?; + let _cleanup = + openshell_core::external_driver_socket::SocketCleanup::new(args.bind_socket.clone()); + info!(socket = %args.bind_socket.display(), "Starting Docker compute driver"); + tonic::transport::Server::builder() + .layer(openshell_otel::compute_driver_rpc_layer()) + .add_service(ComputeDriverServer::new(ComputeDriverService::new(driver))) + .serve_with_incoming_shutdown( + openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), + shutdown_signal(), + ) + .await + .into_diagnostic() +} + +async fn shutdown_signal() { + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + () = terminate => {} + } + info!("Received shutdown signal, draining in-flight requests"); +} diff --git a/crates/openshell-driver-docker/src/otel_tracing.rs b/crates/openshell-driver-docker/src/otel_tracing.rs new file mode 100644 index 0000000000..03090c2eda --- /dev/null +++ b/crates/openshell-driver-docker/src/otel_tracing.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenTelemetry tracing identity for the Docker compute driver. + +pub const TRACING: openshell_otel::ComputeDriverTracing = openshell_otel::compute_driver_tracing!(); + +#[cfg(test)] +mod tests { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn driver_spans_reach_otlp_collector_with_resource_identity() { + openshell_otel_test_support::assert_compute_driver_tracing( + super::TRACING, + openshell_core::VERSION, + "docker.schedule_sandbox", + ) + .await; + } +} diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index fdf850dc6a..98e7cd1c37 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -5,7 +5,7 @@ use super::*; use openshell_core::config::DEFAULT_SERVER_PORT; use openshell_core::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, + LABEL_SANDBOX_NAMESPACE, supervisor_cache_path_with_base, }; use openshell_core::progress::{ PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY, @@ -37,14 +37,18 @@ fn test_sandbox() -> DriverSandbox { log_level: "debug".to_string(), environment: HashMap::from([("SPEC_ENV".to_string(), "spec".to_string())]), template: Some(DriverSandboxTemplate { - image: "ghcr.io/nvidia/openshell/sandbox:dev".to_string(), + image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), agent_socket_path: String::new(), labels: HashMap::new(), environment: HashMap::from([("TEMPLATE_ENV".to_string(), "template".to_string())]), ..Default::default() }), + policy: None, resource_requirements: None, sandbox_token: String::new(), + command: Vec::new(), + tty: false, + await_main_process_attachment: false, }), status: None, workspace: String::new(), @@ -102,6 +106,10 @@ fn runtime_config() -> DockerDriverRuntimeConfig { ), host_alias_ip: IpAddr::V4(Ipv4Addr::new(172, 18, 0, 1)), }, + gateway_callback_bind_address: Some(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(172, 18, 0, 1)), + DEFAULT_SERVER_PORT, + )), ssh_socket_path: "/run/openshell/ssh.sock".to_string(), stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS, log_level: "info".to_string(), @@ -156,9 +164,601 @@ fn test_driver_with_config(config: DockerDriverRuntimeConfig) -> DockerComputeDr CdiGpuInventory::default(), allow_all_default_gpu, )), + lifecycle_event_fences: DockerLifecycleEventFences::default(), } } +type TestDriverClient = + openshell_core::proto::compute::v1::compute_driver_client::ComputeDriverClient< + tonic::transport::Channel, + >; + +fn request_with_traceparent(message: T) -> Request { + let mut request = Request::new(message); + request.metadata_mut().insert( + "traceparent", + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + .parse() + .unwrap(), + ); + request +} + +async fn standalone_traced_client() -> ( + TestDriverClient, + tokio::sync::oneshot::Sender<()>, + JoinHandle>, +) { + use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let service = ComputeDriverService::new(test_driver_with_config(runtime_config())); + let server = tokio::spawn(async move { + tonic::transport::Server::builder() + .layer(openshell_otel::compute_driver_rpc_layer()) + .add_service(ComputeDriverServer::new(service)) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::TcpListenerStream::new(listener), + async { + let _ = shutdown_rx.await; + }, + ) + .await + }); + let client = TestDriverClient::connect(format!("http://{address}")) + .await + .unwrap(); + (client, shutdown, server) +} + +#[tokio::test] +async fn tracing_standalone_rpc_layer_propagates_context_and_records_errors() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let dispatch = tracing::Dispatch::new( + tracing_subscriber::registry().with(otel_tracing::TRACING.layer(&provider)), + ); + let _dispatch = tracing::dispatcher::set_default(&dispatch); + let (mut client, shutdown, server) = standalone_traced_client().await; + + client + .get_capabilities(request_with_traceparent(GetCapabilitiesRequest {})) + .await + .expect("capabilities should succeed"); + client + .validate_sandbox_create(request_with_traceparent(ValidateSandboxCreateRequest { + sandbox: None, + })) + .await + .expect_err("missing sandbox should fail"); + drop(client); + shutdown.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("standalone test server should stop") + .expect("standalone test server should not panic") + .expect("standalone test server should stop cleanly"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let capabilities = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .expect("capabilities RPC span"); + assert_eq!( + capabilities.span_context.trace_id().to_string(), + "4bf92f3577b34da6a3ce929d0e0e4736" + ); + assert_eq!(capabilities.parent_span_id.to_string(), "00f067aa0ba902b7"); + assert!(capabilities.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.method" + && attribute.value.to_string() == "openshell.compute.v1.ComputeDriver/GetCapabilities" + })); + assert!( + capabilities + .attributes + .iter() + .all(|attribute| attribute.key.as_str() != "rpc.service"), + "the current RPC semantic conventions integrate the service into rpc.method" + ); + let failed = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate") + .expect("failed RPC span"); + assert!(matches!( + failed.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_in_process_service_preserves_the_driver_rpc_server_boundary() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::{Instrument as _, instrument::WithSubscriber as _}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let gateway_exporter = InMemorySpanExporterBuilder::new().build(); + let gateway_provider = SdkTracerProvider::builder() + .with_simple_exporter(gateway_exporter.clone()) + .build(); + let driver_exporter = InMemorySpanExporterBuilder::new().build(); + let driver_provider = SdkTracerProvider::builder() + .with_simple_exporter(driver_exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(openshell_otel::layer_excluding_target_prefixes( + &gateway_provider, + "gateway-test", + otel_tracing::TRACING.in_process_targets(), + )) + .with(otel_tracing::TRACING.in_process_layer(&driver_provider)); + let service = ComputeDriverService::new_in_process(test_driver_with_config(runtime_config())); + + async { + let gateway_span = tracing::info_span!( + target: "openshell_server::compute", + "driver", + otel.name = "openshell.compute.v1.ComputeDriver/GetCapabilities", + otel.kind = "client" + ); + ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) + .instrument(gateway_span) + .await?; + + let unrelated = tracing::info_span!( + target: "openshell_driver_kubernetes::compute", + "kubernetes.operation" + ); + drop(unrelated.enter()); + drop(unrelated); + let selected_backend = tracing::info_span!( + target: "openshell_driver_docker::compute", + "docker.operation" + ); + drop(selected_backend.enter()); + drop(selected_backend); + Ok::<_, Status>(()) + } + .with_subscriber(subscriber) + .await + .expect("capabilities should succeed"); + async { + let gateway_span = tracing::info_span!( + target: "openshell_server::compute", + "driver", + otel.name = "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate", + otel.kind = "client" + ); + ComputeDriver::validate_sandbox_create( + &service, + Request::new(ValidateSandboxCreateRequest { sandbox: None }), + ) + .instrument(gateway_span) + .await + } + .with_subscriber( + tracing_subscriber::registry() + .with(openshell_otel::layer_excluding_target_prefixes( + &gateway_provider, + "gateway-test", + otel_tracing::TRACING.in_process_targets(), + )) + .with(otel_tracing::TRACING.in_process_layer(&driver_provider)), + ) + .await + .expect_err("missing sandbox should fail"); + gateway_provider.force_flush().unwrap(); + driver_provider.force_flush().unwrap(); + + let gateway_spans = gateway_exporter.get_finished_spans().unwrap(); + let driver_spans = driver_exporter.get_finished_spans().unwrap(); + let client = gateway_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .unwrap(); + let server = driver_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .expect("in-process server span"); + assert_eq!( + server.span_context.trace_id(), + client.span_context.trace_id() + ); + assert_eq!(server.parent_span_id, client.span_context.span_id()); + assert_eq!(server.span_kind, opentelemetry::trace::SpanKind::Server); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.method" + && attribute.value.to_string() == "openshell.compute.v1.ComputeDriver/GetCapabilities" + })); + assert!( + server + .attributes + .iter() + .all(|attribute| attribute.key.as_str() != "rpc.service"), + "the current RPC semantic conventions integrate the service into rpc.method" + ); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" && attribute.value.to_string() == "OK" + })); + assert!( + gateway_spans + .iter() + .any(|span| span.name == "kubernetes.operation"), + "unrelated driver targets must remain gateway spans" + ); + assert!( + driver_spans + .iter() + .all(|span| span.name != "kubernetes.operation"), + "the Docker provider must not claim unrelated driver spans" + ); + assert!( + gateway_spans + .iter() + .all(|span| span.name != "docker.operation"), + "the gateway provider must not claim the selected driver's backend spans" + ); + assert!( + driver_spans + .iter() + .any(|span| span.name == "docker.operation"), + "the Docker provider must export backend spans from the selected driver" + ); + let failed = driver_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate") + .expect("failed in-process server span"); + assert!(matches!( + failed.status, + opentelemetry::trace::Status::Error { .. } + )); + assert!(failed.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "INVALID_ARGUMENT" + })); + gateway_provider.shutdown().unwrap(); + driver_provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_lifecycle_rpc_failures_export_docker_operation_spans() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::TRACING.layer(&provider)); + let driver = test_driver_with_config(runtime_config()); + + async { + ComputeDriver::create_sandbox( + &driver, + Request::new(CreateSandboxRequest { sandbox: None }), + ) + .await + .expect_err("missing sandbox should fail"); + ComputeDriver::start_sandbox(&driver, Request::new(StartSandboxRequest::default())) + .await + .expect_err("missing start identifier should fail"); + ComputeDriver::stop_sandbox(&driver, Request::new(StopSandboxRequest::default())) + .await + .expect_err("missing stop identifier should fail"); + ComputeDriver::delete_sandbox(&driver, Request::new(DeleteSandboxRequest::default())) + .await + .expect_err("missing delete identifier should fail"); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + for name in [ + "docker.schedule_sandbox", + "docker.start_sandbox", + "docker.stop_sandbox", + "docker.delete_sandbox", + ] { + let span = spans + .iter() + .find(|span| span.name == name) + .unwrap_or_else(|| panic!("{name} should be exported")); + assert!( + matches!(span.status, opentelemetry::trace::Status::Error { .. }), + "{name} should record the failed operation" + ); + } + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_direct_start_exports_a_docker_start_span() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::TRACING.layer(&provider)); + let driver = test_driver_with_config(runtime_config()); + + DockerComputeDriver::start_sandbox(&driver, "", "") + .with_subscriber(subscriber) + .await + .expect_err("missing identifier should fail"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "docker.start_sandbox") + .expect("direct startup operation should export docker.start_sandbox"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_image_preparation_failure_exports_nested_failed_spans() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::{Instrument as _, instrument::WithSubscriber as _}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::TRACING.layer(&provider)); + let mut config = runtime_config(); + config.image_pull_policy = "unsupported".to_string(); + let driver = test_driver_with_config(config); + + async { + driver + .provision_sandbox_inner(&test_sandbox()) + .instrument(tracing::info_span!( + "docker.provision", + otel.status_code = tracing::field::Empty + )) + .await + } + .with_subscriber(subscriber) + .await + .expect_err("unsupported image pull policy should fail provisioning"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let provision = spans + .iter() + .find(|span| span.name == "docker.provision") + .expect("provisioning span should be exported"); + assert!(matches!( + provision.status, + opentelemetry::trace::Status::Error { .. } + )); + let prepare_image = spans + .iter() + .find(|span| span.name == "docker.prepare_image") + .expect("image preparation span should be exported"); + assert_eq!( + prepare_image.parent_span_id, + provision.span_context.span_id() + ); + assert!(matches!( + prepare_image.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn background_provisioning_does_not_extend_the_scheduling_span_lifetime() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::Instrument as _; + use tracing_opentelemetry::OpenTelemetrySpanExt as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(otel_tracing::TRACING.layer(&provider)); + let dispatch = tracing::Dispatch::new(subscriber); + let _dispatch = tracing::dispatcher::set_default(&dispatch); + + let scheduling = tracing::info_span!("docker.schedule_sandbox"); + let entered = scheduling.enter(); + let sandbox = test_sandbox(); + let provisioning = provisioning_span(&scheduling.context(), &sandbox, "test-image"); + let task = tokio::spawn(futures::future::pending::<()>().instrument(provisioning)); + drop(entered); + drop(scheduling); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert!( + spans + .iter() + .any(|span| span.name == "docker.schedule_sandbox"), + "the scheduling span should finish while background provisioning is pending" + ); + assert!( + spans.iter().all(|span| span.name != "docker.provision"), + "the provisioning span should remain open with the background task" + ); + + task.abort(); + task.await + .expect_err("the pending task should be cancelled"); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_in_process_stream_span_lives_until_stream_failure() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + let inner: WatchStream = Box::pin(futures::stream::iter([Err(Status::internal( + "watch failed", + ))])); + let mut stream = TracedWatchStream::new(inner, span); + + provider.force_flush().unwrap(); + assert!( + exporter.get_finished_spans().unwrap().is_empty(), + "server span must remain open while the response stream is alive" + ); + stream + .next() + .await + .expect("stream item") + .expect_err("stream should fail"); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream ends"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_in_process_stream_records_ok_when_stream_completes() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + let inner: WatchStream = Box::pin(futures::stream::empty()); + let mut stream = TracedWatchStream::new(inner, span); + + assert!(stream.next().await.is_none()); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream completes"); + assert!(span.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" && attribute.value.to_string() == "OK" + })); + provider.shutdown().unwrap(); +} + +#[tokio::test] +async fn tracing_in_process_stream_leaves_status_unset_when_dropped() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + let inner: WatchStream = Box::pin(futures::stream::pending()); + let stream = TracedWatchStream::new(inner, span); + + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream is dropped"); + assert!(matches!(span.status, opentelemetry::trace::Status::Unset)); + assert!( + span.attributes + .iter() + .all(|attribute| attribute.key.as_str() != "rpc.response.status_code") + ); + provider.shutdown().unwrap(); +} + #[tokio::test] async fn gateway_listener_requirements_report_managed_bridge_address() { let config = runtime_config(); @@ -185,6 +785,7 @@ async fn gateway_listener_requirements_report_managed_bridge_address() { async fn gateway_listener_requirements_are_empty_for_host_gateway_route() { let mut config = runtime_config(); config.gateway_route = DockerGatewayRoute::HostGateway; + config.gateway_callback_bind_address = None; let driver = test_driver_with_config(config); let response = driver @@ -196,6 +797,26 @@ async fn gateway_listener_requirements_are_empty_for_host_gateway_route() { assert!(response.requirements.is_empty()); } +#[tokio::test] +async fn host_gateway_route_reports_ipv4_loopback_callback_listener() { + let mut config = runtime_config(); + config.gateway_route = DockerGatewayRoute::HostGateway; + config.gateway_callback_bind_address = Some("127.0.0.1:17670".parse().unwrap()); + let driver = test_driver_with_config(config); + + let response = driver + .get_gateway_listener_requirements(Request::new(GetGatewayListenerRequirementsRequest {})) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.requirements.len(), 1); + assert_eq!( + response.requirements[0].selector, + Some(Selector::ExactBindAddress("127.0.0.1:17670".to_string())) + ); +} + #[test] fn container_visible_endpoint_rewrites_loopback_hosts() { assert_eq!( @@ -297,6 +918,31 @@ fn docker_gateway_route_uses_host_gateway_for_docker_desktop() { ); } +#[test] +fn host_gateway_route_requests_ipv4_loopback_for_ipv6_primary() { + assert_eq!( + docker_gateway_callback_bind_address( + &DockerGatewayRoute::HostGateway, + "[::1]:17670".parse().unwrap(), + ), + Some("127.0.0.1:17670".parse().unwrap()) + ); +} + +#[test] +fn host_gateway_route_reuses_ipv4_primary_when_it_covers_loopback() { + for primary in ["127.0.0.1:17670", "0.0.0.0:17670"] { + assert_eq!( + docker_gateway_callback_bind_address( + &DockerGatewayRoute::HostGateway, + primary.parse().unwrap(), + ), + None, + "{primary} already covers the IPv4 loopback callback" + ); + } +} + #[test] fn docker_gateway_route_uses_host_gateway_for_colima() { let info = SystemInfo { @@ -567,7 +1213,40 @@ fn build_environment_sets_docker_tls_paths() { assert!(env.contains(&format!("OPENSHELL_TLS_KEY={TLS_KEY_MOUNT_PATH}"))); assert!(env.contains(&"TEMPLATE_ENV=template".to_string())); assert!(env.contains(&"SPEC_ENV=spec".to_string())); - assert!(env.contains(&"OPENSHELL_SANDBOX_COMMAND=sleep infinity".to_string())); + assert!(env.contains(&format!( + "{}={}", + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY + ))); + let encoded = env + .iter() + .find_map(|entry| { + entry + .strip_prefix("OPENSHELL_MAIN_PROCESS_SPEC=") + .map(str::to_string) + }) + .expect("main-process transport"); + let main = openshell_core::sandbox_env::MainProcessConfig::decode(&encoded).unwrap(); + // An omitted command is forwarded empty; the supervisor resolves the default + // login shell against the sandbox image at startup. + assert!(main.command.is_empty()); + assert!(main.tty); +} + +#[test] +fn build_environment_keeps_network_capabilities_driver_controlled() { + let mut sandbox = test_sandbox(); + sandbox.spec.as_mut().unwrap().environment.insert( + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), + "spoofed".to_string(), + ); + let env = build_environment(&sandbox, &runtime_config()); + assert!(env.contains(&format!( + "{}={}", + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY + ))); + assert!(!env.iter().any(|entry| entry.ends_with("=spoofed"))); } #[test] @@ -594,12 +1273,34 @@ fn build_environment_protects_oci_identity_metadata() { assert!(!env.iter().any(|entry| entry.ends_with("=9999"))); } +#[test] +fn build_environment_strips_gateway_tls_server_name() { + let mut sandbox = test_sandbox(); + let spec = sandbox.spec.as_mut().unwrap(); + spec.environment.insert( + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME.to_string(), + "evil.attacker.example.com".to_string(), + ); + + let env = build_environment(&sandbox, &runtime_config()); + + assert!( + !env.iter().any(|entry| entry.starts_with(&format!( + "{}=", + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME + ))), + "GATEWAY_TLS_SERVER_NAME must be stripped from the supervisor environment" + ); +} + #[test] fn container_creation_uses_inspected_immutable_image() { let sandbox = test_sandbox(); let metadata = DockerImageMetadata { id: "sha256:immutable".to_string(), user: "1234:1235".to_string(), + working_dir: "/workspace/project".to_string(), + volumes: Vec::new(), }; let body = build_container_create_body_for_image( &sandbox, @@ -612,12 +1313,183 @@ fn container_creation_uses_inspected_immutable_image() { assert_eq!(body.image.as_deref(), Some("sha256:immutable")); assert_eq!(body.user.as_deref(), Some("0")); + assert_eq!(body.working_dir.as_deref(), Some("/")); + assert_eq!( + body.cmd.as_deref(), + Some(&["--workdir".to_string(), "/workspace/project".to_string()][..]) + ); assert!(body.env.unwrap().contains(&format!( "{}=1234:1235", openshell_core::sandbox_env::OCI_IMAGE_USER ))); } +#[test] +fn container_creation_rejects_invalid_oci_working_dir() { + let metadata = DockerImageMetadata { + id: "sha256:immutable".to_string(), + user: "1234:1235".to_string(), + working_dir: "relative/workspace".to_string(), + volumes: Vec::new(), + }; + let err = build_container_create_body_for_image( + &test_sandbox(), + &runtime_config(), + &DockerSandboxDriverConfig::default(), + None, + &metadata, + ) + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("must be an absolute container path")); +} + +#[test] +fn container_creation_rejects_openshell_control_path_working_dir() { + let metadata = DockerImageMetadata { + id: "sha256:immutable".to_string(), + user: "1234:1235".to_string(), + working_dir: "/opt/openshell/bin/project".to_string(), + volumes: Vec::new(), + }; + let err = build_container_create_body_for_image( + &test_sandbox(), + &runtime_config(), + &DockerSandboxDriverConfig::default(), + None, + &metadata, + ) + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("OpenShell control path")); +} + +#[test] +fn container_creation_rejects_image_volume_that_masks_working_dir() { + let sandbox = test_sandbox(); + let metadata = DockerImageMetadata { + id: "sha256:immutable".to_string(), + user: "1234:1235".to_string(), + working_dir: "/workspace/project".to_string(), + volumes: vec!["/workspace".to_string()], + }; + + let error = build_container_create_body_for_image( + &sandbox, + &runtime_config(), + &DockerSandboxDriverConfig::default(), + None, + &metadata, + ) + .unwrap_err(); + + assert!( + error + .message() + .contains("masks OCI WorkingDir '/workspace/project'") + ); +} + +#[test] +fn container_creation_rejects_image_volume_over_configured_ssh_socket() { + let metadata = DockerImageMetadata { + id: "sha256:immutable".to_string(), + user: "1234:1235".to_string(), + working_dir: "/workspace".to_string(), + volumes: vec!["/custom-runtime".to_string()], + }; + let mut config = runtime_config(); + config.ssh_socket_path = "/custom-runtime/ssh.sock".to_string(); + + let error = build_container_create_body_for_image( + &test_sandbox(), + &config, + &DockerSandboxDriverConfig::default(), + None, + &metadata, + ) + .unwrap_err(); + + assert!(error.message().contains("OpenShell control path")); +} + +#[test] +fn container_creation_reserves_resolved_workspace_root_but_allows_nested_mounts() { + let metadata = DockerImageMetadata { + id: "sha256:immutable".to_string(), + user: "1234:1235".to_string(), + working_dir: "/workspace".to_string(), + volumes: Vec::new(), + }; + let root_mount: DockerSandboxDriverConfig = serde_json::from_value(serde_json::json!({ + "mounts": [{"type": "tmpfs", "target": "/workspace"}] + })) + .unwrap(); + let err = build_container_create_body_for_image( + &test_sandbox(), + &runtime_config(), + &root_mount, + None, + &metadata, + ) + .unwrap_err(); + assert!( + err.message() + .contains("reserved for the OpenShell workspace") + ); + + let ancestor_mount: DockerSandboxDriverConfig = serde_json::from_value(serde_json::json!({ + "mounts": [{"type": "tmpfs", "target": "/workspace"}] + })) + .unwrap(); + let nested_metadata = DockerImageMetadata { + working_dir: "/workspace/project".to_string(), + volumes: Vec::new(), + ..metadata.clone() + }; + let err = build_container_create_body_for_image( + &test_sandbox(), + &runtime_config(), + &ancestor_mount, + None, + &nested_metadata, + ) + .unwrap_err(); + assert!( + err.message() + .contains("reserved for the OpenShell workspace") + ); + + let nested_mount: DockerSandboxDriverConfig = serde_json::from_value(serde_json::json!({ + "mounts": [{"type": "tmpfs", "target": "/workspace/cache"}] + })) + .unwrap(); + build_container_create_body_for_image( + &test_sandbox(), + &runtime_config(), + &nested_mount, + None, + &metadata, + ) + .expect("nested workspace mounts remain supported"); + + let compatibility_path_mount: DockerSandboxDriverConfig = + serde_json::from_value(serde_json::json!({ + "mounts": [{"type": "tmpfs", "target": "/sandbox"}] + })) + .unwrap(); + build_container_create_body_for_image( + &test_sandbox(), + &runtime_config(), + &compatibility_path_mount, + None, + &metadata, + ) + .expect("/sandbox remains mountable when the inspected workspace is elsewhere"); +} + #[test] fn build_environment_keeps_path_driver_controlled() { let mut sandbox = test_sandbox(); @@ -1153,7 +2025,7 @@ fn driver_config_rejects_reserved_mount_targets() { "mounts": [{ "type": "volume", "source": "work-nfs", - "target": "/etc/openshell/auth/custom" + "target": "/etc/openshell/auth" }] }))); @@ -1163,6 +2035,36 @@ fn driver_config_rejects_reserved_mount_targets() { assert!(err.message().contains("reserved OpenShell path")); } +#[test] +fn driver_config_rejects_mount_over_configured_ssh_socket() { + let mount_config: DockerSandboxDriverConfig = serde_json::from_value(serde_json::json!({ + "mounts": [{ + "type": "tmpfs", + "target": "/custom-runtime" + }] + })) + .unwrap(); + let metadata = DockerImageMetadata { + id: "sha256:immutable".to_string(), + user: "1234:1235".to_string(), + working_dir: "/workspace".to_string(), + volumes: Vec::new(), + }; + let mut config = runtime_config(); + config.ssh_socket_path = "/custom-runtime/ssh.sock".to_string(); + + let error = build_container_create_body_for_image( + &test_sandbox(), + &config, + &mount_config, + None, + &metadata, + ) + .unwrap_err(); + + assert!(error.message().contains("OpenShell control path")); +} + #[test] fn docker_local_volume_with_bind_option_is_bind_backed() { let volume = inspected_volume( @@ -1248,14 +2150,17 @@ fn managed_container_label_filters_include_gateway_namespace() { } #[test] -fn build_container_create_body_clears_inherited_cmd() { +fn build_container_create_body_replaces_inherited_cmd_with_workspace_arg() { let create_body = build_container_create_body(&test_sandbox(), &runtime_config()).unwrap(); assert_eq!( create_body.entrypoint, Some(vec![SUPERVISOR_MOUNT_PATH.to_string()]) ); - assert_eq!(create_body.cmd, Some(Vec::new())); + assert_eq!( + create_body.cmd, + Some(vec!["--workdir".to_string(), "/sandbox".to_string()]) + ); assert_eq!( create_body .labels @@ -1968,7 +2873,7 @@ fn validate_linux_elf_binary_rejects_non_elf_files() { fs::write(&path, b"not-elf").unwrap(); let err = validate_linux_elf_binary(&path).unwrap_err(); - assert!(err.to_string().contains("Linux ELF executable")); + assert!(err.contains("Linux ELF executable")); } #[test] @@ -2174,8 +3079,11 @@ fn docker_supervisor_image_refreshes_mutable_tags_only() { #[test] fn supervisor_cache_path_namespaces_by_digest_under_openshell_data_dir() { let base = PathBuf::from("/var/cache/share"); - let path = - supervisor_cache_path_with_base(&base, "sha256:abc123deadbeef0123456789cafe0123456789fe"); + let path = supervisor_cache_path_with_base( + &base, + "docker-supervisor", + "sha256:abc123deadbeef0123456789cafe0123456789fe", + ); assert_eq!( path, @@ -2188,8 +3096,8 @@ fn supervisor_cache_path_namespaces_by_digest_under_openshell_data_dir() { #[test] fn supervisor_cache_path_isolates_different_digests() { let base = PathBuf::from("/data"); - let left = supervisor_cache_path_with_base(&base, "sha256:aaaaaaaa"); - let right = supervisor_cache_path_with_base(&base, "sha256:bbbbbbbb"); + let left = supervisor_cache_path_with_base(&base, "docker-supervisor", "sha256:aaaaaaaa"); + let right = supervisor_cache_path_with_base(&base, "docker-supervisor", "sha256:bbbbbbbb"); assert_ne!( left.parent().unwrap(), right.parent().unwrap(), @@ -2263,14 +3171,14 @@ fn extract_first_tar_entry_rejects_empty_archive() { } #[test] -fn container_state_needs_resume_matches_startable_states() { +fn container_state_needs_start_matches_startable_states() { for state in [ ContainerSummaryStateEnum::EXITED, ContainerSummaryStateEnum::CREATED, ] { assert!( - container_state_needs_resume(state), - "{state:?} should be resumed with Docker start", + container_state_needs_start(state), + "{state:?} should be started with Docker start", ); } @@ -2283,8 +3191,139 @@ fn container_state_needs_resume_matches_startable_states() { ContainerSummaryStateEnum::EMPTY, ] { assert!( - !container_state_needs_resume(state), - "{state:?} should not be resumed with Docker start", + !container_state_needs_start(state), + "{state:?} should not be started with Docker start", + ); + } +} + +#[test] +fn lifecycle_fence_rejects_polled_exit_from_before_restart() { + let fences = DockerLifecycleEventFences::default(); + fences.begin_start("sandbox-1"); + assert!(fences.start_in_progress("sandbox-1")); + fences.finish_start("sandbox-1"); + assert!(!fences.start_in_progress("sandbox-1")); + + fences.record_previous_exit("sandbox-1", Some("2026-08-12T16:39:13Z")); + assert_eq!( + fences.previous_exit("sandbox-1").as_deref(), + Some("2026-08-12T16:39:13Z") + ); + + let previous_exit = ContainerState { + status: Some(ContainerStateStatusEnum::EXITED), + finished_at: Some("2026-08-12T16:39:13Z".to_string()), + ..Default::default() + }; + assert!(docker_polled_exit_is_stale( + "2026-08-12T16:39:13Z", + Some(&previous_exit), + )); + + let running = ContainerState { + status: Some(ContainerStateStatusEnum::RUNNING), + ..previous_exit.clone() + }; + assert!(docker_polled_exit_is_stale( + "2026-08-12T16:39:13Z", + Some(&running), + )); + + let new_exit = ContainerState { + finished_at: Some("2026-08-12T16:40:00Z".to_string()), + ..previous_exit + }; + assert!(!docker_polled_exit_is_stale( + "2026-08-12T16:39:13Z", + Some(&new_exit), + )); + + fences.remove("sandbox-1"); + assert!(fences.previous_exit("sandbox-1").is_none()); +} + +fn exited_sandbox_with_ready_reason(reason: &str) -> DriverSandbox { + DriverSandbox { + id: "sbx-exit".to_string(), + name: "demo".to_string(), + namespace: String::new(), + spec: None, + status: Some(DriverSandboxStatus { + sandbox_name: "demo".to_string(), + instance_id: "container-1".to_string(), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![DriverCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.to_string(), + message: "Container exited".to_string(), + last_transition_time: String::new(), + }], + deleting: false, + }), + workspace: String::new(), + } +} + +fn ready_reason(sandbox: &DriverSandbox) -> &str { + sandbox + .status + .as_ref() + .and_then(|status| status.conditions.iter().find(|c| c.r#type == "Ready")) + .map(|c| c.reason.as_str()) + .expect("Ready condition present") +} + +#[test] +fn docker_signal_kill_reclassified_as_runtime_restart() { + // 137 (128+SIGKILL) and 143 (128+SIGTERM) mark an external termination — + // the signature of a machine/daemon restart — and become recoverable + // `ContainerRuntimeRestart`. + for exit_code in [137, 143] { + let mut sandbox = exited_sandbox_with_ready_reason(CONDITION_EXITED); + let state = ContainerState { + status: Some(ContainerStateStatusEnum::EXITED), + oom_killed: Some(false), + exit_code: Some(exit_code), + ..Default::default() + }; + apply_docker_exit_classification(&mut sandbox, &state); + assert_eq!( + ready_reason(&sandbox), + CONDITION_RUNTIME_RESTART, + "exit code {exit_code} should reclassify as runtime restart" ); } } + +#[test] +fn docker_ordinary_exit_stays_terminal() { + // An application exit (non-zero error code) stays `ContainerExited` so its + // failure signal survives instead of being relaunched on startup. + let mut sandbox = exited_sandbox_with_ready_reason(CONDITION_EXITED); + let state = ContainerState { + status: Some(ContainerStateStatusEnum::EXITED), + oom_killed: Some(false), + exit_code: Some(1), + ..Default::default() + }; + apply_docker_exit_classification(&mut sandbox, &state); + assert_eq!(ready_reason(&sandbox), CONDITION_EXITED); +} + +#[test] +fn docker_oom_kill_stays_terminal_despite_137() { + // An OOM kill reports exit 137 but must NOT be treated as a recoverable + // restart — it is a genuine failure and stays terminal. + let mut sandbox = exited_sandbox_with_ready_reason(CONDITION_EXITED); + let state = ContainerState { + status: Some(ContainerStateStatusEnum::EXITED), + oom_killed: Some(true), + exit_code: Some(137), + ..Default::default() + }; + apply_docker_exit_classification(&mut sandbox, &state); + assert_eq!(ready_reason(&sandbox), CONDITION_EXITED); +} diff --git a/crates/openshell-driver-kubernetes-secrets/Cargo.toml b/crates/openshell-driver-kubernetes-secrets/Cargo.toml new file mode 100644 index 0000000000..3655013ffa --- /dev/null +++ b/crates/openshell-driver-kubernetes-secrets/Cargo.toml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-kubernetes-secrets" +description = "Kubernetes Secrets credential driver for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-driver-kubernetes-secrets" +path = "src/main.rs" + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +clap = { workspace = true } +futures = { workspace = true } +k8s-openapi = { workspace = true } +kube = { workspace = true } +miette = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } +tonic = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-driver-kubernetes-secrets/src/lib.rs b/crates/openshell-driver-kubernetes-secrets/src/lib.rs new file mode 100644 index 0000000000..91224212bd --- /dev/null +++ b/crates/openshell-driver-kubernetes-secrets/src/lib.rs @@ -0,0 +1,1199 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Credential driver backed by Kubernetes Secret objects. + +use std::collections::BTreeMap; + +use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{DeleteParams, Patch, PatchParams, PostParams, Preconditions}; +use kube::{Api, Client}; +use openshell_core::VERSION; +use openshell_core::proto::CredentialHandle; +use openshell_core::proto::credentials::v1::{ + DeleteCredentialRequest, DeleteCredentialResponse, GetCredentialDriverCapabilitiesRequest, + GetCredentialDriverCapabilitiesResponse, ListCredentialsRequest, ListCredentialsResponse, + ResolveCredentialRequest, ResolveCredentialsRequest, ResolveCredentialsResponse, + ResolvedCredential, StoreCredentialRequest, StoreCredentialResponse, + credential_driver_server::CredentialDriver, +}; +use openshell_core::{Error, Result as CoreResult}; +use sha2::{Digest, Sha256}; +use tonic::{Request, Response, Status}; + +const SERVICE_ACCOUNT_NAMESPACE_PATH: &str = + "/var/run/secrets/kubernetes.io/serviceaccount/namespace"; +const HANDLE_VERSION: &str = "v1"; +const OBJECT_ID_METADATA_KEY: &str = "openshell.storage_object_id"; +const MANAGED_BY_LABEL: &str = "app.kubernetes.io/managed-by"; +const MANAGED_BY_VALUE: &str = "openshell"; +const OWNER_ANNOTATION: &str = "openshell.nvidia.com/provider-credential-id"; +const CONFLICT_RETRY_LIMIT: u32 = 3; + +pub struct KubernetesSecretsCredentialDriver { + client: Client, + settings: KubernetesSecretsDriverSettings, +} + +#[derive(Debug, Clone)] +pub struct CredentialDriverService { + driver: KubernetesSecretsCredentialDriver, +} + +impl CredentialDriverService { + #[must_use] + pub fn new(driver: KubernetesSecretsCredentialDriver) -> Self { + Self { driver } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum WorkspaceMode { + #[default] + Shared, + Managed, + Operator, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct KubernetesSecretsDriverSettings { + namespace: String, + allow_reference_namespace: bool, + workspace_mode: WorkspaceMode, + gateway_id: String, +} + +impl KubernetesSecretsDriverSettings { + fn target_namespace(&self, workspace: &str) -> String { + match self.workspace_mode { + WorkspaceMode::Shared => self.namespace.clone(), + WorkspaceMode::Managed => { + format!("openshell-{}-{}", self.gateway_id, workspace) + } + WorkspaceMode::Operator => workspace.to_string(), + } + } +} + +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesSecretsDriverConfig { + namespace: Option, + allow_reference_namespace: bool, + workspace_mode: WorkspaceMode, + gateway_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct KubernetesSecretReference { + namespace: String, + secret_name: String, + key: String, +} + +impl KubernetesSecretsCredentialDriver { + pub const NAME: &'static str = "kubernetes-secrets"; + + pub async fn from_config(config: &toml::Table) -> CoreResult { + let settings = KubernetesSecretsDriverSettings::from_table(config)?; + let client = Client::try_default().await.map_err(|err| { + Error::config(format!( + "failed to configure kubernetes-secrets credential driver: {err}" + )) + })?; + Ok(Self { client, settings }) + } + + fn handle_from_request( + request_id: &str, + handle: Option, + ) -> Result { + handle.ok_or_else(|| { + Status::invalid_argument(format!( + "kubernetes-secrets credential request '{request_id}' is missing handle" + )) + }) + } + + fn parse_handle( + handle: &CredentialHandle, + credential_key: &str, + ) -> Result { + let parts = handle.handle.split(':').collect::>(); + if parts.len() != 3 || parts[0] != HANDLE_VERSION { + return Err(Status::invalid_argument( + "kubernetes-secrets credential handle is malformed", + )); + } + let namespace = required_handle_component("namespace", parts[1])?; + if !is_dns_label(namespace) { + return Err(Status::invalid_argument( + "kubernetes-secrets credential handle namespace is invalid", + )); + } + let secret_name = required_handle_component("secret", parts[2])?; + if !is_dns_subdomain(secret_name) { + return Err(Status::invalid_argument( + "kubernetes-secrets credential handle Secret name is invalid", + )); + } + let key = required_handle_component("credential_key", credential_key)?; + if !is_secret_data_key(key) { + return Err(Status::invalid_argument( + "kubernetes-secrets credential key must be a valid Kubernetes Secret data key", + )); + } + + Ok(KubernetesSecretReference { + namespace: namespace.to_string(), + secret_name: secret_name.to_string(), + key: key.to_string(), + }) + } + + fn resolve_handle( + &self, + handle: &CredentialHandle, + credential_key: &str, + ) -> Result { + let reference = Self::parse_handle(handle, credential_key)?; + // In managed/operator modes secrets live in workspace-specific + // namespaces so cross-namespace handles are expected. + if self.settings.workspace_mode == WorkspaceMode::Shared + && reference.namespace != self.settings.namespace + && !self.settings.allow_reference_namespace + { + return Err(Status::permission_denied(format!( + "kubernetes-secrets credential handle references namespace '{}' but the driver is \ + configured for namespace '{}'; set allow_reference_namespace = true to allow \ + cross-namespace references", + reference.namespace, self.settings.namespace + ))); + } + Ok(reference) + } + + pub async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + let owner_id = credential_owner_id( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + ); + let object_id = if let Some(existing_handle) = request.existing_handle.as_ref() { + object_id_from_handle(existing_handle, &request.provider_id)? + } else { + requested_object_id(&request.object_id, &request.provider_id)?.to_string() + }; + let reference = if let Some(existing_handle) = request.existing_handle.as_ref() { + let reference = self.resolve_handle(existing_handle, &request.credential_key)?; + validate_expected_secret_name( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + &reference.secret_name, + )?; + reference + } else { + KubernetesSecretReference { + namespace: self.settings.target_namespace(&request.workspace), + secret_name: managed_secret_name( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + ), + key: required_handle_component("credential_key", &request.credential_key)? + .to_string(), + } + }; + if !is_secret_data_key(&reference.key) { + return Err(Status::invalid_argument( + "kubernetes-secrets credential key must be a valid Kubernetes Secret data key", + )); + } + if request.existing_handle.is_some() { + self.overwrite_secret_value(&reference, &owner_id, &request.value) + .await?; + } else { + self.create_secret_value(&reference, &owner_id, &request.value) + .await?; + } + Ok(CredentialHandle { + driver: Self::NAME.to_string(), + handle: format!( + "{HANDLE_VERSION}:{}:{}", + reference.namespace, reference.secret_name + ), + metadata: std::collections::HashMap::from([( + OBJECT_ID_METADATA_KEY.to_string(), + object_id, + )]), + }) + } + + pub async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + let handle = Self::handle_from_request("delete", request.handle)?; + let reference = self.resolve_handle(&handle, &request.credential_key)?; + let object_id = object_id_from_handle(&handle, &request.provider_id)?; + validate_expected_secret_name( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + &reference.secret_name, + )?; + let owner_id = credential_owner_id( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + ); + let api: Api = Api::namespaced(self.client.clone(), &reference.namespace); + for _attempt in 0..CONFLICT_RETRY_LIMIT { + let secret = match api.get(&reference.secret_name).await { + Ok(secret) => secret, + Err(kube::Error::Api(api_err)) if api_err.code == 404 => return Ok(()), + Err(err) => { + return Err(kube_error_to_status( + &reference.namespace, + &reference.secret_name, + err, + )); + } + }; + ensure_secret_is_managed_for(&secret, &reference, &owner_id)?; + let delete_params = DeleteParams { + preconditions: Some(Preconditions { + uid: secret.metadata.uid.clone(), + resource_version: secret.metadata.resource_version.clone(), + }), + ..Default::default() + }; + match api.delete(&reference.secret_name, &delete_params).await { + Ok(_) => return Ok(()), + Err(kube::Error::Api(api_err)) if api_err.code == 404 => return Ok(()), + Err(kube::Error::Api(api_err)) if api_err.code == 409 => {} + Err(kube::Error::Api(api_err)) if api_err.code == 403 => { + return Err(Status::permission_denied(format!( + "gateway is not allowed to delete Kubernetes Secret '{}' in namespace '{}'", + reference.secret_name, reference.namespace + ))); + } + Err(err) => { + return Err(Status::unavailable(format!( + "failed to delete Kubernetes Secret '{}' in namespace '{}': {err}", + reference.secret_name, reference.namespace + ))); + } + } + } + Err(Status::aborted(format!( + "Kubernetes Secret '{}' in namespace '{}' was modified concurrently; exceeded retry limit", + reference.secret_name, reference.namespace + ))) + } + + pub async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + let futures = requests.into_iter().map(|request| async move { + let handle = Self::handle_from_request(&request.request_id, request.handle)?; + let reference = self.resolve_handle(&handle, &request.credential_key)?; + let object_id = object_id_from_handle(&handle, &request.provider_id)?; + validate_expected_secret_name( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + &reference.secret_name, + )?; + let owner_id = credential_owner_id( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + ); + let value = self.resolve_secret_value(&reference, &owner_id).await?; + Ok::<_, Status>(ResolvedCredential { + request_id: request.request_id, + value, + expires_at_ms: 0, + }) + }); + futures::future::try_join_all(futures).await + } + + async fn create_secret_value( + &self, + reference: &KubernetesSecretReference, + owner_id: &str, + value: &str, + ) -> Result<(), Status> { + let api: Api = Api::namespaced(self.client.clone(), &reference.namespace); + let secret = managed_secret(&reference.secret_name, &reference.key, owner_id, value); + match api.create(&PostParams::default(), &secret).await { + Ok(_) => Ok(()), + Err(kube::Error::Api(api_err)) if api_err.code == 409 => { + Err(Status::already_exists(format!( + "Kubernetes Secret '{}' in namespace '{}' already exists; refusing to overwrite a Secret not created for this provider credential", + reference.secret_name, reference.namespace + ))) + } + Err(err) => Err(kube_write_error_to_status( + &reference.namespace, + &reference.secret_name, + err, + )), + } + } + + async fn overwrite_secret_value( + &self, + reference: &KubernetesSecretReference, + owner_id: &str, + value: &str, + ) -> Result<(), Status> { + let api: Api = Api::namespaced(self.client.clone(), &reference.namespace); + for _attempt in 0..CONFLICT_RETRY_LIMIT { + let secret = match api.get(&reference.secret_name).await { + Ok(secret) => secret, + Err(kube::Error::Api(api_err)) if api_err.code == 404 => { + return self.create_secret_value(reference, owner_id, value).await; + } + Err(err) => { + return Err(kube_error_to_status( + &reference.namespace, + &reference.secret_name, + err, + )); + } + }; + ensure_secret_is_managed_for(&secret, reference, owner_id)?; + + let mut patch = managed_secret(&reference.secret_name, &reference.key, owner_id, value); + patch.metadata.resource_version = secret.metadata.resource_version.clone(); + match api + .patch( + &reference.secret_name, + &PatchParams::default(), + &Patch::Merge(&patch), + ) + .await + { + Ok(_) => return Ok(()), + Err(kube::Error::Api(api_err)) if api_err.code == 409 => {} + Err(err) => { + return Err(kube_write_error_to_status( + &reference.namespace, + &reference.secret_name, + err, + )); + } + } + } + Err(Status::aborted(format!( + "Kubernetes Secret '{}' in namespace '{}' was modified concurrently; exceeded retry limit", + reference.secret_name, reference.namespace + ))) + } + + async fn resolve_secret_value( + &self, + reference: &KubernetesSecretReference, + owner_id: &str, + ) -> Result { + let api: Api = Api::namespaced(self.client.clone(), &reference.namespace); + let secret = api.get(&reference.secret_name).await.map_err(|err| { + kube_error_to_status(&reference.namespace, &reference.secret_name, err) + })?; + ensure_secret_is_managed_for(&secret, reference, owner_id)?; + let data = secret.data.ok_or_else(|| { + Status::not_found(format!( + "Kubernetes Secret '{}' in namespace '{}' has no data", + reference.secret_name, reference.namespace + )) + })?; + let value = data.get(&reference.key).ok_or_else(|| { + Status::not_found(format!( + "Kubernetes Secret '{}' in namespace '{}' does not contain key '{}'", + reference.secret_name, reference.namespace, reference.key + )) + })?; + String::from_utf8(value.0.clone()).map_err(|_| { + Status::invalid_argument(format!( + "Kubernetes Secret '{}' in namespace '{}' key '{}' is not valid UTF-8", + reference.secret_name, reference.namespace, reference.key + )) + }) + } +} + +impl std::fmt::Debug for KubernetesSecretsCredentialDriver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KubernetesSecretsCredentialDriver") + .field("settings", &self.settings) + .finish_non_exhaustive() + } +} + +impl Clone for KubernetesSecretsCredentialDriver { + fn clone(&self) -> Self { + Self { + client: self.client.clone(), + settings: self.settings.clone(), + } + } +} + +#[tonic::async_trait] +impl CredentialDriver for CredentialDriverService { + async fn get_capabilities( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(GetCredentialDriverCapabilitiesResponse { + driver_name: KubernetesSecretsCredentialDriver::NAME.to_string(), + driver_version: VERSION.to_string(), + backend_kind: KubernetesSecretsCredentialDriver::NAME.to_string(), + supports_list: false, + supports_expires_at: false, + })) + } + + async fn store_credential( + &self, + request: Request, + ) -> Result, Status> { + let handle = self.driver.store_credential(request.into_inner()).await?; + Ok(Response::new(StoreCredentialResponse { + handle: Some(handle), + })) + } + + async fn delete_credential( + &self, + request: Request, + ) -> Result, Status> { + self.driver.delete_credential(request.into_inner()).await?; + Ok(Response::new(DeleteCredentialResponse {})) + } + + async fn resolve_credentials( + &self, + request: Request, + ) -> Result, Status> { + let credentials = self + .driver + .resolve_credentials(request.into_inner().credentials) + .await?; + Ok(Response::new(ResolveCredentialsResponse { credentials })) + } + + async fn list_credentials( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "kubernetes-secrets credential driver does not support listing credentials", + )) + } +} + +impl KubernetesSecretsDriverSettings { + fn from_table(config: &toml::Table) -> CoreResult { + let config: KubernetesSecretsDriverConfig = toml::Value::Table(config.clone()) + .try_into() + .map_err(|err| { + Error::config(format!( + "invalid [openshell.credential_drivers.kubernetes-secrets]: {err}" + )) + })?; + let namespace = match config.namespace { + Some(namespace) => { + let namespace = trimmed_config_string("namespace", &namespace)?; + if !is_dns_label(namespace) { + return Err(Error::config( + "[openshell.credential_drivers.kubernetes-secrets] namespace must be a Kubernetes namespace name", + )); + } + namespace.to_string() + } + None => default_namespace(), + }; + let gateway_id = config.gateway_id.unwrap_or_default(); + if config.workspace_mode == WorkspaceMode::Managed { + let gateway_id = trimmed_config_string("gateway_id", &gateway_id)?; + if !is_dns_label(gateway_id) { + return Err(Error::config( + "[openshell.credential_drivers.kubernetes-secrets] gateway_id must be a DNS-1123 label in managed workspace mode", + )); + } + // Workspace names are limited to 19 characters by the gateway. + // Keep the longest generated namespace within Kubernetes' 63-char + // DNS label limit, matching the Kubernetes compute driver. + if "openshell-".len() + gateway_id.len() + 1 + 19 > 63 { + return Err(Error::config( + "[openshell.credential_drivers.kubernetes-secrets] gateway_id is too long for managed workspace mode", + )); + } + } + + Ok(Self { + namespace, + allow_reference_namespace: config.allow_reference_namespace, + workspace_mode: config.workspace_mode, + gateway_id, + }) + } +} + +fn kube_error_to_status(namespace: &str, secret_name: &str, err: kube::Error) -> Status { + match err { + kube::Error::Api(api_err) if api_err.code == 404 => Status::not_found(format!( + "Kubernetes Secret '{secret_name}' in namespace '{namespace}' was not found" + )), + kube::Error::Api(api_err) if api_err.code == 403 => Status::permission_denied(format!( + "gateway is not allowed to read Kubernetes Secret '{secret_name}' in namespace '{namespace}'" + )), + other => Status::unavailable(format!( + "failed to read Kubernetes Secret '{secret_name}' in namespace '{namespace}': {other}" + )), + } +} + +fn default_namespace() -> String { + std::fs::read_to_string(SERVICE_ACCOUNT_NAMESPACE_PATH) + .ok() + .map(|namespace| namespace.trim().to_string()) + .filter(|namespace| !namespace.is_empty() && is_dns_label(namespace)) + .unwrap_or_else(|| "default".to_string()) +} + +fn kube_write_error_to_status(namespace: &str, secret_name: &str, err: kube::Error) -> Status { + match err { + kube::Error::Api(api_err) if api_err.code == 403 => Status::permission_denied(format!( + "gateway is not allowed to write Kubernetes Secret '{secret_name}' in namespace '{namespace}'" + )), + other => Status::unavailable(format!( + "failed to write Kubernetes Secret '{secret_name}' in namespace '{namespace}': {other}" + )), + } +} + +fn managed_secret(secret_name: &str, key: &str, owner_id: &str, value: &str) -> Secret { + let labels = BTreeMap::from([(MANAGED_BY_LABEL.to_string(), MANAGED_BY_VALUE.to_string())]); + let annotations = BTreeMap::from([(OWNER_ANNOTATION.to_string(), owner_id.to_string())]); + Secret { + metadata: ObjectMeta { + name: Some(secret_name.to_string()), + labels: Some(labels), + annotations: Some(annotations), + ..Default::default() + }, + string_data: Some(BTreeMap::from([(key.to_string(), value.to_string())])), + type_: Some("Opaque".to_string()), + ..Default::default() + } +} + +fn credential_owner_id( + workspace: &str, + provider_id: &str, + provider_name: &str, + credential_key: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(workspace.as_bytes()); + hasher.update([0]); + hasher.update(provider_id.as_bytes()); + hasher.update([0]); + hasher.update(provider_name.as_bytes()); + hasher.update([0]); + hasher.update(credential_key.as_bytes()); + let digest = hasher.finalize(); + format!("{digest:x}") +} + +fn managed_secret_name( + workspace: &str, + provider_id: &str, + provider_name: &str, + credential_key: &str, + object_id: &str, +) -> String { + let mut hex = credential_owner_id(workspace, provider_id, provider_name, credential_key); + if object_id != provider_id { + let mut hasher = Sha256::new(); + hasher.update(hex.as_bytes()); + hasher.update([0]); + hasher.update(object_id.as_bytes()); + hex = format!("{:x}", hasher.finalize()); + } + format!("openshell-cred-{}", &hex[..40]) +} + +fn validate_expected_secret_name( + workspace: &str, + provider_id: &str, + provider_name: &str, + credential_key: &str, + object_id: &str, + secret_name: &str, +) -> Result<(), Status> { + let expected = managed_secret_name( + workspace, + provider_id, + provider_name, + credential_key, + object_id, + ); + if secret_name != expected { + return Err(Status::invalid_argument(format!( + "kubernetes-secrets credential handle Secret name '{secret_name}' does not match the managed Secret for provider credential '{credential_key}'" + ))); + } + Ok(()) +} + +fn requested_object_id<'a>(object_id: &'a str, provider_id: &'a str) -> Result<&'a str, Status> { + let object_id = if object_id.is_empty() { + provider_id + } else { + object_id + }; + if object_id.trim() != object_id || object_id.is_empty() { + return Err(Status::invalid_argument( + "kubernetes-secrets credential object_id must not be empty or contain surrounding whitespace", + )); + } + Ok(object_id) +} + +fn object_id_from_handle(handle: &CredentialHandle, provider_id: &str) -> Result { + requested_object_id( + handle + .metadata + .get(OBJECT_ID_METADATA_KEY) + .map_or("", String::as_str), + provider_id, + ) + .map(str::to_string) +} + +fn ensure_secret_is_managed_for( + secret: &Secret, + reference: &KubernetesSecretReference, + owner_id: &str, +) -> Result<(), Status> { + let managed_by = secret + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(MANAGED_BY_LABEL)) + .map(String::as_str); + let owner = secret + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(OWNER_ANNOTATION)) + .map(String::as_str); + if managed_by == Some(MANAGED_BY_VALUE) && owner == Some(owner_id) { + return Ok(()); + } + Err(Status::failed_precondition(format!( + "Kubernetes Secret '{}' in namespace '{}' is not managed by OpenShell for this provider credential", + reference.secret_name, reference.namespace + ))) +} + +fn trimmed_config_string<'a>(field_name: &str, value: &'a str) -> CoreResult<&'a str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(Error::config(format!( + "[openshell.credential_drivers.kubernetes-secrets] {field_name} must not be empty" + ))); + } + if trimmed.len() != value.len() { + return Err(Error::config(format!( + "[openshell.credential_drivers.kubernetes-secrets] {field_name} must not contain leading or trailing whitespace" + ))); + } + Ok(trimmed) +} + +fn required_handle_component<'a>(field_name: &str, value: &'a str) -> Result<&'a str, Status> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(Status::invalid_argument(format!( + "kubernetes-secrets credential handle {field_name} is required" + ))); + } + if trimmed.len() != value.len() { + return Err(Status::invalid_argument(format!( + "kubernetes-secrets credential handle {field_name} must not contain leading or trailing whitespace" + ))); + } + Ok(trimmed) +} + +fn is_dns_subdomain(value: &str) -> bool { + !value.is_empty() + && value.len() <= 253 + && value.split('.').all(is_dns_label) + && !value.contains("..") +} + +fn is_dns_label(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) +} + +fn is_secret_data_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= 253 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + use tonic::Code; + + fn handle(value: &str) -> CredentialHandle { + CredentialHandle { + driver: "kubernetes-secrets".to_string(), + handle: value.to_string(), + metadata: std::collections::HashMap::new(), + } + } + + #[test] + fn settings_parse_configured_namespace() { + let settings = KubernetesSecretsDriverSettings::from_table(&toml::toml! { + namespace = "openshell" + allow_reference_namespace = true + }) + .unwrap(); + + assert_eq!(settings.namespace, "openshell"); + assert!(settings.allow_reference_namespace); + } + + #[test] + fn settings_reject_unknown_fields() { + let err = KubernetesSecretsDriverSettings::from_table(&toml::toml! { + namespace = "openshell" + unknown = "value" + }) + .unwrap_err(); + + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn settings_reject_invalid_namespace() { + let err = KubernetesSecretsDriverSettings::from_table(&toml::toml! { + namespace = "OpenShell" + }) + .unwrap_err(); + + assert!(err.to_string().contains("namespace")); + } + + #[test] + fn settings_managed_mode_requires_gateway_id() { + let err = KubernetesSecretsDriverSettings::from_table(&toml::toml! { + workspace_mode = "managed" + }) + .unwrap_err(); + + assert!(err.to_string().contains("gateway_id")); + } + + #[test] + fn settings_managed_mode_rejects_invalid_gateway_id() { + let err = KubernetesSecretsDriverSettings::from_table(&toml::toml! { + workspace_mode = "managed" + gateway_id = "Invalid_Gateway" + }) + .unwrap_err(); + + assert!(err.to_string().contains("DNS-1123")); + } + + #[test] + fn settings_managed_mode_rejects_gateway_id_that_is_too_long() { + let gateway_id = "a".repeat(35); + let err = KubernetesSecretsDriverSettings::from_table(&toml::toml! { + workspace_mode = "managed" + gateway_id = gateway_id + }) + .unwrap_err(); + + assert!(err.to_string().contains("too long")); + } + + #[test] + fn settings_managed_mode_accepts_valid_gateway_id() { + let settings = KubernetesSecretsDriverSettings::from_table(&toml::toml! { + workspace_mode = "managed" + gateway_id = "gateway-1" + }) + .unwrap(); + + assert_eq!(settings.gateway_id, "gateway-1"); + } + + #[test] + fn handle_resolves_secret_reference() { + let reference = KubernetesSecretsCredentialDriver::parse_handle( + &handle("v1:openshell:provider-secret"), + "API_KEY", + ) + .unwrap(); + + assert_eq!(reference.namespace, "openshell"); + assert_eq!(reference.secret_name, "provider-secret"); + assert_eq!(reference.key, "API_KEY"); + } + + #[test] + fn handle_rejects_malformed_value() { + let err = + KubernetesSecretsCredentialDriver::parse_handle(&handle("provider-secret"), "API_KEY") + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("malformed")); + } + + #[test] + fn handle_rejects_invalid_namespace() { + let err = KubernetesSecretsCredentialDriver::parse_handle( + &handle("v1:OpenShell:provider-secret"), + "API_KEY", + ) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("namespace")); + } + + #[test] + fn handle_rejects_invalid_secret_name() { + let err = KubernetesSecretsCredentialDriver::parse_handle( + &handle("v1:openshell:ProviderSecret"), + "API_KEY", + ) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("Secret name")); + } + + #[test] + fn handle_rejects_invalid_credential_key() { + let err = KubernetesSecretsCredentialDriver::parse_handle( + &handle("v1:openshell:provider-secret"), + "api/key", + ) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("data key")); + } + + #[test] + fn handle_rejects_cross_namespace_when_not_allowed() { + let settings = KubernetesSecretsDriverSettings { + namespace: "openshell".to_string(), + allow_reference_namespace: false, + workspace_mode: WorkspaceMode::Shared, + gateway_id: String::new(), + }; + let reference = KubernetesSecretsCredentialDriver::parse_handle( + &handle("v1:other-namespace:provider-secret"), + "API_KEY", + ) + .unwrap(); + + assert_eq!(reference.namespace, "other-namespace"); + + let result = + if reference.namespace != settings.namespace && !settings.allow_reference_namespace { + Err(Status::permission_denied("cross-namespace")) + } else { + Ok(reference) + }; + assert_eq!(result.unwrap_err().code(), Code::PermissionDenied); + } + + #[test] + fn handle_allows_cross_namespace_when_configured() { + let settings = KubernetesSecretsDriverSettings { + namespace: "openshell".to_string(), + allow_reference_namespace: true, + workspace_mode: WorkspaceMode::Shared, + gateway_id: String::new(), + }; + let reference = KubernetesSecretsCredentialDriver::parse_handle( + &handle("v1:other-namespace:provider-secret"), + "API_KEY", + ) + .unwrap(); + + let result = + if reference.namespace != settings.namespace && !settings.allow_reference_namespace { + Err(Status::permission_denied("cross-namespace")) + } else { + Ok(reference) + }; + assert!(result.is_ok()); + } + + #[test] + fn handle_allows_same_namespace() { + let reference = KubernetesSecretsCredentialDriver::parse_handle( + &handle("v1:openshell:provider-secret"), + "API_KEY", + ) + .unwrap(); + + assert_eq!(reference.namespace, "openshell"); + } + + #[test] + fn managed_secret_names_are_stable_dns_subdomains() { + let name = managed_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "prov-123", + ); + + assert!(name.starts_with("openshell-cred-")); + assert!(is_dns_subdomain(&name)); + assert_eq!( + name, + managed_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "prov-123", + ) + ); + } + + #[test] + fn staged_secret_names_keep_provider_ownership_and_use_distinct_object_identity() { + let committed = managed_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "prov-123", + ); + let staged = managed_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "refresh-456", + ); + + assert_ne!(committed, staged); + validate_expected_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "refresh-456", + &staged, + ) + .unwrap(); + assert!( + validate_expected_secret_name( + "default", + "other-provider", + "openai-prod", + "OPENAI_API_KEY", + "refresh-456", + &staged, + ) + .is_err() + ); + } + + #[test] + fn managed_secret_carries_owner_metadata() { + let owner_id = credential_owner_id("default", "prov-123", "openai-prod", "OPENAI_API_KEY"); + let secret = managed_secret("provider-secret", "OPENAI_API_KEY", &owner_id, "sk-test"); + + assert_eq!( + secret + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(MANAGED_BY_LABEL)) + .map(String::as_str), + Some(MANAGED_BY_VALUE) + ); + assert_eq!( + secret + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(OWNER_ANNOTATION)) + .map(String::as_str), + Some(owner_id.as_str()) + ); + } + + #[test] + fn expected_secret_name_rejects_arbitrary_handle_names() { + let err = validate_expected_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "prov-123", + "preexisting-secret", + ) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("does not match")); + } + + #[test] + fn ownership_check_accepts_matching_managed_secret() { + let owner_id = credential_owner_id("default", "prov-123", "openai-prod", "OPENAI_API_KEY"); + let secret_name = managed_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "prov-123", + ); + let reference = KubernetesSecretReference { + namespace: "openshell".to_string(), + secret_name: secret_name.clone(), + key: "OPENAI_API_KEY".to_string(), + }; + let secret = managed_secret(&secret_name, "OPENAI_API_KEY", &owner_id, "sk-test"); + + ensure_secret_is_managed_for(&secret, &reference, &owner_id).unwrap(); + } + + #[test] + fn ownership_check_rejects_unmanaged_secret() { + let owner_id = credential_owner_id("default", "prov-123", "openai-prod", "OPENAI_API_KEY"); + let reference = KubernetesSecretReference { + namespace: "openshell".to_string(), + secret_name: "provider-secret".to_string(), + key: "OPENAI_API_KEY".to_string(), + }; + let secret = Secret { + metadata: ObjectMeta { + name: Some("provider-secret".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let err = ensure_secret_is_managed_for(&secret, &reference, &owner_id).unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("is not managed by OpenShell")); + } + + #[test] + fn ownership_check_rejects_different_provider_credential() { + let owner_id = credential_owner_id("default", "prov-123", "openai-prod", "OPENAI_API_KEY"); + let other_owner_id = credential_owner_id( + "other-workspace", + "prov-456", + "other-provider", + "OPENAI_API_KEY", + ); + let secret_name = managed_secret_name( + "default", + "prov-123", + "openai-prod", + "OPENAI_API_KEY", + "prov-123", + ); + let reference = KubernetesSecretReference { + namespace: "openshell".to_string(), + secret_name: secret_name.clone(), + key: "OPENAI_API_KEY".to_string(), + }; + let secret = managed_secret(&secret_name, "OPENAI_API_KEY", &other_owner_id, "sk-test"); + + let err = ensure_secret_is_managed_for(&secret, &reference, &owner_id).unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("is not managed by OpenShell")); + } + + #[test] + fn target_namespace_shared_returns_static_namespace() { + let settings = KubernetesSecretsDriverSettings { + namespace: "openshell".to_string(), + allow_reference_namespace: false, + workspace_mode: WorkspaceMode::Shared, + gateway_id: String::new(), + }; + assert_eq!(settings.target_namespace("team-a"), "openshell"); + assert_eq!(settings.target_namespace("team-b"), "openshell"); + } + + #[test] + fn target_namespace_managed_computes_from_workspace() { + let settings = KubernetesSecretsDriverSettings { + namespace: "openshell".to_string(), + allow_reference_namespace: false, + workspace_mode: WorkspaceMode::Managed, + gateway_id: "gw1".to_string(), + }; + assert_eq!(settings.target_namespace("team-a"), "openshell-gw1-team-a"); + assert_eq!(settings.target_namespace("team-b"), "openshell-gw1-team-b"); + } + + #[test] + fn target_namespace_operator_uses_workspace_name() { + let settings = KubernetesSecretsDriverSettings { + namespace: "openshell".to_string(), + allow_reference_namespace: false, + workspace_mode: WorkspaceMode::Operator, + gateway_id: String::new(), + }; + assert_eq!(settings.target_namespace("team-a"), "team-a"); + assert_eq!(settings.target_namespace("prod-ns"), "prod-ns"); + } +} diff --git a/crates/openshell-driver-kubernetes-secrets/src/main.rs b/crates/openshell-driver-kubernetes-secrets/src/main.rs new file mode 100644 index 0000000000..bb3cdacbdd --- /dev/null +++ b/crates/openshell-driver-kubernetes-secrets/src/main.rs @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use clap::Parser; +use futures::Stream; +use miette::{IntoDiagnostic, Result, miette}; +use openshell_core::VERSION; +use openshell_core::proto::credentials::v1::credential_driver_server::CredentialDriverServer; +use openshell_driver_kubernetes_secrets::{ + CredentialDriverService, KubernetesSecretsCredentialDriver, +}; +use tokio::net::{UnixListener, UnixStream}; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[derive(Parser, Debug)] +#[command(name = "openshell-driver-kubernetes-secrets")] +#[command(version = VERSION)] +struct Args { + #[arg(long, env = "OPENSHELL_CREDENTIAL_DRIVER_SOCKET")] + bind_socket: PathBuf, + + #[arg(long, env = "OPENSHELL_LOG_LEVEL", default_value = "info")] + log_level: String, + + #[arg(long, env = "OPENSHELL_KUBERNETES_SECRETS_NAMESPACE")] + namespace: Option, + + #[arg( + long, + env = "OPENSHELL_KUBERNETES_SECRETS_ALLOW_REFERENCE_NAMESPACE", + default_value_t = false + )] + allow_reference_namespace: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), + ) + .init(); + + let driver = KubernetesSecretsCredentialDriver::from_config(&driver_config(&args)) + .await + .into_diagnostic()?; + + prepare_socket(&args.bind_socket)?; + let listener = UnixListener::bind(&args.bind_socket).into_diagnostic()?; + restrict_socket_permissions(&args.bind_socket)?; + + info!( + socket = %args.bind_socket.display(), + "Starting Kubernetes Secrets credential driver" + ); + let result = tonic::transport::Server::builder() + .add_service(CredentialDriverServer::new(CredentialDriverService::new( + driver, + ))) + .serve_with_incoming(UnixIncoming::new(listener)) + .await + .into_diagnostic(); + let _ = std::fs::remove_file(&args.bind_socket); + result +} + +fn driver_config(args: &Args) -> toml::Table { + let mut config = toml::Table::new(); + if let Some(namespace) = args.namespace.as_ref() { + config.insert( + "namespace".to_string(), + toml::Value::String(namespace.clone()), + ); + } + if args.allow_reference_namespace { + config.insert( + "allow_reference_namespace".to_string(), + toml::Value::Boolean(true), + ); + } + config +} + +fn prepare_socket(socket_path: &Path) -> Result<()> { + let parent = socket_path.parent().ok_or_else(|| { + miette!( + "credential driver socket path '{}' has no parent directory", + socket_path.display() + ) + })?; + std::fs::create_dir_all(parent).into_diagnostic()?; + + match std::fs::symlink_metadata(socket_path) { + Ok(metadata) if metadata.file_type().is_socket() => { + std::fs::remove_file(socket_path).into_diagnostic()?; + } + Ok(_) => { + return Err(miette!( + "credential driver socket path '{}' exists but is not a Unix socket", + socket_path.display() + )); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err).into_diagnostic(), + } + Ok(()) +} + +fn restrict_socket_permissions(socket_path: &Path) -> Result<()> { + let mut permissions = std::fs::metadata(socket_path) + .into_diagnostic()? + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(socket_path, permissions).into_diagnostic() +} + +struct UnixIncoming { + listener: UnixListener, +} + +impl UnixIncoming { + fn new(listener: UnixListener) -> Self { + Self { listener } + } +} + +impl Stream for UnixIncoming { + type Item = io::Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut().listener.poll_accept(cx) { + Poll::Ready(Ok((stream, _addr))) => Poll::Ready(Some(Ok(stream))), + Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))), + Poll::Pending => Poll::Pending, + } + } +} diff --git a/crates/openshell-driver-kubernetes/BUILD.bazel b/crates/openshell-driver-kubernetes/BUILD.bazel deleted file mode 100644 index 5006c444e2..0000000000 --- a/crates/openshell-driver-kubernetes/BUILD.bazel +++ /dev/null @@ -1,41 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_binary.bzl", "rust_binary") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-driver-kubernetes", - srcs = glob( - ["src/**/*.rs"], - exclude = ["src/main.rs"], - ), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_binary( - name = "openshell-driver-kubernetes_bin", - srcs = ["src/main.rs"], - aliases = aliases(), - binary_name = "openshell-driver-kubernetes", - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True) + [":openshell-driver-kubernetes"], -) - -rust_test( - name = "openshell-driver-kubernetes_test", - crate = ":openshell-driver-kubernetes", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-driver-kubernetes", - ":openshell-driver-kubernetes_bin", - ":openshell-driver-kubernetes_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-driver-kubernetes/Cargo.toml b/crates/openshell-driver-kubernetes/Cargo.toml index 2c02f864ab..3a3d843f1a 100644 --- a/crates/openshell-driver-kubernetes/Cargo.toml +++ b/crates/openshell-driver-kubernetes/Cargo.toml @@ -16,8 +16,10 @@ path = "src/main.rs" [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-otel = { path = "../openshell-otel" } openshell-policy = { path = "../openshell-policy" } +opentelemetry = { workspace = true } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } prost = { workspace = true } @@ -34,9 +36,18 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } thiserror = { workspace = true } miette = { workspace = true } +notify = "8" [dev-dependencies] +openshell-otel-test-support = { path = "../openshell-otel-test-support" } +opentelemetry_sdk = { workspace = true, features = ["testing"] } +bytes = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true } temp-env = "0.3" +tokio = { workspace = true, features = ["test-util"] } +toml = { workspace = true } +tower = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 1356e2d932..02dcfe5e87 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -3,8 +3,44 @@ Kubernetes-backed compute driver for OpenShell cluster deployments. The driver uses the Kubernetes API to create, delete, fetch, and watch sandbox -custom resources in the configured namespace. It runs in-process with the -gateway server. +custom resources. It runs in-process with the gateway server and supports three +workspace namespace modes via `workspace_mode`: + +- **Shared** (default): All sandboxes render into a single static namespace. + Resource names use `{workspace}--{name}` for collision avoidance. +- **Managed**: The driver auto-creates/deletes a K8s namespace per workspace + (`openshell-{gateway_id}-{workspace_name}`), creates a ServiceAccount in each, + and copies OpenShift SCC annotations from the gateway namespace when present. +- **Operator**: Workspace names map 1:1 to pre-provisioned namespaces discovered + through exactly one source: either a label selector + (`operator_namespace_label`) or a drop-in allowlist file + (`operator_namespace_file`). Sandbox creation fails closed if the workspace + namespace is not in the current allowlist. Workspace deletion only removes + gateway state; it never deletes or otherwise accesses the operator-managed + Kubernetes namespace. + +When the gateway configures `[openshell.gateway.otlp]`, Kubernetes +compute-driver spans export to the same OTLP/gRPC collector with the service +name `openshell-driver-kubernetes`. The driver preserves the gateway trace +context and uses the same compute-driver RPC span names in its in-process and +standalone forms. Standalone deployments set `--gateway-name` or +`OPENSHELL_GATEWAY_NAME` so exported spans carry the same +`openshell.gateway.name` resource attribute as gateway spans. + +When it creates an Agent Sandbox resource, the driver serializes the active W3C +trace context into the controller-reserved `opentelemetry.io/trace-context` +annotation. An OTLP-enabled Agent Sandbox controller can therefore attach its +asynchronous reconciliation spans to the originating OpenShell create trace. + +Workspace namespace modes assume exclusive control of the sandbox identity +resource chain. In shared and managed modes, only the driver and its trusted +Agent Sandbox controller may administer the sandbox namespace, Sandbox CRs, +sandbox pods, or configured sandbox ServiceAccount. In operator mode, the +platform operator owns namespace lifecycle but must prevent other principals +from creating or mutating Sandbox CRs, creating sandbox pods with fabricated +owner references, or using the configured sandbox ServiceAccount. Treat adding +a namespace to the operator allowlist as granting this trust; the allowlist is +not a tenant isolation boundary. ## Runtime Model @@ -24,7 +60,9 @@ state and platform events into the shared compute-driver protobuf surface used by the gateway. Kubernetes API calls use explicit timeouts so gRPC handlers do not block -indefinitely when the API server is slow or unavailable. +indefinitely when the API server is slow or unavailable. Resource and Event +watches recover in place with API-friendly backoff after transient watcher +errors, avoiding a gateway-side watch restart and its associated watch gap. ## Workspace Persistence @@ -36,6 +74,15 @@ This is a stopgap persistence model. It preserves user files across pod rescheduling but duplicates the base workspace and does not automatically apply image updates to existing PVCs. Future snapshotting should replace it. +Stop preserves the Agent Sandbox resource and workspace PVC while stopping +its pod. The driver sets `spec.operatingMode: Suspended` for `v1beta1` or +`spec.replicas: 0` for `v1alpha1`. Start sets `Running` or one replica for the +same resource, so the replacement pod mounts the existing claim. Delete is the +only lifecycle operation that removes the Sandbox resource and its owned +storage. The driver confirms the stop from both the published `Suspended` +condition and deletion of the backing pod. Legacy `v1alpha1` controllers omit +a usable stopped condition, so pod deletion alone confirms their stop. + The workspace PVC size defaults to `workspace_default_storage_size`. Set `workspace_storage_class` to pin the PVC to a specific `StorageClass`; an empty value omits `storageClassName` so the cluster's default `StorageClass` applies. @@ -56,11 +103,18 @@ Sandbox pods run as `service_account_name` and keep `automountServiceAccountToken: false`. The only Kubernetes token exposed to the supervisor is an explicit, audience-bound projected token mounted at `/var/run/secrets/openshell/token` for the one-shot `IssueSandboxToken` -bootstrap exchange. +bootstrap exchange. The Kubernetes driver authenticates that token through the +compute-driver protocol using its own `service_account_name` and workspace-mode +namespace policy; the gateway receives only the verified sandbox ID. The gateway uses the supervisor relay for connect, exec, and file sync. Sandbox pods do not need direct external ingress for SSH. +The driver forwards the canonical main-process specification to the process +supervisor and sets pod `restartPolicy: Never`. Main-process environment +overrides stay local to that child; the sidecar bootstrap retains the unmodified +provider environment used by later exec, editor, and SFTP sessions. + ## Container Security Context The default `combined` supervisor topology grants the sandbox agent container diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index fb471180a9..805c0314b0 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -1,11 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +pub use openshell_core::DynamicStringAllowlist as OperatorNamespaceAllowlist; use openshell_core::config; use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::BTreeMap; +#[cfg(test)] +use std::collections::BTreeSet; use std::path::Path; use std::str::FromStr; +/// Default gateway identity used in managed-mode namespace naming. +pub const DEFAULT_GATEWAY_ID: &str = "openshell"; + /// Default Kubernetes namespace for sandbox resources. pub const DEFAULT_K8S_NAMESPACE: &str = "openshell"; @@ -88,6 +95,48 @@ impl FromStr for SupervisorTopology { } } +/// How workspaces map to Kubernetes namespaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WorkspaceMode { + /// All sandboxes render into a single statically-configured namespace. + /// Resource names use `{workspace}--{name}` for collision avoidance. + #[default] + Shared, + /// The driver creates and deletes K8s namespaces on demand using the + /// convention `openshell-{gateway_id}-{workspace_name}`. + Managed, + /// Sandboxes render into pre-existing K8s namespaces. The driver has no + /// namespace create/delete permissions. Platform teams manage namespaces + /// via their existing tooling. + Operator, +} + +impl std::fmt::Display for WorkspaceMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Shared => f.write_str("shared"), + Self::Managed => f.write_str("managed"), + Self::Operator => f.write_str("operator"), + } + } +} + +impl FromStr for WorkspaceMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "shared" => Ok(Self::Shared), + "managed" => Ok(Self::Managed), + "operator" => Ok(Self::Operator), + other => Err(format!( + "unknown workspace mode '{other}'; expected 'shared', 'managed', or 'operator'" + )), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct KubernetesSidecarConfig { @@ -115,10 +164,13 @@ impl Default for KubernetesSidecarConfig { impl KubernetesSidecarConfig { pub fn validate_proxy_uid(&self) -> Result<(), String> { - if self.proxy_uid < openshell_policy::MIN_SANDBOX_UID { + if !(openshell_policy::MIN_SANDBOX_PROXY_UID..=openshell_policy::MAX_SANDBOX_UID) + .contains(&self.proxy_uid) + { return Err(format!( - "sidecar.proxy_uid must be at least {}", - openshell_policy::MIN_SANDBOX_UID + "sidecar.proxy_uid must be in range [{}, {}]", + openshell_policy::MIN_SANDBOX_PROXY_UID, + openshell_policy::MAX_SANDBOX_UID, )); } Ok(()) @@ -232,14 +284,35 @@ where #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct KubernetesComputeConfig { + /// How workspaces map to Kubernetes namespaces. `"shared"` (default) + /// renders all sandboxes into `namespace`; `"managed"` creates per-workspace + /// namespaces on demand; `"operator"` uses pre-provisioned namespaces. + pub workspace_mode: WorkspaceMode, + /// Stable gateway identity used in managed-mode namespace naming + /// (`openshell-{gateway_id}-{workspace}`). Propagated from + /// `gateway_jwt.gateway_id`. + pub gateway_id: String, pub namespace: String, + /// K8s label selector for operator-mode namespace discovery (e.g., + /// `"openshell.ai/workspace=true"`). The driver watches namespaces matching + /// this label and builds the allowlist dynamically. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operator_namespace_label: Option, + /// Path to a JSON file containing an array of namespace names allowed in + /// operator mode. Hot-reloaded on change. Delivered via `ConfigMap` volume mount. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operator_namespace_file: Option, /// Kubernetes `ServiceAccount` assigned to sandbox pods and accepted by - /// the gateway's `TokenReview` bootstrap authenticator. + /// the driver's `TokenReview` bootstrap authenticator. pub service_account_name: String, pub default_image: String, pub image_pull_policy: String, /// Kubernetes `imagePullSecrets` names attached to sandbox pods. pub image_pull_secrets: Vec, + /// Managed-mode SSH ingress isolation. When enabled, the driver creates a + /// `NetworkPolicy` in each managed workspace namespace that permits TCP 2222 + /// only from gateway pods matching this peer. + pub managed_ssh_ingress: ManagedSshIngressConfig, /// Image that provides the `openshell-sandbox` supervisor binary. /// Mounted directly as an image volume, or copied via an init container, /// depending on `supervisor_sideload_method`. @@ -253,6 +326,25 @@ pub struct KubernetesComputeConfig { pub topology: SupervisorTopology, /// Sidecar-only settings used when `topology = "sidecar"`. pub sidecar: KubernetesSidecarConfig, + /// Corporate HTTP forward proxy used by the network supervisor for + /// policy-approved TLS CONNECT egress. + pub https_proxy: Option, + /// Comma-separated destinations that bypass the corporate proxy while + /// continuing through `OpenShell` policy evaluation. + pub no_proxy: Option, + /// Name of the Kubernetes Secret holding the `user:pass` proxy credential. + /// The Secret is mounted only in the network-supervising container. The + /// driver validates this reference at startup; the supervisor validates + /// the Secret content when kubelet mounts it before accepting egress. + pub proxy_auth_secret_name: Option, + /// Key in `proxy_auth_secret_name` containing the `user:pass` credential. + pub proxy_auth_secret_key: Option, + /// Explicit acknowledgement that Basic authentication is cleartext over + /// the connection to an `http://` forward proxy. + pub proxy_auth_allow_insecure: Option, + /// Send hostnames rather than validated IPs in CONNECT requests. This is a + /// last-resort compatibility mode for hostname-filtering proxy ACLs. + pub proxy_connect_by_hostname: Option, pub grpc_endpoint: String, pub ssh_socket_path: String, pub client_tls_secret_name: String, @@ -309,6 +401,14 @@ pub struct KubernetesComputeConfig { pub sandbox_gid: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ManagedSshIngressConfig { + pub enabled: bool, + pub gateway_namespace: String, + pub gateway_pod_selector: BTreeMap, +} + /// Lower bound enforced by kubelet for projected SA tokens. pub const MIN_SA_TOKEN_TTL_SECS: i64 = 600; @@ -332,7 +432,11 @@ pub const ANNOTATION_SCC_SUPPLEMENTAL_GROUPS: &str = "openshift.io/sa.scc.supple impl Default for KubernetesComputeConfig { fn default() -> Self { Self { + workspace_mode: WorkspaceMode::default(), + gateway_id: DEFAULT_GATEWAY_ID.to_string(), namespace: DEFAULT_K8S_NAMESPACE.to_string(), + operator_namespace_label: None, + operator_namespace_file: None, service_account_name: DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME.to_string(), default_image: openshell_core::image::default_sandbox_image(), // Default empty so the gateway omits `imagePullPolicy` from pod @@ -341,13 +445,20 @@ impl Default for KubernetesComputeConfig { // is Podman vocabulary and is not a valid Kubernetes value. image_pull_policy: String::new(), image_pull_secrets: Vec::new(), + managed_ssh_ingress: ManagedSshIngressConfig::default(), supervisor_image: config::default_supervisor_image(), supervisor_image_pull_policy: String::new(), supervisor_sideload_method: SupervisorSideloadMethod::default(), topology: SupervisorTopology::default(), sidecar: KubernetesSidecarConfig::default(), + https_proxy: None, + no_proxy: None, + proxy_auth_secret_name: None, + proxy_auth_secret_key: None, + proxy_auth_allow_insecure: None, + proxy_connect_by_hostname: None, grpc_endpoint: String::new(), - ssh_socket_path: "/run/openshell/ssh.sock".to_string(), + ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), client_tls_secret_name: String::new(), host_gateway_ip: String::new(), enable_user_namespaces: false, @@ -395,6 +506,101 @@ impl KubernetesComputeConfig { self.sidecar.validate_proxy_uid() } + /// Validate the operator-owned corporate upstream proxy configuration. + pub fn validate_upstream_proxy_config(&self) -> Result<(), String> { + use openshell_core::driver_utils::{UpstreamProxyUrlError, parse_upstream_proxy_url}; + + if let Some(url) = &self.https_proxy { + parse_upstream_proxy_url(url).map_err(|err| match err { + UpstreamProxyUrlError::Empty => "https_proxy must not be empty when set".to_string(), + UpstreamProxyUrlError::InlineCredentials => "https_proxy must not embed credentials in the URL; supply them through proxy_auth_secret_name and proxy_auth_secret_key".to_string(), + err => format!("https_proxy {err}"), + })?; + } + + if let Some(list) = self.no_proxy.as_deref() { + if list.trim().is_empty() { + return Err("no_proxy must not be empty when set; omit it instead".to_string()); + } + if self.https_proxy.is_none() { + return Err("no_proxy is set but no https_proxy is configured".to_string()); + } + } + + let secret_name = self.proxy_auth_secret_name.as_deref(); + let secret_key = self.proxy_auth_secret_key.as_deref(); + match (secret_name, secret_key) { + (None, None) => { + if self.proxy_auth_allow_insecure == Some(true) { + return Err("proxy_auth_allow_insecure is set but no proxy credential Secret is configured".to_string()); + } + } + (Some(name), Some(key)) => { + if name.trim().is_empty() || key.trim().is_empty() { + return Err( + "proxy credential Secret name and key must not be empty".to_string() + ); + } + if !is_dns1123_subdomain(name) { + return Err( + "proxy_auth_secret_name must be a valid Kubernetes DNS-1123 subdomain" + .to_string(), + ); + } + if !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) + { + return Err( + "proxy_auth_secret_key must contain only letters, digits, '.', '-', or '_'" + .to_string(), + ); + } + // Kubernetes rejects Secret keys longer than 253 bytes and the + // reserved `.`/`..` names. Reject them here so an invalid + // deployment setting fails at gateway startup instead of + // surfacing as repeated Pod-provisioning failures. + if key.len() > 253 { + return Err( + "proxy_auth_secret_key must be at most 253 bytes to satisfy Kubernetes Secret key limits" + .to_string(), + ); + } + if key == "." || key == ".." { + return Err("proxy_auth_secret_key must not be '.' or '..'".to_string()); + } + if self.https_proxy.is_none() { + return Err( + "proxy credential Secret is set but no https_proxy is configured" + .to_string(), + ); + } + if self.proxy_auth_allow_insecure != Some(true) { + return Err("proxy credentials use cleartext Basic auth over the connection to the http:// proxy; set proxy_auth_allow_insecure = true to accept that exposure, or remove the credential Secret".to_string()); + } + if self.topology == SupervisorTopology::Combined { + return Err( + "proxy credential Secrets require topology = \"sidecar\"; combined topology shares the credential mount with the workload and fsGroup can make it readable by the sandbox user" + .to_string(), + ); + } + } + _ => { + return Err( + "proxy_auth_secret_name and proxy_auth_secret_key must be set together" + .to_string(), + ); + } + } + + if self.proxy_connect_by_hostname.is_some() && self.https_proxy.is_none() { + return Err( + "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), + ); + } + Ok(()) + } + /// Resolve the sandbox UID/GID pair. /// /// Resolution order: @@ -405,7 +611,7 @@ impl KubernetesComputeConfig { /// 3. Fallback defaults: UID=`1000`, GID=UID pub fn resolve_sandbox_uid( &self, - namespace_annotations: Option<&std::collections::BTreeMap>, + namespace_annotations: Option<&BTreeMap>, ) -> u32 { if let Some(uid) = self.sandbox_uid { return uid; @@ -423,7 +629,7 @@ impl KubernetesComputeConfig { pub fn resolve_sandbox_gid( &self, resolved_uid: u32, - _namespace_annotations: Option<&std::collections::BTreeMap>, + _namespace_annotations: Option<&BTreeMap>, ) -> u32 { self.sandbox_gid .or(self.sandbox_uid) @@ -473,6 +679,182 @@ impl KubernetesComputeConfig { } Ok(()) } + + /// Resolve the K8s namespace for a workspace. + /// + /// - **Shared:** returns the static `namespace` config field. + /// - **Managed:** computes `openshell-{gateway_id}-{workspace_name}`. + /// - **Operator:** looks up `workspace` in the dynamic allowlist. Fails + /// closed if the workspace is not found. + pub fn namespace_for_workspace( + &self, + workspace: &str, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + ) -> Result { + match self.workspace_mode { + WorkspaceMode::Shared => Ok(self.namespace.clone()), + WorkspaceMode::Managed => Ok(managed_namespace(&self.gateway_id, workspace)), + WorkspaceMode::Operator => { + let allowlist = + operator_allowlist.ok_or("operator mode requires a namespace allowlist")?; + let namespaces = allowlist.read(); + if namespaces.contains(workspace) { + Ok(workspace.to_string()) + } else { + Err(format!( + "workspace '{workspace}' is not in the operator namespace allowlist" + )) + } + } + } + } + + /// Whether the driver operates across multiple namespaces. + #[must_use] + pub fn is_multi_namespace(&self) -> bool { + !matches!(self.workspace_mode, WorkspaceMode::Shared) + } + + /// Compute the K8s resource name for a sandbox. + /// + /// - **Shared:** `{workspace}--{name}` (namespace doesn't provide isolation). + /// - **Managed/Operator:** bare sandbox name (namespace provides isolation). + #[must_use] + pub fn kube_resource_name(&self, workspace: &str, name: &str) -> String { + match self.workspace_mode { + WorkspaceMode::Shared => format!("{workspace}--{name}"), + WorkspaceMode::Managed | WorkspaceMode::Operator => name.to_string(), + } + } + + /// Validate workspace-mode-specific configuration at startup. + pub fn validate_workspace_mode(&self) -> Result<(), String> { + match self.workspace_mode { + WorkspaceMode::Shared => Ok(()), + WorkspaceMode::Managed => { + if self.gateway_id.is_empty() { + return Err("managed workspace mode requires a non-empty gateway_id".into()); + } + if !is_dns_1123_label(&self.gateway_id) { + return Err(format!( + "gateway_id '{}' is not a valid DNS-1123 label", + self.gateway_id + )); + } + // Workspace names can be up to 19 chars (MAX_ROUTABLE_NAME_LEN + // in the server crate). The managed namespace prefix + + // workspace must fit within 63 chars. + let prefix = managed_namespace_prefix(&self.gateway_id); + if prefix.len() + 19 > 63 { + return Err(format!( + "gateway_id '{}' is too long for managed mode; \ + the namespace prefix '{}' ({} chars) plus the \ + maximum workspace name (19 chars) exceeds the \ + 63-char K8s namespace limit", + self.gateway_id, + prefix, + prefix.len() + )); + } + if self.managed_ssh_ingress.enabled { + if self.managed_ssh_ingress.gateway_namespace.is_empty() { + return Err( + "managed SSH ingress isolation requires gateway_namespace".into() + ); + } + if self.managed_ssh_ingress.gateway_pod_selector.is_empty() { + return Err( + "managed SSH ingress isolation requires gateway_pod_selector".into(), + ); + } + } + Ok(()) + } + WorkspaceMode::Operator => { + if self.operator_namespace_label.is_none() && self.operator_namespace_file.is_none() + { + return Err("operator workspace mode requires exactly one of \ + operator_namespace_label or operator_namespace_file" + .into()); + } + if self.operator_namespace_label.is_some() && self.operator_namespace_file.is_some() + { + return Err("operator workspace mode requires exactly one of \ + operator_namespace_label or operator_namespace_file, not both" + .into()); + } + if let Some(ref label) = self.operator_namespace_label + && label.is_empty() + { + return Err("operator_namespace_label must not be empty when set".into()); + } + if let Some(ref file) = self.operator_namespace_file + && file.is_empty() + { + return Err("operator_namespace_file must not be empty when set".into()); + } + Ok(()) + } + } + } +} + +/// Compute the managed-mode namespace name for a workspace. +#[must_use] +pub fn managed_namespace(gateway_id: &str, workspace: &str) -> String { + format!("openshell-{gateway_id}-{workspace}") +} + +/// The managed-mode namespace prefix used for SA token validation. +#[must_use] +pub fn managed_namespace_prefix(gateway_id: &str) -> String { + format!("openshell-{gateway_id}-") +} + +/// Check whether a string is a valid DNS-1123 label (lowercase alphanumeric +/// and hyphens, 1-63 chars, must start and end with alphanumeric). +#[must_use] +pub fn is_dns_1123_label(s: &str) -> bool { + let len = s.len(); + if len == 0 || len > 63 { + return false; + } + let bytes = s.as_bytes(); + if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { + return false; + } + if !bytes[len - 1].is_ascii_lowercase() && !bytes[len - 1].is_ascii_digit() { + return false; + } + bytes + .iter() + .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// Validate that a workspace name produces a valid K8s namespace name in +/// managed mode (combined length <= 63, DNS-1123 compliant). +pub fn validate_managed_namespace_name(gateway_id: &str, workspace: &str) -> Result<(), String> { + let ns = managed_namespace(gateway_id, workspace); + if !is_dns_1123_label(&ns) { + return Err(format!( + "managed namespace '{ns}' (from workspace '{workspace}') is not a valid DNS-1123 label" + )); + } + Ok(()) +} + +fn is_dns1123_subdomain(value: &str) -> bool { + !value.is_empty() + && value.len() <= 253 + && value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) } fn validate_provider_spiffe_workload_api_socket_path_value( @@ -836,17 +1218,28 @@ mod tests { } #[test] - fn parse_openshift_uid_range_rejects_below_min() { - // 999 is below MIN_SANDBOX_UID (1000) — should be rejected. + fn parse_openshift_uid_range_accepts_non_root_system_uid() { assert_eq!( KubernetesComputeConfig::from_open_shift_uid_range("999/50000"), + Some(999) + ); + assert_eq!( + KubernetesComputeConfig::from_open_shift_uid_range("1/50000"), + Some(1) + ); + } + + #[test] + fn parse_openshift_uid_range_rejects_root() { + assert_eq!( + KubernetesComputeConfig::from_open_shift_uid_range("0/50000"), None ); } #[test] fn parse_openshift_uid_range_rejects_above_max() { - // u32::MAX is well above MAX_SANDBOX_UID — should be rejected. + // u32::MAX is the invalid identity sentinel. assert_eq!( KubernetesComputeConfig::from_open_shift_uid_range("4294967295/10000"), None @@ -856,8 +1249,8 @@ mod tests { #[test] fn validate_sandbox_identity_config_accepts_valid_range() { let cfg = KubernetesComputeConfig { - sandbox_uid: Some(1000), - sandbox_gid: Some(1000), + sandbox_uid: Some(500), + sandbox_gid: Some(30), ..KubernetesComputeConfig::default() }; assert!(cfg.validate_sandbox_identity_config().is_ok()); @@ -895,6 +1288,10 @@ mod tests { KubernetesComputeConfig::from_open_shift_supplemental_groups("1000/50000"), Some(1000) ); + assert_eq!( + KubernetesComputeConfig::from_open_shift_supplemental_groups("30/50000"), + Some(30) + ); } #[test] @@ -966,4 +1363,507 @@ mod tests { let uid = cfg.resolve_sandbox_uid(None); assert_eq!(cfg.resolve_sandbox_gid(uid, None), uid); } + + #[test] + fn upstream_proxy_config_accepts_http_proxy_without_credentials() { + let cfg = KubernetesComputeConfig { + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + no_proxy: Some(".svc.cluster.local,10.96.0.0/12".to_string()), + ..KubernetesComputeConfig::default() + }; + assert!(cfg.validate_upstream_proxy_config().is_ok()); + } + + #[test] + fn upstream_proxy_config_accepts_secret_credentials_with_acknowledgement() { + let cfg = KubernetesComputeConfig { + topology: SupervisorTopology::Sidecar, + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_secret_name: Some("corporate-proxy-auth".to_string()), + proxy_auth_secret_key: Some("credentials".to_string()), + proxy_auth_allow_insecure: Some(true), + ..KubernetesComputeConfig::default() + }; + assert!(cfg.validate_upstream_proxy_config().is_ok()); + } + + #[test] + fn toml_deserializes_sidecar_upstream_proxy_settings() { + let cfg: KubernetesComputeConfig = toml::from_str( + r#" + topology = "sidecar" + https_proxy = "http://proxy.corp.example:8080" + no_proxy = ".svc.cluster.local,10.96.0.0/12" + proxy_auth_secret_name = "corporate-proxy-auth" + proxy_auth_secret_key = "credentials" + proxy_auth_allow_insecure = true + proxy_connect_by_hostname = true + "#, + ) + .unwrap(); + assert!(cfg.validate_upstream_proxy_config().is_ok()); + assert_eq!( + cfg.https_proxy.as_deref(), + Some("http://proxy.corp.example:8080") + ); + assert_eq!( + cfg.proxy_auth_secret_name.as_deref(), + Some("corporate-proxy-auth") + ); + } + + #[test] + fn upstream_proxy_config_rejects_incoherent_auxiliary_settings() { + for cfg in [ + KubernetesComputeConfig { + no_proxy: Some(".svc".to_string()), + ..KubernetesComputeConfig::default() + }, + KubernetesComputeConfig { + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_secret_name: Some("corporate-proxy-auth".to_string()), + ..KubernetesComputeConfig::default() + }, + KubernetesComputeConfig { + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_secret_name: Some("corporate-proxy-auth".to_string()), + proxy_auth_secret_key: Some("credentials".to_string()), + ..KubernetesComputeConfig::default() + }, + KubernetesComputeConfig { + proxy_connect_by_hostname: Some(true), + ..KubernetesComputeConfig::default() + }, + ] { + assert!(cfg.validate_upstream_proxy_config().is_err()); + } + } + + // -- WorkspaceMode tests -- + + #[test] + fn default_workspace_mode_is_shared() { + let cfg = KubernetesComputeConfig::default(); + assert_eq!(cfg.workspace_mode, WorkspaceMode::Shared); + } + + #[test] + fn serde_override_workspace_mode_managed() { + let json = serde_json::json!({ "workspace_mode": "managed" }); + let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.workspace_mode, WorkspaceMode::Managed); + } + + #[test] + fn serde_override_workspace_mode_operator() { + let json = serde_json::json!({ "workspace_mode": "operator" }); + let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.workspace_mode, WorkspaceMode::Operator); + } + + #[test] + fn serde_rejects_invalid_workspace_mode() { + let json = serde_json::json!({ "workspace_mode": "invalid" }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("unknown variant")); + } + + #[test] + fn workspace_mode_display_roundtrips() { + for mode in [ + WorkspaceMode::Shared, + WorkspaceMode::Managed, + WorkspaceMode::Operator, + ] { + assert_eq!(mode.to_string().parse::().unwrap(), mode); + } + } + + #[test] + fn upstream_proxy_config_rejects_unsupported_proxy_scheme() { + let cfg = KubernetesComputeConfig { + https_proxy: Some("socks5://proxy.corp.example:1080".to_string()), + ..KubernetesComputeConfig::default() + }; + let err = cfg.validate_upstream_proxy_config().unwrap_err(); + assert!(err.contains("https_proxy"), "{err}"); + } + + #[test] + fn upstream_proxy_config_rejects_invalid_secret_name() { + let cfg = KubernetesComputeConfig { + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_secret_name: Some("Not_A_Secret".to_string()), + proxy_auth_secret_key: Some("credentials".to_string()), + proxy_auth_allow_insecure: Some(true), + ..KubernetesComputeConfig::default() + }; + let err = cfg.validate_upstream_proxy_config().unwrap_err(); + assert!(err.contains("proxy_auth_secret_name"), "{err}"); + } + + #[test] + fn upstream_proxy_config_rejects_invalid_secret_key() { + // A key that Kubernetes cannot create must fail at gateway startup + // instead of surfacing as repeated Pod-provisioning failures. + for key in [ + "a".repeat(254), // exceeds the 253-byte Secret key limit + ".".to_string(), + "..".to_string(), + "bad key".to_string(), // whitespace is outside the allowed charset + ] { + let cfg = KubernetesComputeConfig { + topology: SupervisorTopology::Sidecar, + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_secret_name: Some("corporate-proxy-auth".to_string()), + proxy_auth_secret_key: Some(key.clone()), + proxy_auth_allow_insecure: Some(true), + ..KubernetesComputeConfig::default() + }; + let err = cfg.validate_upstream_proxy_config().unwrap_err(); + assert!( + err.contains("proxy_auth_secret_key"), + "key {key:?} should be rejected with a key-specific error: {err}" + ); + } + } + + #[test] + fn upstream_proxy_config_accepts_max_length_secret_key() { + let cfg = KubernetesComputeConfig { + topology: SupervisorTopology::Sidecar, + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_secret_name: Some("corporate-proxy-auth".to_string()), + proxy_auth_secret_key: Some("a".repeat(253)), + proxy_auth_allow_insecure: Some(true), + ..KubernetesComputeConfig::default() + }; + assert!(cfg.validate_upstream_proxy_config().is_ok()); + } + + #[test] + fn upstream_proxy_config_rejects_credentials_in_combined_topology() { + let cfg = KubernetesComputeConfig { + topology: SupervisorTopology::Combined, + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_secret_name: Some("corporate-proxy-auth".to_string()), + proxy_auth_secret_key: Some("credentials".to_string()), + proxy_auth_allow_insecure: Some(true), + ..KubernetesComputeConfig::default() + }; + let err = cfg.validate_upstream_proxy_config().unwrap_err(); + assert!(err.contains("topology = \"sidecar\""), "{err}"); + } + + #[test] + fn upstream_proxy_config_allows_explicit_false_acknowledgement_without_credentials() { + let cfg = KubernetesComputeConfig { + https_proxy: Some("http://proxy.corp.example:8080".to_string()), + proxy_auth_allow_insecure: Some(false), + ..KubernetesComputeConfig::default() + }; + assert!(cfg.validate_upstream_proxy_config().is_ok()); + } + + #[test] + fn namespace_for_workspace_shared() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Shared, + namespace: "sandbox-ns".to_string(), + ..KubernetesComputeConfig::default() + }; + assert_eq!( + cfg.namespace_for_workspace("team-a", None).unwrap(), + "sandbox-ns" + ); + assert_eq!( + cfg.namespace_for_workspace("team-b", None).unwrap(), + "sandbox-ns" + ); + } + + #[test] + fn namespace_for_workspace_managed() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + gateway_id: "gw1".to_string(), + ..KubernetesComputeConfig::default() + }; + assert_eq!( + cfg.namespace_for_workspace("team-a", None).unwrap(), + "openshell-gw1-team-a" + ); + } + + #[test] + fn namespace_for_workspace_operator() { + let allowlist = OperatorNamespaceAllowlist::from_set(BTreeSet::from(["prod".to_string()])); + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + operator_namespace_label: Some("openshell.ai/workspace=true".to_string()), + ..KubernetesComputeConfig::default() + }; + assert_eq!( + cfg.namespace_for_workspace("prod", Some(&allowlist)) + .unwrap(), + "prod" + ); + assert!( + cfg.namespace_for_workspace("unknown", Some(&allowlist)) + .is_err() + ); + } + + #[test] + fn namespace_for_workspace_operator_requires_allowlist() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + operator_namespace_label: Some("openshell.ai/workspace=true".to_string()), + ..KubernetesComputeConfig::default() + }; + + let err = cfg.namespace_for_workspace("prod", None).unwrap_err(); + assert_eq!(err, "operator mode requires a namespace allowlist"); + } + + #[test] + fn kube_resource_name_shared_prefixes_workspace() { + let cfg = KubernetesComputeConfig::default(); + assert_eq!(cfg.kube_resource_name("ws", "box1"), "ws--box1"); + } + + #[test] + fn kube_resource_name_managed_uses_bare_name() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + ..KubernetesComputeConfig::default() + }; + assert_eq!(cfg.kube_resource_name("ws", "box1"), "box1"); + } + + #[test] + fn kube_resource_name_operator_uses_bare_name() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + operator_namespace_label: Some("x=y".to_string()), + ..KubernetesComputeConfig::default() + }; + assert_eq!(cfg.kube_resource_name("ws", "box1"), "box1"); + } + + #[test] + fn is_multi_namespace() { + assert!(!KubernetesComputeConfig::default().is_multi_namespace()); + assert!( + KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + ..KubernetesComputeConfig::default() + } + .is_multi_namespace() + ); + assert!( + KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + operator_namespace_label: Some("x=y".to_string()), + ..KubernetesComputeConfig::default() + } + .is_multi_namespace() + ); + } + + #[test] + fn validate_workspace_mode_shared_always_ok() { + let cfg = KubernetesComputeConfig::default(); + cfg.validate_workspace_mode().unwrap(); + } + + #[test] + fn validate_workspace_mode_managed_requires_gateway_id() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + gateway_id: String::new(), + ..KubernetesComputeConfig::default() + }; + assert!(cfg.validate_workspace_mode().is_err()); + } + + #[test] + fn validate_workspace_mode_managed_rejects_invalid_gateway_id() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + gateway_id: "INVALID".to_string(), + ..KubernetesComputeConfig::default() + }; + assert!(cfg.validate_workspace_mode().is_err()); + } + + #[test] + fn validate_workspace_mode_managed_rejects_long_gateway_id() { + // prefix = "openshell-{id}-" = 11 + id.len() + // 11 + 34 + 19 = 64 > 63 → rejected + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + gateway_id: "a".repeat(34), + ..KubernetesComputeConfig::default() + }; + let err = cfg.validate_workspace_mode().unwrap_err(); + assert!(err.contains("too long for managed mode"), "{err}"); + } + + #[test] + fn validate_workspace_mode_managed_accepts_max_gateway_id() { + // 11 + 33 + 19 = 63 → accepted + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + gateway_id: "a".repeat(33), + ..KubernetesComputeConfig::default() + }; + cfg.validate_workspace_mode().unwrap(); + } + + #[test] + fn validate_workspace_mode_managed_requires_complete_ssh_ingress_peer() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + managed_ssh_ingress: ManagedSshIngressConfig { + enabled: true, + gateway_namespace: "gateway".to_string(), + gateway_pod_selector: BTreeMap::new(), + }, + ..KubernetesComputeConfig::default() + }; + let err = cfg.validate_workspace_mode().unwrap_err(); + assert!(err.contains("gateway_pod_selector"), "{err}"); + } + + #[test] + fn validate_workspace_mode_managed_accepts_complete_ssh_ingress_peer() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + managed_ssh_ingress: ManagedSshIngressConfig { + enabled: true, + gateway_namespace: "gateway".to_string(), + gateway_pod_selector: BTreeMap::from([( + "app.kubernetes.io/name".to_string(), + "openshell".to_string(), + )]), + }, + ..KubernetesComputeConfig::default() + }; + cfg.validate_workspace_mode().unwrap(); + } + + #[test] + fn validate_workspace_mode_operator_requires_discovery() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + ..KubernetesComputeConfig::default() + }; + assert!(cfg.validate_workspace_mode().is_err()); + } + + #[test] + fn validate_workspace_mode_operator_accepts_label_only() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + operator_namespace_label: Some("openshell.ai/workspace=true".to_string()), + ..KubernetesComputeConfig::default() + }; + cfg.validate_workspace_mode().unwrap(); + } + + #[test] + fn validate_workspace_mode_operator_accepts_file_only() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + operator_namespace_file: Some("/etc/openshell/namespaces.json".to_string()), + ..KubernetesComputeConfig::default() + }; + cfg.validate_workspace_mode().unwrap(); + } + + #[test] + fn validate_workspace_mode_operator_rejects_label_and_file() { + let cfg = KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Operator, + operator_namespace_label: Some("openshell.ai/workspace=true".to_string()), + operator_namespace_file: Some("/etc/openshell/namespaces.json".to_string()), + ..KubernetesComputeConfig::default() + }; + + let err = cfg.validate_workspace_mode().unwrap_err(); + assert!(err.contains("not both"), "{err}"); + } + + #[test] + fn dns_1123_label_validation() { + assert!(is_dns_1123_label("openshell")); + assert!(is_dns_1123_label("my-gateway-1")); + assert!(is_dns_1123_label("a")); + assert!(!is_dns_1123_label("")); + assert!(!is_dns_1123_label("UPPER")); + assert!(!is_dns_1123_label("-starts-with-dash")); + assert!(!is_dns_1123_label("ends-with-dash-")); + assert!(!is_dns_1123_label("has_underscore")); + assert!(!is_dns_1123_label(&"a".repeat(64))); + } + + #[test] + fn managed_namespace_naming() { + assert_eq!( + managed_namespace("openshell", "default"), + "openshell-openshell-default" + ); + assert_eq!(managed_namespace("gw1", "team-a"), "openshell-gw1-team-a"); + } + + #[test] + fn validate_managed_namespace_name_accepts_valid() { + validate_managed_namespace_name("gw1", "team-a").unwrap(); + } + + #[test] + fn validate_managed_namespace_name_rejects_invalid_workspace_characters() { + let err = validate_managed_namespace_name("gw1", "INVALID").unwrap_err(); + assert!(err.contains("not a valid DNS-1123 label")); + } + + #[test] + fn validate_managed_namespace_name_rejects_too_long() { + let long_workspace = "a".repeat(50); + assert!(validate_managed_namespace_name("openshell", &long_workspace).is_err()); + } + + #[test] + fn operator_allowlist_operations() { + let al = OperatorNamespaceAllowlist::new(); + assert!(!al.contains("ns1")); + + al.replace(BTreeSet::from(["ns1".to_string(), "ns2".to_string()])); + assert!(al.contains("ns1")); + assert!(al.contains("ns2")); + assert!(!al.contains("ns3")); + + al.merge(&BTreeSet::from(["ns3".to_string()])); + assert!(al.contains("ns3")); + + al.replace(BTreeSet::new()); + assert!(!al.contains("ns1")); + } + + #[test] + fn operator_allowlist_recovers_from_poisoned_lock() { + let al = OperatorNamespaceAllowlist::from_set(BTreeSet::from(["ns1".to_string()])); + let shared = al.shared(); + let _ = std::thread::spawn(move || { + let _guard = shared.write().unwrap(); + panic!("poison allowlist lock"); + }) + .join(); + + assert!(al.contains("ns1")); + assert!(al.insert("ns2".to_string())); + assert!(al.read().contains("ns2")); + assert!(al.remove("ns1")); + } } diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 2d947b8a28..bb1b75e8a9 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -6,22 +6,37 @@ use super::AppArmorProfile; use crate::config::{ DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_SANDBOX_UID, - DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, SupervisorSideloadMethod, - SupervisorTopology, + DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, OperatorNamespaceAllowlist, + SupervisorSideloadMethod, SupervisorTopology, WorkspaceMode, is_dns_1123_label, + managed_namespace, managed_namespace_prefix, validate_managed_namespace_name, }; use futures::{Stream, StreamExt, TryStreamExt}; +use k8s_openapi::api::authentication::v1::{ + TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo, +}; use k8s_openapi::api::core::v1::{ - Event as KubeEventObj, Namespace, Node, PersistentVolumeClaimVolumeSource, Volume, VolumeMount, + Event as KubeEventObj, Namespace, Node, PersistentVolumeClaimVolumeSource, Pod, Secret, + ServiceAccount, Volume, VolumeMount, +}; +use k8s_openapi::api::networking::v1::{ + NetworkPolicy, NetworkPolicyIngressRule, NetworkPolicyPeer, NetworkPolicyPort, + NetworkPolicySpec, +}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, OwnerReference}; +use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; +use kube::api::{ + Api, ApiResource, DeleteParams, ListParams, Patch, PatchParams, PostParams, Preconditions, }; -use kube::api::{Api, ApiResource, DeleteParams, ListParams, PostParams, Preconditions}; use kube::core::gvk::GroupVersionKind; use kube::core::{DynamicObject, ObjectMeta}; +use kube::runtime::WatchStreamExt; use kube::runtime::watcher::{self, Event}; use kube::{Client, Error as KubeError}; use openshell_core::driver_mounts; use openshell_core::driver_utils::{ - LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, openshell_sandbox_label_selector, + LABEL_GATEWAY_ID, LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, + LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, + openshell_sandbox_label_selector, }; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::progress::{ @@ -39,10 +54,10 @@ use openshell_core::proto::compute::v1::{ use openshell_core::proto_struct::{struct_to_json_object, value_to_json}; use serde::Deserialize; use std::collections::{BTreeMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use tokio::sync::{OnceCell, mpsc}; use tokio_stream::wrappers::ReceiverStream; use tracing::{debug, info, warn}; @@ -50,10 +65,15 @@ use tracing::{debug, info, warn}; pub type WatchStream = Pin> + Send>>; +const MANAGED_SSH_NETWORK_POLICY_NAME: &str = "openshell-sandbox-ssh"; +const AGENT_SANDBOX_TRACE_CONTEXT_ANNOTATION: &str = "opentelemetry.io/trace-context"; + #[derive(Debug, thiserror::Error)] pub enum KubernetesDriverError { #[error("sandbox already exists")] AlreadyExists, + #[error("sandbox not found")] + NotFound, #[error("{0}")] InvalidArgument(String), #[error("{0}")] @@ -75,6 +95,7 @@ impl From for openshell_core::ComputeDriverError { fn from(err: KubernetesDriverError) -> Self { match err { KubernetesDriverError::AlreadyExists => Self::AlreadyExists, + KubernetesDriverError::NotFound => Self::NotFound, KubernetesDriverError::InvalidArgument(m) => Self::InvalidArgument(m), KubernetesDriverError::Precondition(m) => Self::Precondition(m), KubernetesDriverError::Message(m) => Self::Message(m), @@ -87,11 +108,23 @@ impl From for openshell_core::ComputeDriverError { /// API server is unreachable or slow. const KUBE_API_TIMEOUT: Duration = Duration::from_secs(30); +/// Kubernetes defaults pod termination to 30 seconds when the pod template +/// omits `terminationGracePeriodSeconds`. +const DEFAULT_POD_TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(30); +const STOP_INITIAL_POLL_INTERVAL: Duration = Duration::from_millis(250); +const STOP_MAX_POLL_INTERVAL: Duration = Duration::from_secs(2); + const SANDBOX_GROUP: &str = "agents.x-k8s.io"; const SANDBOX_VERSION_V1BETA1: &str = "v1beta1"; const SANDBOX_VERSION_V1ALPHA1: &str = "v1alpha1"; const SANDBOX_VERSIONS: &[&str] = &[SANDBOX_VERSION_V1BETA1, SANDBOX_VERSION_V1ALPHA1]; pub const SANDBOX_KIND: &str = "Sandbox"; +const SANDBOX_POD_NAME_ANNOTATION: &str = "agents.x-k8s.io/pod-name"; +const SANDBOX_SUSPENDED_CONDITION: &str = "Suspended"; +const SANDBOX_SUSPENDED_POD_NOT_OWNED_REASON: &str = "PodNotOwned"; +const SANDBOX_TOKEN_AUDIENCE: &str = "openshell-gateway"; +const POD_NAME_EXTRA: &str = "authentication.kubernetes.io/pod-name"; +const POD_UID_EXTRA: &str = "authentication.kubernetes.io/pod-uid"; const GPU_RESOURCE_NAME: &str = "nvidia.com/gpu"; const SPIFFE_WORKLOAD_API_VOLUME_NAME: &str = "spiffe-workload-api"; @@ -246,11 +279,13 @@ impl From<&KubernetesDriverVolumeMountConfig> for VolumeMount { } const CLIENT_TLS_VOLUME_NAME: &str = "openshell-client-tls"; +const UPSTREAM_PROXY_AUTH_VOLUME_NAME: &str = "openshell-upstream-proxy-auth"; const SERVICE_ACCOUNT_TOKEN_VOLUME_NAME: &str = "openshell-sa-token"; const SERVICE_ACCOUNT_TOKEN_MOUNT_PATH: &str = "/var/run/secrets/openshell"; const KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES: &[&str] = &[ CLIENT_TLS_VOLUME_NAME, + UPSTREAM_PROXY_AUTH_VOLUME_NAME, SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, SPIFFE_WORKLOAD_API_VOLUME_NAME, SUPERVISOR_VOLUME_NAME, @@ -312,6 +347,10 @@ fn validate_kubernetes_driver_volume_mounts( } driver_mounts::validate_container_mount_target(&mount.mount_path)?; + driver_mounts::validate_workspace_mount_target( + &mount.mount_path, + driver_mounts::DEFAULT_WORKSPACE_ROOT, + )?; let normalized_mount_path = driver_mounts::normalize_mount_target(&mount.mount_path); if !mount_paths.insert(normalized_mount_path.clone()) { return Err(format!( @@ -326,23 +365,13 @@ fn validate_kubernetes_driver_volume_mounts( Ok(()) } -// TODO: replace with an openshell_core Kubernetes-name helper once available. -fn is_dns_label(label: &str) -> bool { - if label.is_empty() || label.len() > 63 || label.starts_with('-') || label.ends_with('-') { - return false; - } - label - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') -} - // TODO: replace with an openshell_core Kubernetes-name helper once available. fn is_dns_subdomain(value: &str) -> bool { - value.len() <= 253 && value.split('.').all(is_dns_label) + value.len() <= 253 && value.split('.').all(is_dns_1123_label) } fn validate_kubernetes_dns1123_label(value: &str, field: &str) -> Result<(), String> { - if !is_dns_label(value) { + if !is_dns_1123_label(value) { return Err(format!( "{field} must be a DNS-1123 label: use lowercase alphanumeric characters or '-', start and end with an alphanumeric character, and use at most 63 characters" )); @@ -432,6 +461,7 @@ pub struct KubernetesComputeDriver { watch_client: Client, sandbox_api_version: Arc>, config: KubernetesComputeConfig, + operator_allowlist: Option, } impl std::fmt::Debug for KubernetesComputeDriver { @@ -445,7 +475,30 @@ impl std::fmt::Debug for KubernetesComputeDriver { } impl KubernetesComputeDriver { - pub async fn new(config: KubernetesComputeConfig) -> Result { + #[cfg(test)] + pub(crate) fn new_for_test(config: KubernetesComputeConfig) -> Self { + let service = tower::service_fn(|_request: http::Request| async { + Ok::<_, std::convert::Infallible>(http::Response::new(http_body_util::Empty::< + bytes::Bytes, + >::new())) + }); + let client = Client::new(service, "default"); + Self { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + config, + operator_allowlist: None, + } + } + + pub async fn new( + config: KubernetesComputeConfig, + shutdown_rx: tokio::sync::watch::Receiver, + ) -> Result { + config + .validate_workspace_mode() + .map_err(KubernetesDriverError::Precondition)?; config .validate_provider_spiffe_workload_api_socket_path() .map_err(KubernetesDriverError::Precondition)?; @@ -455,6 +508,9 @@ impl KubernetesComputeDriver { config .validate_proxy_uid() .map_err(KubernetesDriverError::Precondition)?; + config + .validate_upstream_proxy_config() + .map_err(KubernetesDriverError::Precondition)?; let base_config = match kube::Config::incluster() { Ok(c) => c, Err(_) => kube::Config::infer() @@ -476,20 +532,116 @@ impl KubernetesComputeDriver { let watch_client = Client::try_from(watch_kube_config).map_err(KubernetesDriverError::from_kube)?; - Ok(Self { + let operator_allowlist = if matches!(config.workspace_mode, WorkspaceMode::Operator) { + let allowlist = OperatorNamespaceAllowlist::new(); + + if let Some(ref label) = config.operator_namespace_label { + spawn_namespace_label_watcher( + watch_client.clone(), + label.clone(), + allowlist.clone(), + shutdown_rx.clone(), + ); + } + + if let Some(ref path) = config.operator_namespace_file { + spawn_namespace_file_watcher(path.into(), allowlist.clone(), shutdown_rx.clone()); + } + + Some(allowlist) + } else { + None + }; + + let driver = Self { client, watch_client, sandbox_api_version: Arc::new(OnceCell::new()), config, - }) + operator_allowlist, + }; + + if driver.workspace_mode() == WorkspaceMode::Shared { + driver.backfill_gateway_id_labels().await?; + } + + Ok(driver) } pub fn capabilities(&self) -> Result { - Ok(openshell_core::driver_utils::build_capabilities_response( - "kubernetes", - openshell_core::VERSION, - &self.config.default_image, - )) + Ok(GetCapabilitiesResponse { + driver_name: "kubernetes".to_string(), + driver_version: openshell_core::VERSION.to_string(), + default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: true, + driver_reports_runtime_readiness: false, + }) + } + + /// Authenticate the projected `ServiceAccount` token used by a sandbox pod. + pub async fn authenticate_sandbox(&self, credential: &str) -> Result { + let reviews: Api = Api::all(self.client.clone()); + let review = TokenReview { + metadata: ObjectMeta::default(), + spec: TokenReviewSpec { + audiences: Some(vec![SANDBOX_TOKEN_AUDIENCE.to_string()]), + token: Some(credential.to_string()), + }, + status: None, + }; + let review = reviews + .create(&PostParams::default(), &review) + .await + .map_err(|error| { + warn!(%error, "Kubernetes TokenReview failed"); + tonic::Status::internal("Kubernetes TokenReview failed") + })?; + let status = review + .status + .ok_or_else(|| tonic::Status::internal("TokenReview response missing status"))?; + let identity = token_review_identity(&status, &self.config.service_account_name)? + .ok_or_else(|| tonic::Status::unauthenticated("sandbox credential was not accepted"))?; + if !self.accepts_auth_namespace(&identity.namespace) { + return Err(tonic::Status::permission_denied( + "sandbox credential namespace is not accepted by the driver", + )); + } + + let pods: Api = Api::namespaced(self.client.clone(), &identity.namespace); + let pod = pods + .get_opt(&identity.pod_name) + .await + .map_err(|error| { + warn!(pod = %identity.pod_name, %error, "failed to read authenticated sandbox pod"); + tonic::Status::internal("failed to read authenticated sandbox pod") + })? + .ok_or_else(|| { + tonic::Status::permission_denied("authenticated sandbox pod not found") + })?; + validate_pod_uid(&pod, &identity.pod_uid)?; + let sandbox_id = pod_sandbox_id(&pod)?; + let owner = sandbox_owner_reference(&pod)?; + let sandboxes = self + .supported_agent_sandbox_api(self.client.clone(), &identity.namespace) + .await + .map_err(|error| { + tonic::Status::internal(format!("failed to select Sandbox API: {error}")) + })?; + let sandbox = sandboxes.api.get_opt(&owner.name).await.map_err(|error| { + warn!(sandbox = %owner.name, %error, "failed to read authenticated Sandbox resource"); + tonic::Status::internal("failed to read authenticated Sandbox resource") + })?.ok_or_else(|| tonic::Status::permission_denied("sandbox owner not found"))?; + validate_sandbox_owner_identity(owner, &sandbox_id, &sandbox)?; + Ok(sandbox_id) + } + + fn accepts_auth_namespace(&self, namespace: &str) -> bool { + accepts_auth_namespace(&self.config, self.operator_allowlist.as_ref(), namespace) + } + + pub fn operator_allowlist(&self) -> Option<&OperatorNamespaceAllowlist> { + self.operator_allowlist.as_ref() } pub fn default_image(&self) -> &str { @@ -504,6 +656,437 @@ impl KubernetesComputeDriver { &self.config.ssh_socket_path } + pub fn workspace_mode(&self) -> WorkspaceMode { + self.config.workspace_mode + } + + pub(crate) fn validate_workspace_namespace( + &self, + workspace: &str, + ) -> Result<(), KubernetesDriverError> { + if self.config.workspace_mode == WorkspaceMode::Managed { + validate_managed_namespace_name(&self.config.gateway_id, workspace) + .map_err(KubernetesDriverError::InvalidArgument)?; + } + Ok(()) + } + + /// Backfill the `openshell.ai/gateway-id` label on Sandbox CRs that + /// predate its introduction. Runs once at startup in shared mode so that + /// label-selector based lookups continue to find legacy resources. + async fn backfill_gateway_id_labels(&self) -> Result<(), KubernetesDriverError> { + let sandbox_api = self + .supported_sandbox_api_for_lookup(self.client.clone()) + .await + .map_err(KubernetesDriverError::Message)?; + + let selector = openshell_sandbox_label_selector(); + let list = match tokio::time::timeout( + KUBE_API_TIMEOUT, + sandbox_api + .api + .list(&ListParams::default().labels(&selector)), + ) + .await + { + Ok(Ok(list)) => list, + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message( + "timeout listing Sandbox resources for gateway-id label backfill".to_string(), + )); + } + }; + + let gateway_id = &self.config.gateway_id; + for obj in &list { + if !gateway_id_label_needs_backfill(obj.metadata.labels.as_ref(), gateway_id) { + continue; + } + let Some(name) = obj.metadata.name.as_deref() else { + continue; + }; + let patch = serde_json::json!({ + "metadata": { + "labels": { + LABEL_GATEWAY_ID: gateway_id + } + } + }); + match tokio::time::timeout( + KUBE_API_TIMEOUT, + sandbox_api + .api + .patch(name, &PatchParams::default(), &Patch::Merge(&patch)), + ) + .await + { + Ok(Ok(_)) => { + info!(sandbox = %name, gateway_id, "backfilled gateway-id label"); + } + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout backfilling gateway-id label on Sandbox {name}" + ))); + } + } + } + + Ok(()) + } + + /// Ensure the K8s namespace for a workspace exists (managed mode only). + /// + /// Idempotent: returns the namespace name whether it was just created or + /// already existed. Also creates the sandbox `ServiceAccount` in the + /// namespace. + /// + pub async fn ensure_namespace(&self, workspace: &str) -> Result { + let ns_name = managed_namespace(&self.config.gateway_id, workspace); + let ns_api: Api = Api::all(self.client.clone()); + + let gateway_ns_annotations = match tokio::time::timeout( + KUBE_API_TIMEOUT, + ns_api.get(&self.config.namespace), + ) + .await + { + Ok(Ok(ns)) => ns.metadata.annotations.unwrap_or_default(), + Ok(Err(error)) => return Err(KubernetesDriverError::from_kube(error)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout getting gateway namespace {} for SCC annotations", + self.config.namespace + ))); + } + }; + + let mut labels = BTreeMap::new(); + labels.insert( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ); + labels.insert(LABEL_GATEWAY_ID.to_string(), self.config.gateway_id.clone()); + labels.insert(LABEL_SANDBOX_WORKSPACE.to_string(), workspace.to_string()); + + let mut annotations = BTreeMap::new(); + for key in [ + crate::config::ANNOTATION_SCC_UID_RANGE, + crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS, + ] { + if let Some(val) = gateway_ns_annotations.get(key) { + annotations.insert(key.to_string(), val.clone()); + } + } + + let ns = Namespace { + metadata: ObjectMeta { + name: Some(ns_name.clone()), + labels: Some(labels), + annotations: if annotations.is_empty() { + None + } else { + Some(annotations) + }, + ..Default::default() + }, + ..Default::default() + }; + + match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.create(&PostParams::default(), &ns)) + .await + { + Ok(Ok(_)) => { + info!(namespace = %ns_name, workspace = %workspace, "created managed namespace"); + } + Ok(Err(KubeError::Api(api))) if api.code == 409 => { + let existing = + match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.get(&ns_name)).await { + Ok(Ok(ns)) => ns, + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout reading namespace {ns_name}" + ))); + } + }; + if !is_namespace_owned_by_gateway( + existing.metadata.labels.as_ref(), + &self.config.gateway_id, + ) { + return Err(KubernetesDriverError::Precondition(format!( + "namespace {ns_name} exists but is not owned by this gateway" + ))); + } + debug!(namespace = %ns_name, "managed namespace already exists"); + } + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout creating namespace {ns_name}" + ))); + } + } + + self.ensure_service_account(&ns_name).await?; + self.ensure_managed_ssh_network_policy(&ns_name).await?; + + Ok(ns_name) + } + + async fn ensure_managed_ssh_network_policy( + &self, + namespace: &str, + ) -> Result<(), KubernetesDriverError> { + if !self.config.managed_ssh_ingress.enabled { + return Ok(()); + } + + let policy = managed_ssh_network_policy(namespace, &self.config); + let policy_api: Api = Api::namespaced(self.client.clone(), namespace); + match tokio::time::timeout( + KUBE_API_TIMEOUT, + policy_api.patch( + MANAGED_SSH_NETWORK_POLICY_NAME, + &PatchParams::apply("openshell"), + &Patch::Apply(&policy), + ), + ) + .await + { + Ok(Ok(_)) => { + info!(namespace, "applied managed sandbox SSH NetworkPolicy"); + Ok(()) + } + Ok(Err(error)) => Err(KubernetesDriverError::from_kube(error)), + Err(_) => Err(KubernetesDriverError::Message(format!( + "timeout applying SSH NetworkPolicy in {namespace}" + ))), + } + } + + async fn ensure_service_account(&self, namespace: &str) -> Result<(), KubernetesDriverError> { + let sa_api: Api = Api::namespaced(self.client.clone(), namespace); + let sa = ServiceAccount { + metadata: ObjectMeta { + name: Some(self.config.service_account_name.clone()), + labels: Some(BTreeMap::from([( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + )])), + ..Default::default() + }, + ..Default::default() + }; + + match tokio::time::timeout(KUBE_API_TIMEOUT, sa_api.create(&PostParams::default(), &sa)) + .await + { + Ok(Ok(_)) => { + info!(namespace = %namespace, sa = %self.config.service_account_name, "created service account"); + } + Ok(Err(KubeError::Api(api))) if api.code == 409 => {} + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout creating service account in {namespace}" + ))); + } + } + + Ok(()) + } + + /// Ensure the client TLS Secret exists in `namespace` by copying it from + /// the gateway's Helm release namespace. Idempotent: creates the Secret on + /// first call, updates it on subsequent calls to pick up cert rotations. + /// No-op when `client_tls_secret_name` is empty (TLS disabled). + async fn ensure_tls_secret(&self, namespace: &str) -> Result<(), KubernetesDriverError> { + if self.config.client_tls_secret_name.is_empty() { + return Ok(()); + } + + let source_api: Api = Api::namespaced(self.client.clone(), &self.config.namespace); + let source = match tokio::time::timeout( + KUBE_API_TIMEOUT, + source_api.get(&self.config.client_tls_secret_name), + ) + .await + { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + warn!( + secret = %self.config.client_tls_secret_name, + source_namespace = %self.config.namespace, + error = %e, + "failed to read source TLS secret" + ); + return Err(KubernetesDriverError::from_kube(e)); + } + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout reading TLS secret {} from {}", + self.config.client_tls_secret_name, self.config.namespace + ))); + } + }; + + let target_api: Api = Api::namespaced(self.client.clone(), namespace); + let copy = Secret { + metadata: ObjectMeta { + name: Some(self.config.client_tls_secret_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(BTreeMap::from([( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + )])), + ..Default::default() + }, + data: source.data, + type_: source.type_, + ..Default::default() + }; + + match tokio::time::timeout( + KUBE_API_TIMEOUT, + target_api.patch( + &self.config.client_tls_secret_name, + &PatchParams::apply("openshell"), + &Patch::Apply(©), + ), + ) + .await + { + Ok(Ok(_)) => { + info!( + namespace = %namespace, + secret = %self.config.client_tls_secret_name, + "applied TLS secret copy" + ); + } + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout applying TLS secret in {namespace}" + ))); + } + } + + Ok(()) + } + + /// Copy the explicitly configured image-pull Secrets into a managed + /// workspace namespace. Server-side apply refreshes rotated credentials + /// without forcibly taking fields owned by another manager. + async fn ensure_image_pull_secrets( + &self, + namespace: &str, + ) -> Result<(), KubernetesDriverError> { + let source_api: Api = Api::namespaced(self.client.clone(), &self.config.namespace); + let target_api: Api = Api::namespaced(self.client.clone(), namespace); + + for secret_name in &self.config.image_pull_secrets { + let source = match tokio::time::timeout(KUBE_API_TIMEOUT, source_api.get(secret_name)) + .await + { + Ok(Ok(secret)) => secret, + Ok(Err(KubeError::Api(error))) if error.code == 404 => { + return Err(KubernetesDriverError::Precondition(format!( + "configured image-pull Secret {secret_name} does not exist in source namespace {}", + self.config.namespace + ))); + } + Ok(Err(error)) => return Err(KubernetesDriverError::from_kube(error)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout reading image-pull Secret {secret_name} from {}", + self.config.namespace + ))); + } + }; + + let copy = image_pull_secret_copy(secret_name, namespace, source); + match tokio::time::timeout( + KUBE_API_TIMEOUT, + target_api.patch( + secret_name, + &PatchParams::apply("openshell"), + &Patch::Apply(©), + ), + ) + .await + { + Ok(Ok(_)) => { + info!(namespace, secret = %secret_name, "applied image-pull Secret copy"); + } + Ok(Err(error)) => return Err(KubernetesDriverError::from_kube(error)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout applying image-pull Secret {secret_name} in {namespace}" + ))); + } + } + } + + Ok(()) + } + + /// Delete the managed namespace and all its contents (managed mode only). + /// Called via the `DeleteWorkspace` RPC after workspace deletion. + /// Kubernetes cascades namespace deletion to all resources within it. + pub async fn delete_namespace(&self, workspace: &str) -> Result<(), KubernetesDriverError> { + let ns_name = managed_namespace(&self.config.gateway_id, workspace); + let ns_api: Api = Api::all(self.client.clone()); + + let ns = match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.get(&ns_name)).await { + Ok(Ok(ns)) => ns, + Ok(Err(KubeError::Api(api))) if api.code == 404 => { + debug!(namespace = %ns_name, "managed namespace already deleted"); + return Ok(()); + } + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout getting namespace {ns_name}" + ))); + } + }; + + if !is_namespace_owned_by_gateway(ns.metadata.labels.as_ref(), &self.config.gateway_id) { + debug!( + namespace = %ns_name, + "namespace not owned by this gateway, skipping delete" + ); + return Ok(()); + } + + let namespace_uid = ns.metadata.uid.ok_or_else(|| { + KubernetesDriverError::Message(format!( + "namespace {ns_name} has no UID; refusing an unguarded delete" + )) + })?; + let delete_params = namespace_delete_params(namespace_uid); + + match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.delete(&ns_name, &delete_params)).await + { + Ok(Ok(_)) => { + info!(namespace = %ns_name, workspace = %workspace, "deleted managed namespace"); + } + Ok(Err(KubeError::Api(api))) if api.code == 404 => { + debug!(namespace = %ns_name, "managed namespace already deleted"); + } + Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)), + Err(_) => { + return Err(KubernetesDriverError::Message(format!( + "timeout deleting namespace {ns_name}" + ))); + } + } + + Ok(()) + } + fn validate_driver_config_for_sandbox( &self, sandbox: &Sandbox, @@ -518,16 +1101,59 @@ impl KubernetesComputeDriver { ) } - fn agent_sandbox_api(&self, client: Client, sandbox_api_version: &str) -> AgentSandboxApi { + fn agent_sandbox_api( + client: Client, + sandbox_api_version: &str, + namespace: &str, + ) -> AgentSandboxApi { let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, sandbox_api_version, SANDBOX_KIND); let resource = ApiResource::from_gvk(&gvk); - let api = Api::namespaced_with(client, &self.config.namespace, &resource); + let api = Api::namespaced_with(client, namespace, &resource); AgentSandboxApi { api, resource } } - async fn supported_agent_sandbox_api(&self, client: Client) -> Result { + fn cluster_wide_sandbox_api(client: Client, sandbox_api_version: &str) -> AgentSandboxApi { + let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, sandbox_api_version, SANDBOX_KIND); + let resource = ApiResource::from_gvk(&gvk); + let api = Api::all_with(client, &resource); + AgentSandboxApi { api, resource } + } + + async fn supported_agent_sandbox_api( + &self, + client: Client, + namespace: &str, + ) -> Result { let sandbox_api_version = self.supported_sandbox_api_version(client.clone()).await?; - Ok(self.agent_sandbox_api(client, sandbox_api_version)) + Ok(Self::agent_sandbox_api( + client, + sandbox_api_version, + namespace, + )) + } + + async fn supported_sandbox_api_for_lookup( + &self, + client: Client, + ) -> Result { + let sandbox_api_version = self.supported_sandbox_api_version(client.clone()).await?; + if self.config.is_multi_namespace() { + Ok(Self::cluster_wide_sandbox_api(client, sandbox_api_version)) + } else { + Ok(Self::agent_sandbox_api( + client, + sandbox_api_version, + &self.config.namespace, + )) + } + } + + fn sandbox_lookup_selector(&self, sandbox_id: &str) -> String { + sandbox_lookup_selector_for(sandbox_id, &self.config.gateway_id) + } + + fn openshell_sandbox_selector(&self) -> String { + openshell_sandbox_selector_for(&self.config.gateway_id) } async fn supported_sandbox_api_version(&self, client: Client) -> Result<&'static str, String> { @@ -544,7 +1170,11 @@ impl KubernetesComputeDriver { client: Client, ) -> Result<&'static str, String> { for sandbox_api_version in SANDBOX_VERSIONS { - let agent_sandbox_api = self.agent_sandbox_api(client.clone(), sandbox_api_version); + let agent_sandbox_api = Self::agent_sandbox_api( + client.clone(), + sandbox_api_version, + &self.config.namespace, + ); match tokio::time::timeout( KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&ListParams::default().limit(1)), @@ -582,39 +1212,27 @@ impl KubernetesComputeDriver { )) } - /// Resolve sandbox UID/GID from config or `OpenShift` SCC namespace annotations. - /// - /// Returns `(uid, gid, ns_annotations_map)`: - /// - If `sandbox_uid` is set in config, returns that (with fallback GID) - /// - Otherwise fetches the target namespace and checks for - /// `openshift.io/sa.scc.uid-range` / `openshift.io/sa.scc.supplemental-groups` - /// annotations. - /// - If neither config nor `OpenShift` is found, returns `(1000, 1000, {})` as defaults. - async fn resolve_sandbox_identity(&self) -> (u32, u32, BTreeMap) { - // Explicit config takes priority — skip namespace lookup entirely. + async fn resolve_sandbox_identity_in_namespace( + &self, + namespace: &str, + ) -> (u32, u32, BTreeMap) { if self.config.sandbox_uid.is_some() { let uid = self.config.resolve_sandbox_uid(None); let gid = self.config.resolve_sandbox_gid(uid, None); return (uid, gid, BTreeMap::new()); } - // Try to read namespace annotations for OpenShift SCC. - // Namespace is namespaced so Api::all works (it's cluster-scoped but - // can list all namespaces) and we filter by name, or use Api::namespaced. let ns_api: Api = Api::all(self.client.clone()); - match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.get(self.config.namespace.as_str())) - .await - { + match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.get(namespace)).await { Ok(Ok(ns)) => { let anns = ns.metadata.annotations.unwrap_or_default(); tracing::info!( - namespace = %self.config.namespace, + namespace = %namespace, uid_range = ?anns.get(crate::config::ANNOTATION_SCC_UID_RANGE), sup_groups = ?anns.get(crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS), "Resolved namespace annotations for sandbox identity" ); let uid = self.config.resolve_sandbox_uid(Some(&anns)); - // Explicit sandbox_gid config wins; SCC annotation only applies when not set. let baseline_gid = self.config.resolve_sandbox_gid(uid, None); let gid = self.config.sandbox_gid.map_or_else( || { @@ -633,7 +1251,7 @@ impl KubernetesComputeDriver { } Ok(Err(e)) => { tracing::warn!( - namespace = %self.config.namespace, + namespace = %namespace, error = %e, "Failed to fetch namespace for SCC annotations, falling back to defaults" ); @@ -643,7 +1261,7 @@ impl KubernetesComputeDriver { } Err(_) => { tracing::warn!( - namespace = %self.config.namespace, + namespace = %namespace, "Namespace fetch timed out, falling back to defaults" ); let uid = DEFAULT_SANDBOX_UID; @@ -668,7 +1286,15 @@ impl KubernetesComputeDriver { let _ = self .validate_driver_config_for_sandbox(sandbox) .map_err(tonic::Status::invalid_argument)?; - validate_kube_resource_name_length(&sandbox.workspace, &sandbox.name)?; + match self.config.workspace_mode { + WorkspaceMode::Shared => { + validate_kube_resource_name_length(&sandbox.workspace, &sandbox.name)?; + } + WorkspaceMode::Managed | WorkspaceMode::Operator => { + validate_kubernetes_dns1123_label(&sandbox.name, "sandbox name") + .map_err(tonic::Status::invalid_argument)?; + } + } let gpu_requirements = sandbox .spec .as_ref() @@ -689,15 +1315,14 @@ impl KubernetesComputeDriver { pub async fn get_sandbox(&self, sandbox_id: &str) -> Result, String> { info!( sandbox_id = %sandbox_id, - namespace = %self.config.namespace, + workspace_mode = %self.config.workspace_mode, "Fetching sandbox from Kubernetes" ); let agent_sandbox_api = self - .supported_agent_sandbox_api(self.client.clone()) + .supported_sandbox_api_for_lookup(self.client.clone()) .await?; - let selector = - format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id}"); + let selector = self.sandbox_lookup_selector(sandbox_id); let lp = ListParams::default().labels(&selector); match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { Ok(Ok(list)) => list.items.into_iter().next().map_or_else( @@ -706,9 +1331,12 @@ impl KubernetesComputeDriver { Ok(None) }, |obj| { - Ok(sandbox_from_object(&self.config.namespace, obj) - .ok() - .map(|(_, s)| s)) + let ns = obj + .metadata + .namespace + .clone() + .unwrap_or_else(|| self.config.namespace.clone()); + Ok(sandbox_from_object(&ns, obj).ok().map(|(_, s)| s)) }, ), Ok(Err(err)) => { @@ -735,18 +1363,19 @@ impl KubernetesComputeDriver { pub async fn list_sandboxes(&self) -> Result, String> { info!( - namespace = %self.config.namespace, + workspace_mode = %self.config.workspace_mode, "Listing sandboxes from Kubernetes" ); let agent_sandbox_api = self - .supported_agent_sandbox_api(self.client.clone()) + .supported_sandbox_api_for_lookup(self.client.clone()) .await?; + let selector = self.openshell_sandbox_selector(); match tokio::time::timeout( KUBE_API_TIMEOUT, agent_sandbox_api .api - .list(&ListParams::default().labels(&openshell_sandbox_label_selector())), + .list(&ListParams::default().labels(&selector)), ) .await { @@ -756,7 +1385,12 @@ impl KubernetesComputeDriver { .into_iter() .filter_map(|obj| { let name = obj.metadata.name.clone().unwrap_or_default(); - match sandbox_from_object(&self.config.namespace, obj) { + let ns = obj + .metadata + .namespace + .clone() + .unwrap_or_else(|| self.config.namespace.clone()); + match sandbox_from_object(&ns, obj) { Ok((_, s)) => Some(s), Err(err) => { warn!(object_name = %name, error = %err, "skipping unrecognized Sandbox in list"); @@ -774,7 +1408,6 @@ impl KubernetesComputeDriver { } Ok(Err(err)) => { warn!( - namespace = %self.config.namespace, error = %err, "Failed to list sandboxes from Kubernetes" ); @@ -782,7 +1415,6 @@ impl KubernetesComputeDriver { } Err(_elapsed) => { warn!( - namespace = %self.config.namespace, timeout_secs = KUBE_API_TIMEOUT.as_secs(), "Timed out listing sandboxes from Kubernetes" ); @@ -795,7 +1427,24 @@ impl KubernetesComputeDriver { } #[allow(clippy::similar_names)] + #[tracing::instrument( + name = "kubernetes.provision", + skip(self, sandbox), + fields( + otel.name = "kubernetes.provision", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + sandbox.name = %sandbox.name, + ) + )] pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self.create_sandbox_inner(sandbox).await; + span_status.finish(result) + } + + #[allow(clippy::similar_names)] + async fn create_sandbox_inner(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { let gpu_requirements = sandbox .spec .as_ref() @@ -809,21 +1458,50 @@ impl KubernetesComputeDriver { .map_err(KubernetesDriverError::InvalidArgument)?; let name = sandbox.name.as_str(); + let workspace = sandbox.workspace.as_str(); + self.validate_workspace_namespace(workspace)?; + + let target_namespace = match self.config.workspace_mode { + WorkspaceMode::Shared => self.config.namespace.clone(), + WorkspaceMode::Managed => { + let namespace = self.ensure_namespace(workspace).await?; + self.ensure_image_pull_secrets(&namespace).await?; + namespace + } + WorkspaceMode::Operator => { + if let Some(ref allowlist) = self.operator_allowlist + && !allowlist.contains(workspace) + { + return Err(KubernetesDriverError::Precondition(format!( + "workspace '{workspace}' is not in the operator namespace allowlist" + ))); + } + workspace.to_string() + } + }; + + if self.config.is_multi_namespace() { + self.ensure_tls_secret(&target_namespace).await?; + } + info!( sandbox_id = %sandbox.id, sandbox_name = %name, - namespace = %self.config.namespace, + namespace = %target_namespace, + workspace = %workspace, + workspace_mode = %self.config.workspace_mode, "Creating sandbox in Kubernetes" ); let agent_sandbox_api = self - .supported_agent_sandbox_api(self.client.clone()) + .supported_agent_sandbox_api(self.client.clone(), &target_namespace) .await .map_err(KubernetesDriverError::Message)?; // Resolve sandbox UID/GID from config or OpenShift SCC namespace annotations. - let (resolved_user_id, resolved_group_id, ns_annotations) = - self.resolve_sandbox_identity().await; + let (resolved_user_id, resolved_group_id, ns_annotations) = self + .resolve_sandbox_identity_in_namespace(&target_namespace) + .await; let params = SandboxPodParams { default_image: &self.config.default_image, @@ -838,6 +1516,12 @@ impl KubernetesComputeDriver { .config .sidecar .process_binary_aware_network_policy, + https_proxy: self.config.https_proxy.as_deref(), + no_proxy: self.config.no_proxy.as_deref(), + proxy_auth_secret_name: self.config.proxy_auth_secret_name.as_deref(), + proxy_auth_secret_key: self.config.proxy_auth_secret_key.as_deref(), + proxy_auth_allow_insecure: self.config.proxy_auth_allow_insecure == Some(true), + proxy_connect_by_hostname: self.config.proxy_connect_by_hostname == Some(true), service_account_name: &self.config.service_account_name, sandbox_id: &sandbox.id, sandbox_name: &sandbox.name, @@ -862,12 +1546,10 @@ impl KubernetesComputeDriver { let data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms) .map_err(KubernetesDriverError::InvalidArgument)?; - let kube_name = kube_resource_name(&sandbox.workspace, name); + let kube_name = self.config.kube_resource_name(workspace, name); let mut obj = DynamicObject::new(&kube_name, &agent_sandbox_api.resource); - // Copy only the SCC-related annotations onto the Sandbox CR for - // traceability. Copying the full namespace annotation map exposes - // unrelated cluster metadata and can fail with oversized annotations. let mut annotations = sandbox_annotations(sandbox); + add_trace_context_annotation(&mut annotations); for key in [ crate::config::ANNOTATION_SCC_UID_RANGE, crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS, @@ -878,8 +1560,8 @@ impl KubernetesComputeDriver { } obj.metadata = ObjectMeta { name: Some(kube_name), - namespace: Some(self.config.namespace.clone()), - labels: Some(sandbox_labels(sandbox)), + namespace: Some(target_namespace), + labels: Some(sandbox_labels(sandbox, Some(&self.config.gateway_id))), annotations: Some(annotations), ..Default::default() }; @@ -923,22 +1605,212 @@ impl KubernetesComputeDriver { } } + #[tracing::instrument( + name = "kubernetes.stop_sandbox", + skip(self), + fields( + otel.name = "kubernetes.stop_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] + pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), KubernetesDriverError> { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self.stop_sandbox_inner(sandbox_id).await; + span_status.finish(result) + } + + async fn stop_sandbox_inner(&self, sandbox_id: &str) -> Result<(), KubernetesDriverError> { + let (agent_sandbox_api, kube_name, pod_name, namespace, stop_timeout) = self + .patch_sandbox_operating_state(sandbox_id, false) + .await?; + let pod_api = Api::::namespaced(self.client.clone(), &namespace); + + let deadline = tokio::time::Instant::now() + stop_timeout; + let mut poll_interval = STOP_INITIAL_POLL_INTERVAL; + loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(KubernetesDriverError::Message(format!( + "timed out after {}s waiting for Kubernetes sandbox to stop", + stop_timeout.as_secs() + ))); + } + let request_timeout = KUBE_API_TIMEOUT.min(deadline.saturating_duration_since(now)); + let object = tokio::time::timeout( + request_timeout, + agent_sandbox_api.api.get(&kube_name), + ) + .await + .map_err(|_| { + KubernetesDriverError::Message(format!( + "timed out after {}s waiting for Kubernetes API while checking sandbox stop", + request_timeout.as_secs() + )) + })? + .map_err(KubernetesDriverError::from_kube)?; + if let Some(error) = kubernetes_sandbox_stop_failure(&object) { + return Err(KubernetesDriverError::Message(error)); + } + let pod_is_gone = kubernetes_sandbox_pod_is_gone(&pod_api, &pod_name, deadline) + .await + .map_err(KubernetesDriverError::Message)?; + let stop_is_complete = kubernetes_sandbox_stop_is_complete( + &agent_sandbox_api.resource.version, + &object, + pod_is_gone, + ); + if stop_is_complete { + return Ok(()); + } + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(KubernetesDriverError::Message(format!( + "timed out after {}s waiting for Kubernetes sandbox to stop", + stop_timeout.as_secs() + ))); + } + tokio::time::sleep(poll_interval.min(deadline.saturating_duration_since(now))).await; + poll_interval = next_stop_poll_interval(poll_interval); + } + } + + #[tracing::instrument( + name = "kubernetes.start_sandbox", + skip(self), + fields( + otel.name = "kubernetes.start_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] + pub async fn start_sandbox(&self, sandbox_id: &str) -> Result<(), KubernetesDriverError> { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self + .patch_sandbox_operating_state(sandbox_id, true) + .await + .map(|_| ()); + span_status.finish(result) + } + + async fn patch_sandbox_operating_state( + &self, + sandbox_id: &str, + running: bool, + ) -> Result<(AgentSandboxApi, String, String, String, Duration), KubernetesDriverError> { + let lookup_api = self + .supported_sandbox_api_for_lookup(self.client.clone()) + .await + .map_err(KubernetesDriverError::Message)?; + let selector = self.sandbox_lookup_selector(sandbox_id); + let list = tokio::time::timeout( + KUBE_API_TIMEOUT, + lookup_api + .api + .list(&ListParams::default().labels(&selector)), + ) + .await + .map_err(|_| { + KubernetesDriverError::Message(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )) + })? + .map_err(KubernetesDriverError::from_kube)?; + let object = list + .items + .into_iter() + .next() + .ok_or(KubernetesDriverError::NotFound)?; + let namespace = object + .metadata + .namespace + .clone() + .unwrap_or_else(|| self.config.namespace.clone()); + let agent_sandbox_api = Self::agent_sandbox_api( + self.client.clone(), + &lookup_api.resource.version, + &namespace, + ); + let stop_timeout = kubernetes_sandbox_stop_timeout(&object); + let kube_name = object.metadata.name.ok_or_else(|| { + KubernetesDriverError::Message("sandbox resource has no name".to_string()) + })?; + let pod_name = object + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(SANDBOX_POD_NAME_ANNOTATION)) + .cloned() + .unwrap_or_else(|| kube_name.clone()); + let resource_version = object.metadata.resource_version.unwrap_or_default(); + let desired = sandbox_operating_state_patch( + &agent_sandbox_api.resource.version, + &resource_version, + running, + ); + tokio::time::timeout( + KUBE_API_TIMEOUT, + agent_sandbox_api.api.patch( + &kube_name, + &PatchParams::default(), + &Patch::Merge(&desired), + ), + ) + .await + .map_err(|_| { + KubernetesDriverError::Message(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )) + })? + .map_err(KubernetesDriverError::from_kube)?; + + info!( + sandbox_id, + sandbox_api_version = %agent_sandbox_api.resource.version, + running, + "Updated Kubernetes sandbox operating state" + ); + Ok(( + agent_sandbox_api, + kube_name, + pod_name, + namespace, + stop_timeout, + )) + } + + #[tracing::instrument( + name = "kubernetes.delete_sandbox", + skip(self), + fields( + otel.name = "kubernetes.delete_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let result = self.delete_sandbox_inner(sandbox_id).await; + span_status.finish(result) + } + + async fn delete_sandbox_inner(&self, sandbox_id: &str) -> Result { info!( sandbox_id = %sandbox_id, - namespace = %self.config.namespace, + workspace_mode = %self.config.workspace_mode, "Deleting sandbox from Kubernetes" ); - let agent_sandbox_api = self - .supported_agent_sandbox_api(self.client.clone()) + let lookup_api = self + .supported_sandbox_api_for_lookup(self.client.clone()) .await?; - let selector = - format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id}"); + let selector = self.sandbox_lookup_selector(sandbox_id); let lp = ListParams::default().labels(&selector); - let (kube_name, preconditions) = match tokio::time::timeout( + let (kube_name, obj_namespace, _workspace, preconditions) = match tokio::time::timeout( KUBE_API_TIMEOUT, - agent_sandbox_api.api.list(&lp), + lookup_api.api.list(&lp), ) .await { @@ -946,11 +1818,22 @@ impl KubernetesComputeDriver { if let Some(obj) = list.items.into_iter().next() { match obj.metadata.name { Some(name) => { + let ns = obj + .metadata + .namespace + .clone() + .unwrap_or_else(|| self.config.namespace.clone()); + let ws = obj + .metadata + .labels + .as_ref() + .and_then(|l| l.get(LABEL_SANDBOX_WORKSPACE).cloned()) + .unwrap_or_default(); let pc = Preconditions { uid: obj.metadata.uid, resource_version: obj.metadata.resource_version, }; - (name, pc) + (name, ns, ws, pc) } None => return Ok(false), } @@ -980,15 +1863,13 @@ impl KubernetesComputeDriver { } }; + let delete_api = self + .supported_agent_sandbox_api(self.client.clone(), &obj_namespace) + .await?; let dp = DeleteParams::default().preconditions(preconditions); - match tokio::time::timeout( - KUBE_API_TIMEOUT, - agent_sandbox_api.api.delete(&kube_name, &dp), - ) - .await - { + match tokio::time::timeout(KUBE_API_TIMEOUT, delete_api.api.delete(&kube_name, &dp)).await { Ok(Ok(_response)) => { - info!(sandbox_id = %sandbox_id, "Sandbox deleted from Kubernetes"); + info!(sandbox_id = %sandbox_id, namespace = %obj_namespace, "Sandbox deleted from Kubernetes"); Ok(true) } Ok(Err(KubeError::Api(err))) if err.code == 404 || err.code == 409 => { @@ -1019,10 +1900,9 @@ impl KubernetesComputeDriver { pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result { let agent_sandbox_api = self - .supported_agent_sandbox_api(self.client.clone()) + .supported_sandbox_api_for_lookup(self.client.clone()) .await?; - let selector = - format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id}"); + let selector = self.sandbox_lookup_selector(sandbox_id); let lp = ListParams::default().labels(&selector); match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { Ok(Ok(list)) => Ok(!list.items.is_empty()), @@ -1037,14 +1917,30 @@ impl KubernetesComputeDriver { // Kept `async` to match the gRPC handler signature in `grpc.rs`, which awaits this method. #[allow(clippy::unused_async)] pub async fn watch_sandboxes(&self) -> Result { + if self.config.is_multi_namespace() { + self.watch_sandboxes_cluster_wide().await + } else { + self.watch_sandboxes_single_namespace().await + } + } + + async fn watch_sandboxes_single_namespace(&self) -> Result { let namespace = self.config.namespace.clone(); let agent_sandbox_api = self - .supported_agent_sandbox_api(self.watch_client.clone()) + .supported_agent_sandbox_api(self.watch_client.clone(), &self.config.namespace) .await?; let event_api: Api = Api::namespaced(self.watch_client.clone(), &namespace); let watcher_config = watcher::Config::default().labels(&openshell_sandbox_label_selector()); - let mut sandbox_stream = watcher::watcher(agent_sandbox_api.api, watcher_config).boxed(); - let mut event_stream = watcher::watcher(event_api, watcher::Config::default()).boxed(); + let mut sandbox_stream = recovering_watcher_stream( + watcher::watcher(agent_sandbox_api.api, watcher_config), + "sandbox-resource", + ) + .boxed(); + let mut event_stream = recovering_watcher_stream( + watcher::watcher(event_api, watcher::Config::default()), + "kubernetes-event", + ) + .boxed(); let (tx, rx) = mpsc::channel(256); tokio::spawn(async move { @@ -1053,8 +1949,8 @@ impl KubernetesComputeDriver { loop { tokio::select! { - result = sandbox_stream.try_next() => match result { - Ok(Some(Event::Applied(obj))) => { + event = sandbox_stream.next() => match event { + Some(Event::Applied(obj)) => { if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); let event = WatchSandboxesEvent { @@ -1067,7 +1963,7 @@ impl KubernetesComputeDriver { } } } - Ok(Some(Event::Deleted(obj))) => { + Some(Event::Deleted(obj)) => { if is_openshell_managed(&obj) && let Ok(sandbox_id) = sandbox_id_from_object(&obj) { @@ -1082,7 +1978,7 @@ impl KubernetesComputeDriver { } } } - Ok(Some(Event::Restarted(objs))) => { + Some(Event::Restarted(objs)) => { for obj in objs { if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); @@ -1097,19 +1993,15 @@ impl KubernetesComputeDriver { } } } - Ok(None) => { + None => { let _ = tx.send(Err(KubernetesDriverError::Message( "sandbox watcher stream ended unexpectedly".to_string() ))).await; break; } - Err(err) => { - let _ = tx.send(Err(KubernetesDriverError::Message(err.to_string()))).await; - break; - } }, - result = event_stream.try_next() => match result { - Ok(Some(Event::Applied(obj))) => { + event = event_stream.next() => match event { + Some(Event::Applied(obj)) => { if let Some((sandbox_id, event)) = map_kube_event_to_platform( &sandbox_name_to_id, &agent_pod_to_id, @@ -1125,27 +2017,158 @@ impl KubernetesComputeDriver { } } } - Ok(Some(Event::Deleted(_))) => {} - Ok(Some(Event::Restarted(_))) => { + Some(Event::Deleted(_)) => {} + Some(Event::Restarted(_)) => { debug!(namespace = %namespace, "Kubernetes event watcher restarted"); } - Ok(None) => { + None => { let _ = tx.send(Err(KubernetesDriverError::Message( "kubernetes event watcher stream ended".to_string() ))).await; break; } - Err(err) => { - let _ = tx.send(Err(KubernetesDriverError::Message(err.to_string()))).await; - break; - } }, () = tx.closed() => break, } } - }); + }); + + Ok(Box::pin(ReceiverStream::new(rx))) + } + + async fn watch_sandboxes_cluster_wide(&self) -> Result { + let sandbox_api_version = self + .supported_sandbox_api_version(self.watch_client.clone()) + .await?; + let cluster_api = + Self::cluster_wide_sandbox_api(self.watch_client.clone(), sandbox_api_version); + let selector = self.openshell_sandbox_selector(); + let watcher_config = watcher::Config::default().labels(&selector); + let sandbox_stream = recovering_watcher_stream( + watcher::watcher(cluster_api.api, watcher_config), + "sandbox-resource", + ) + .boxed(); + + Ok(cluster_wide_watch_stream( + sandbox_stream, + self.config.namespace.clone(), + )) + } +} + +fn cluster_wide_watch_stream(mut sandbox_stream: S, default_namespace: String) -> WatchStream +where + S: Stream> + Send + Unpin + 'static, +{ + let (tx, rx) = mpsc::channel(256); + + tokio::spawn(async move { + loop { + tokio::select! { + event = sandbox_stream.next() => match event { + Some(Event::Applied(obj)) => { + let ns = obj.metadata.namespace.clone() + .unwrap_or_else(|| default_namespace.clone()); + if let Ok((_kube_name, sandbox)) = sandbox_from_object(&ns, obj) { + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Deleted(obj)) => { + if is_openshell_managed(&obj) + && let Ok(sandbox_id) = sandbox_id_from_object(&obj) + { + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Restarted(objs)) => { + for obj in objs { + let ns = obj.metadata.namespace.clone() + .unwrap_or_else(|| default_namespace.clone()); + if let Ok((_kube_name, sandbox)) = sandbox_from_object(&ns, obj) { + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + return; + } + } + } + } + None => { + let _ = tx.send(Err(KubernetesDriverError::Message( + "sandbox watcher stream ended unexpectedly".to_string() + ))).await; + break; + } + }, + () = tx.closed() => break, + } + } + }); + + Box::pin(ReceiverStream::new(rx)) +} + +fn recovering_watcher_stream( + stream: S, + watcher: &'static str, +) -> impl Stream> +where + S: Stream, E>>, + E: std::fmt::Display, +{ + continue_on_watcher_errors(stream.default_backoff(), watcher) +} + +/// Drop kube-runtime watcher errors after logging them so continued polling can +/// drive its built-in relist and recovery state machine. The production adapter +/// above applies backoff first to avoid hot-looping on persistent API failures. +fn continue_on_watcher_errors( + stream: S, + watcher: &'static str, +) -> impl Stream> +where + S: Stream, E>>, + E: std::fmt::Display, +{ + stream.filter_map(move |result| { + futures::future::ready(match result { + Ok(event) => Some(event), + Err(err) => { + warn!( + watcher, + error = %err, + "Kubernetes watcher stream error; waiting for kube-runtime recovery" + ); + None + } + }) + }) +} - Ok(Box::pin(ReceiverStream::new(rx))) +fn add_trace_context_annotation(annotations: &mut BTreeMap) { + let Some(carrier) = openshell_otel::current_trace_context_carrier() else { + return; + }; + if let Ok(value) = serde_json::to_string(&carrier) { + annotations.insert(AGENT_SANDBOX_TRACE_CONTEXT_ANNOTATION.to_string(), value); } } @@ -1165,10 +2188,6 @@ fn validate_gpu_request( Ok(()) } -fn kube_resource_name(workspace: &str, name: &str) -> String { - format!("{workspace}--{name}") -} - const MAX_KUBE_NAME_LEN: usize = 63; fn validate_kube_resource_name_length(workspace: &str, name: &str) -> Result<(), tonic::Status> { @@ -1182,7 +2201,48 @@ fn validate_kube_resource_name_length(workspace: &str, name: &str) -> Result<(), Ok(()) } -fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap { +fn is_namespace_owned_by_gateway( + labels: Option<&BTreeMap>, + gateway_id: &str, +) -> bool { + labels + .and_then(|l| l.get(LABEL_MANAGED_BY)) + .is_some_and(|v| v == LABEL_MANAGED_BY_VALUE) + && labels + .and_then(|l| l.get(LABEL_GATEWAY_ID)) + .is_some_and(|v| v == gateway_id) +} + +fn gateway_id_label_needs_backfill( + labels: Option<&BTreeMap>, + gateway_id: &str, +) -> bool { + labels + .and_then(|labels| labels.get(LABEL_GATEWAY_ID)) + .is_none_or(|value| value != gateway_id) +} + +fn namespace_delete_params(uid: String) -> DeleteParams { + DeleteParams::default().preconditions(Preconditions { + uid: Some(uid), + resource_version: None, + }) +} + +fn sandbox_lookup_selector_for(sandbox_id: &str, gateway_id: &str) -> String { + format!( + "{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_SANDBOX_ID}={sandbox_id},{LABEL_GATEWAY_ID}={gateway_id}" + ) +} + +fn openshell_sandbox_selector_for(gateway_id: &str) -> String { + use std::fmt::Write; + let mut selector = openshell_sandbox_label_selector(); + write!(selector, ",{LABEL_GATEWAY_ID}={gateway_id}").unwrap(); + selector +} + +fn sandbox_labels(sandbox: &Sandbox, gateway_id: Option<&str>) -> BTreeMap { let mut labels = BTreeMap::new(); labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); @@ -1194,9 +2254,76 @@ fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap { LABEL_MANAGED_BY.to_string(), LABEL_MANAGED_BY_VALUE.to_string(), ); + if let Some(gw_id) = gateway_id { + labels.insert(LABEL_GATEWAY_ID.to_string(), gw_id.to_string()); + } labels } +fn managed_ssh_network_policy(namespace: &str, config: &KubernetesComputeConfig) -> NetworkPolicy { + NetworkPolicy { + metadata: ObjectMeta { + name: Some(MANAGED_SSH_NETWORK_POLICY_NAME.to_string()), + namespace: Some(namespace.to_string()), + labels: Some(BTreeMap::from([( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + )])), + ..Default::default() + }, + spec: Some(NetworkPolicySpec { + pod_selector: LabelSelector { + match_labels: Some(BTreeMap::from([( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + )])), + ..Default::default() + }, + policy_types: Some(vec!["Ingress".to_string()]), + ingress: Some(vec![NetworkPolicyIngressRule { + from: Some(vec![NetworkPolicyPeer { + namespace_selector: Some(LabelSelector { + match_labels: Some(BTreeMap::from([( + "kubernetes.io/metadata.name".to_string(), + config.managed_ssh_ingress.gateway_namespace.clone(), + )])), + ..Default::default() + }), + pod_selector: Some(LabelSelector { + match_labels: Some(config.managed_ssh_ingress.gateway_pod_selector.clone()), + ..Default::default() + }), + ..Default::default() + }]), + ports: Some(vec![NetworkPolicyPort { + port: Some(IntOrString::Int(2222)), + protocol: Some("TCP".to_string()), + ..Default::default() + }]), + }]), + ..Default::default() + }), + status: None, + } +} + +fn image_pull_secret_copy(secret_name: &str, namespace: &str, source: Secret) -> Secret { + Secret { + metadata: ObjectMeta { + name: Some(secret_name.to_string()), + namespace: Some(namespace.to_string()), + labels: Some(BTreeMap::from([( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + )])), + ..Default::default() + }, + data: source.data, + type_: source.type_, + ..Default::default() + } +} + fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap { let mut annotations = BTreeMap::new(); annotations.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); @@ -1222,6 +2349,161 @@ fn sandbox_id_from_object(obj: &DynamicObject) -> Result { Err("sandbox id not found on object".to_string()) } +#[derive(Debug)] +struct TokenReviewIdentity { + namespace: String, + pod_name: String, + pod_uid: String, +} + +#[allow(clippy::result_large_err)] +fn token_review_identity( + status: &TokenReviewStatus, + expected_service_account: &str, +) -> Result, tonic::Status> { + if status.authenticated != Some(true) { + return Ok(None); + } + if !status + .audiences + .as_deref() + .unwrap_or_default() + .iter() + .any(|audience| audience == SANDBOX_TOKEN_AUDIENCE) + { + return Err(tonic::Status::unauthenticated( + "sandbox credential audience not accepted", + )); + } + let user = status + .user + .as_ref() + .ok_or_else(|| tonic::Status::permission_denied("TokenReview response missing user"))?; + let rest = user + .username + .as_deref() + .unwrap_or_default() + .strip_prefix("system:serviceaccount:") + .ok_or_else(|| tonic::Status::permission_denied("credential is not a service account"))?; + let (namespace, service_account) = rest + .split_once(':') + .filter(|(namespace, service_account)| !namespace.is_empty() && !service_account.is_empty()) + .ok_or_else(|| tonic::Status::permission_denied("invalid service account identity"))?; + if service_account != expected_service_account { + return Err(tonic::Status::permission_denied( + "credential is not from the configured sandbox service account", + )); + } + Ok(Some(TokenReviewIdentity { + namespace: namespace.to_string(), + pod_name: user_extra_one(user, POD_NAME_EXTRA)?, + pod_uid: user_extra_one(user, POD_UID_EXTRA)?, + })) +} + +#[allow(clippy::result_large_err)] +fn user_extra_one(user: &UserInfo, key: &str) -> Result { + let values = user + .extra + .as_ref() + .and_then(|extra| extra.get(key)) + .ok_or_else(|| tonic::Status::permission_denied("sandbox credential is not pod-bound"))?; + if values.len() != 1 || values[0].is_empty() { + return Err(tonic::Status::permission_denied( + "sandbox credential has invalid pod binding", + )); + } + Ok(values[0].clone()) +} + +#[allow(clippy::result_large_err)] +fn pod_sandbox_id(pod: &Pod) -> Result { + pod.metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(LABEL_SANDBOX_ID)) + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(|| tonic::Status::permission_denied("pod is not bound to a sandbox identity")) +} + +#[allow(clippy::result_large_err)] +fn validate_pod_uid(pod: &Pod, expected_uid: &str) -> Result<(), tonic::Status> { + if pod.metadata.uid.as_deref() == Some(expected_uid) { + return Ok(()); + } + Err(tonic::Status::permission_denied( + "sandbox credential pod UID mismatch", + )) +} + +#[allow(clippy::result_large_err)] +fn sandbox_owner_reference(pod: &Pod) -> Result<&OwnerReference, tonic::Status> { + let mut owners = pod + .metadata + .owner_references + .as_deref() + .unwrap_or_default() + .iter() + .filter(|owner| { + owner.kind == SANDBOX_KIND + && matches!( + owner.api_version.as_str(), + "agents.x-k8s.io/v1beta1" | "agents.x-k8s.io/v1alpha1" + ) + }); + let owner = owners + .next() + .ok_or_else(|| tonic::Status::permission_denied("pod is not controlled by a Sandbox"))?; + if owners.next().is_some() + || owner.controller != Some(true) + || owner.name.is_empty() + || owner.uid.is_empty() + { + return Err(tonic::Status::permission_denied( + "pod has an invalid Sandbox owner", + )); + } + Ok(owner) +} + +#[allow(clippy::result_large_err)] +fn validate_sandbox_owner_identity( + owner: &OwnerReference, + sandbox_id: &str, + sandbox: &DynamicObject, +) -> Result<(), tonic::Status> { + let uid_matches = sandbox.metadata.uid.as_deref() == Some(owner.uid.as_str()); + let sandbox_id_matches = sandbox + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(LABEL_SANDBOX_ID)) + .is_some_and(|actual| actual == sandbox_id); + if uid_matches && sandbox_id_matches { + return Ok(()); + } + Err(tonic::Status::permission_denied( + "pod identity does not match its Sandbox owner", + )) +} + +fn accepts_auth_namespace( + config: &KubernetesComputeConfig, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + namespace: &str, +) -> bool { + match config.workspace_mode { + WorkspaceMode::Shared => namespace == config.namespace, + WorkspaceMode::Managed => { + namespace.starts_with(&managed_namespace_prefix(&config.gateway_id)) + } + WorkspaceMode::Operator => { + operator_allowlist.is_some_and(|allowlist| allowlist.contains(namespace)) + } + } +} + fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option { obj.metadata .annotations @@ -1432,8 +2714,8 @@ const BINARY_AWARE_SIDECAR_PROXY_UID: u32 = 0; /// Shared volume used by the network sidecar and process-only supervisor for /// local coordination in sidecar topology. const SIDECAR_STATE_VOLUME_NAME: &str = "openshell-sidecar-state"; -const SIDECAR_STATE_MOUNT_PATH: &str = "/run/openshell-sidecar"; -const SIDECAR_CONTROL_SOCKET: &str = "/run/openshell-sidecar/control.sock"; +const SIDECAR_STATE_MOUNT_PATH: &str = openshell_core::container_paths::SIDECAR_RUN_ROOT; +const SIDECAR_CONTROL_SOCKET: &str = openshell_core::container_paths::SIDECAR_CONTROL_SOCKET; // Linux abstract socket names are scoped to the pod's shared network namespace. // Unlike a filesystem socket in the shared state volume, the workload cannot // unlink and replace this relay endpoint after the trusted supervisor binds it. @@ -1442,8 +2724,8 @@ const SIDECAR_SSH_SOCKET_FILE: &str = "@openshell-sidecar-ssh"; /// Shared TLS work directory. The network sidecar writes the proxy CA bundle /// here, while the agent container consumes it after sidecar bootstrap. const SIDECAR_TLS_VOLUME_NAME: &str = "openshell-supervisor-tls"; -const SIDECAR_TLS_MOUNT_PATH: &str = "/etc/openshell-tls/proxy"; -const SIDECAR_CLIENT_TLS_MOUNT_PATH: &str = "/etc/openshell-tls/proxy/client"; +const SIDECAR_TLS_MOUNT_PATH: &str = openshell_core::container_paths::SIDECAR_TLS_DIR; +const SIDECAR_CLIENT_TLS_MOUNT_PATH: &str = openshell_core::container_paths::SIDECAR_CLIENT_TLS_DIR; /// Build the emptyDir volume that holds the supervisor binary. /// @@ -1569,19 +2851,20 @@ fn apply_supervisor_binary_source( /// side-loaded binary as root so it can create network namespaces, set up the /// proxy, and configure Landlock/seccomp. #[allow(clippy::similar_names)] -fn apply_supervisor_sideload( +fn apply_supervisor_sideload_with_params( pod_template: &mut serde_json::Value, - supervisor_image: &str, - supervisor_image_pull_policy: &str, - method: SupervisorSideloadMethod, - sandbox_uid: u32, - sandbox_gid: u32, + params: &SandboxPodParams<'_>, ) { let Some(spec) = pod_template.get_mut("spec").and_then(|v| v.as_object_mut()) else { return; }; - apply_supervisor_binary_source(spec, supervisor_image, supervisor_image_pull_policy, method); + apply_supervisor_binary_source( + spec, + params.supervisor_image, + params.supervisor_image_pull_policy, + params.supervisor_sideload_method, + ); // Find the agent container and add volume mount + command override let Some(containers) = spec.get_mut("containers").and_then(|v| v.as_array_mut()) else { @@ -1599,10 +2882,13 @@ fn apply_supervisor_sideload( if let Some(container) = containers.get_mut(index).and_then(|v| v.as_object_mut()) { // Override command to use the side-loaded supervisor binary - container.insert( - "command".to_string(), - serde_json::json!([format!("{}/openshell-sandbox", SUPERVISOR_MOUNT_PATH)]), - ); + let mut command = vec![ + format!("{}/openshell-sandbox", SUPERVISOR_MOUNT_PATH), + "--workdir".to_string(), + driver_mounts::DEFAULT_WORKSPACE_ROOT.to_string(), + ]; + command.extend(upstream_proxy_cli_args(params)); + container.insert("command".to_string(), serde_json::json!(command)); // Force the supervisor to run as root (UID 0). Sandbox images may set // a non-root USER directive (e.g. `USER sandbox`), but the supervisor @@ -1633,11 +2919,90 @@ fn apply_supervisor_sideload( .or_insert_with(|| serde_json::json!([])) .as_array_mut(); if let Some(env) = env { - apply_resolved_identity_env(env, sandbox_uid, sandbox_gid); + apply_resolved_identity_env(env, params.sandbox_uid, params.sandbox_gid); + } + if has_upstream_proxy_credentials(params) { + let volume_mounts = container + .entry("volumeMounts") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut(); + if let Some(volume_mounts) = volume_mounts { + volume_mounts.push(upstream_proxy_auth_volume_mount()); + } } } } +#[cfg(test)] +#[allow(clippy::similar_names)] +fn apply_supervisor_sideload( + pod_template: &mut serde_json::Value, + supervisor_image: &str, + supervisor_image_pull_policy: &str, + method: SupervisorSideloadMethod, + sandbox_uid: u32, + sandbox_gid: u32, +) { + let params = SandboxPodParams { + supervisor_image, + supervisor_image_pull_policy, + supervisor_sideload_method: method, + sandbox_uid, + sandbox_gid, + ..SandboxPodParams::default() + }; + apply_supervisor_sideload_with_params(pod_template, ¶ms); +} + +fn upstream_proxy_cli_args(params: &SandboxPodParams<'_>) -> Vec { + let mut args = Vec::new(); + if let Some(url) = params.https_proxy { + args.extend(["--upstream-proxy".to_string(), url.to_string()]); + } + if let Some(list) = params.no_proxy { + args.extend(["--upstream-no-proxy".to_string(), list.to_string()]); + } + if has_upstream_proxy_credentials(params) { + args.extend([ + "--upstream-proxy-auth-file".to_string(), + openshell_core::container_paths::UPSTREAM_PROXY_AUTH_MOUNT_PATH.to_string(), + ]); + } + if params.proxy_auth_allow_insecure { + args.push("--upstream-proxy-auth-allow-insecure".to_string()); + } + if params.proxy_connect_by_hostname { + args.push("--upstream-proxy-connect-by-hostname".to_string()); + } + args +} + +fn upstream_proxy_auth_volume_mount() -> serde_json::Value { + serde_json::json!({ + "name": UPSTREAM_PROXY_AUTH_VOLUME_NAME, + "mountPath": upstream_proxy_auth_volume_mount_path(), + "readOnly": true, + }) +} + +fn upstream_proxy_auth_volume_mount_path() -> &'static str { + Path::new(openshell_core::container_paths::UPSTREAM_PROXY_AUTH_MOUNT_PATH) + .parent() + .and_then(Path::to_str) + .expect("upstream proxy auth path has a parent directory") +} + +fn upstream_proxy_auth_file_name() -> &'static str { + Path::new(openshell_core::container_paths::UPSTREAM_PROXY_AUTH_MOUNT_PATH) + .file_name() + .and_then(|name| name.to_str()) + .expect("upstream proxy auth path has a UTF-8 file name") +} + +fn has_upstream_proxy_credentials(params: &SandboxPodParams<'_>) -> bool { + params.proxy_auth_secret_name.is_some() && params.proxy_auth_secret_key.is_some() +} + fn sidecar_state_volume_mount() -> serde_json::Value { serde_json::json!({ "name": SIDECAR_STATE_VOLUME_NAME, @@ -1775,6 +3140,14 @@ fn supervisor_sidecar_container( } ] }); + container["command"] + .as_array_mut() + .expect("network supervisor command is an array") + .extend( + upstream_proxy_cli_args(params) + .into_iter() + .map(serde_json::Value::String), + ); if !params.supervisor_image_pull_policy.is_empty() { container["imagePullPolicy"] = serde_json::json!(params.supervisor_image_pull_policy); } @@ -1788,6 +3161,12 @@ fn supervisor_sidecar_container( "readOnly": true, })); } + if has_upstream_proxy_credentials(params) { + container["volumeMounts"] + .as_array_mut() + .expect("volumeMounts is an array") + .push(upstream_proxy_auth_volume_mount()); + } if let Some(profile) = params.app_armor_profile { container["securityContext"]["appArmorProfile"] = app_armor_profile_to_k8s(profile); } @@ -1841,7 +3220,7 @@ fn supervisor_network_init_container(params: &SandboxPodParams<'_>) -> serde_jso .expect("volumeMounts is an array") .push(serde_json::json!({ "name": "openshell-client-tls", - "mountPath": "/etc/openshell-tls/client", + "mountPath": openshell_core::container_paths::CLIENT_TLS_DIR, "readOnly": true })); } @@ -1917,7 +3296,9 @@ fn apply_supervisor_sidecar_topology( "command".to_string(), serde_json::json!([ format!("{}/openshell-sandbox", SUPERVISOR_MOUNT_PATH), - "--mode=process" + "--mode=process", + "--workdir", + driver_mounts::DEFAULT_WORKSPACE_ROOT ]), ); @@ -2166,6 +3547,7 @@ fn default_workspace_volume_claim_templates( } /// Parameters shared by `sandbox_to_k8s_spec` and `sandbox_template_to_k8s`. +#[allow(clippy::struct_excessive_bools)] struct SandboxPodParams<'a> { default_image: &'a str, image_pull_policy: &'a str, @@ -2176,6 +3558,12 @@ struct SandboxPodParams<'a> { topology: SupervisorTopology, proxy_uid: u32, process_binary_aware_network_policy: bool, + https_proxy: Option<&'a str>, + no_proxy: Option<&'a str>, + proxy_auth_secret_name: Option<&'a str>, + proxy_auth_secret_key: Option<&'a str>, + proxy_auth_allow_insecure: bool, + proxy_connect_by_hostname: bool, service_account_name: &'a str, sandbox_id: &'a str, sandbox_name: &'a str, @@ -2211,6 +3599,12 @@ impl Default for SandboxPodParams<'_> { topology: SupervisorTopology::default(), proxy_uid: DEFAULT_PROXY_UID, process_binary_aware_network_policy: true, + https_proxy: None, + no_proxy: None, + proxy_auth_secret_name: None, + proxy_auth_secret_key: None, + proxy_auth_allow_insecure: false, + proxy_connect_by_hostname: false, service_account_name: DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, sandbox_id: "", sandbox_name: "", @@ -2302,6 +3696,7 @@ fn sandbox_to_k8s_spec( template, driver_gpu_requirements(spec.resource_requirements.as_ref()), &pod_env, + Some(spec), &driver_config, inject_workspace, params, @@ -2335,6 +3730,7 @@ fn sandbox_to_k8s_spec( &SandboxTemplate::default(), driver_gpu_requirements(spec.and_then(|s| s.resource_requirements.as_ref())), &pod_env, + spec, &driver_config, inject_workspace, params, @@ -2362,6 +3758,7 @@ fn sandbox_template_to_k8s( template, gpu_requirements.as_ref(), spec_environment, + None, &driver_config, inject_workspace, params, @@ -2382,6 +3779,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( template, gpu_requirements, spec_environment, + None, &driver_config, inject_workspace, params, @@ -2392,6 +3790,7 @@ fn sandbox_template_to_k8s_with_validated_config( template: &SandboxTemplate, gpu_requirements: Option<&GpuResourceRequirements>, spec_environment: &std::collections::HashMap, + sandbox_spec: Option<&openshell_core::proto::compute::v1::DriverSandboxSpec>, driver_config: &KubernetesSandboxDriverConfig, inject_workspace: bool, params: &SandboxPodParams<'_>, @@ -2431,7 +3830,7 @@ fn sandbox_template_to_k8s_with_validated_config( .unwrap_or_default(); if !params.sandbox_id.is_empty() { pod_annotations.insert( - "openshell.io/sandbox-id".to_string(), + LABEL_SANDBOX_ID.to_string(), serde_json::Value::String(params.sandbox_id.to_string()), ); } @@ -2466,9 +3865,14 @@ fn sandbox_template_to_k8s_with_validated_config( } apply_pod_driver_config(&mut spec, &driver_config.pod); - // Per-sandbox platform_config.host_users overrides the cluster-wide default. - let use_user_namespaces = platform_config_bool(template, "host_users") - .map_or(params.enable_user_namespaces, |host_users| !host_users); + // Per-sandbox portable intent overrides the cluster-wide default. This + // driver owns the Kubernetes-specific `hostUsers` translation. Accept the + // former platform_config encoding during rolling upgrades from gateways + // that predate the typed field. + let use_user_namespaces = template + .user_namespaces + .or_else(|| platform_config_bool(template, "host_users").map(|host_users| !host_users)) + .unwrap_or(params.enable_user_namespaces); if use_user_namespaces { spec.insert("hostUsers".to_string(), serde_json::json!(false)); @@ -2501,6 +3905,9 @@ fn sandbox_template_to_k8s_with_validated_config( "automountServiceAccountToken".to_string(), serde_json::json!(false), ); + // Do not let kubelet replace the canonical main-process generation after + // the supervisor exits. The gateway records that exit as terminal Error. + spec.insert("restartPolicy".to_string(), serde_json::json!("Never")); let mut container = serde_json::Map::new(); container.insert("name".to_string(), serde_json::json!("agent")); @@ -2525,6 +3932,7 @@ fn sandbox_template_to_k8s_with_validated_config( None, &template.environment, spec_environment, + sandbox_spec, params.sandbox_id, params.sandbox_name, params.grpc_endpoint, @@ -2560,7 +3968,7 @@ fn sandbox_template_to_k8s_with_validated_config( if !params.client_tls_secret_name.is_empty() { volume_mounts.push(serde_json::json!({ "name": CLIENT_TLS_VOLUME_NAME, - "mountPath": "/etc/openshell-tls/client", + "mountPath": openshell_core::container_paths::CLIENT_TLS_DIR, "readOnly": true })); } @@ -2616,6 +4024,32 @@ fn sandbox_template_to_k8s_with_validated_config( } })); } + if has_upstream_proxy_credentials(params) { + let secret_name = params + .proxy_auth_secret_name + .expect("complete proxy credential reference has a Secret name"); + let secret_key = params + .proxy_auth_secret_key + .expect("complete proxy credential reference has a Secret key"); + // The credential volume is mounted only into the container that runs + // network supervision. Sidecar mode uses the pod fsGroup already + // required for its non-root network supervisor. + let default_mode = match params.topology { + SupervisorTopology::Combined => 0o400, + SupervisorTopology::Sidecar => 0o440, + }; + volumes.push(serde_json::json!({ + "name": UPSTREAM_PROXY_AUTH_VOLUME_NAME, + "secret": { + "secretName": secret_name, + "defaultMode": default_mode, + "items": [{ + "key": secret_key, + "path": upstream_proxy_auth_file_name(), + }] + } + })); + } if params.provider_spiffe_enabled { volumes.push(serde_json::json!({ "name": SPIFFE_WORKLOAD_API_VOLUME_NAME, @@ -2676,14 +4110,7 @@ fn sandbox_template_to_k8s_with_validated_config( match params.topology { SupervisorTopology::Combined => { - apply_supervisor_sideload( - &mut result, - params.supervisor_image, - params.supervisor_image_pull_policy, - params.supervisor_sideload_method, - params.sandbox_uid, - params.sandbox_gid, - ); + apply_supervisor_sideload_with_params(&mut result, params); } SupervisorTopology::Sidecar => { apply_supervisor_sidecar_topology( @@ -2875,6 +4302,7 @@ fn build_env_list( existing_env: Option<&Vec>, template_environment: &std::collections::HashMap, spec_environment: &std::collections::HashMap, + sandbox_spec: Option<&openshell_core::proto::compute::v1::DriverSandboxSpec>, sandbox_id: &str, sandbox_name: &str, grpc_endpoint: &str, @@ -2896,6 +4324,14 @@ fn build_env_list( &json, ); } + let main_process = + openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(sandbox_spec) + .expect("main process config serialization cannot fail"); + upsert_env( + &mut env, + openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + &main_process, + ); apply_required_env( &mut env, sandbox_id, @@ -2933,13 +4369,15 @@ fn apply_required_env( upsert_env(env, openshell_core::sandbox_env::ENDPOINT, grpc_endpoint); upsert_env( env, - openshell_core::sandbox_env::SANDBOX_COMMAND, - "sleep infinity", + openshell_core::sandbox_env::TELEMETRY_ENABLED, + openshell_core::telemetry::enabled_env_value(), ); + // Runtime capabilities are driver-owned. Kubernetes topologies do not yet + // provide the complete policy DNS and transparent TCP substrate. upsert_env( env, - openshell_core::sandbox_env::TELEMETRY_ENABLED, - openshell_core::telemetry::enabled_env_value(), + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, + "", ); if !ssh_socket_path.is_empty() { upsert_env( @@ -3050,7 +4488,7 @@ fn platform_config_bool(template: &SandboxTemplate, key: &str) -> Option { let config = template.platform_config.as_ref()?; let value = config.fields.get(key)?; match value.kind.as_ref() { - Some(prost_types::value::Kind::BoolValue(b)) => Some(*b), + Some(prost_types::value::Kind::BoolValue(value)) => Some(*value), _ => None, } } @@ -3111,6 +4549,124 @@ fn status_from_object(obj: &DynamicObject) -> Option { }) } +fn kubernetes_sandbox_has_stopped_condition(obj: &DynamicObject) -> bool { + obj.data + .get("status") + .and_then(|status| status.get("conditions")) + .and_then(serde_json::Value::as_array) + .is_some_and(|conditions| { + conditions.iter().any(|condition| { + condition.get("type").and_then(serde_json::Value::as_str) + == Some(SANDBOX_SUSPENDED_CONDITION) + && condition + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| status.eq_ignore_ascii_case("true")) + }) + }) +} + +fn kubernetes_sandbox_stop_is_complete( + api_version: &str, + obj: &DynamicObject, + pod_is_gone: bool, +) -> bool { + if api_version == SANDBOX_VERSION_V1ALPHA1 { + // v1alpha1 omits a usable stopped condition. + pod_is_gone + } else { + kubernetes_sandbox_has_stopped_condition(obj) && pod_is_gone + } +} + +fn kubernetes_sandbox_stop_failure(obj: &DynamicObject) -> Option { + obj.data + .get("status")? + .get("conditions")? + .as_array()? + .iter() + .find_map(|condition| { + let is_terminal = condition.get("type").and_then(serde_json::Value::as_str) + == Some(SANDBOX_SUSPENDED_CONDITION) + && condition + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| status.eq_ignore_ascii_case("false")) + && condition.get("reason").and_then(serde_json::Value::as_str) + == Some(SANDBOX_SUSPENDED_POD_NOT_OWNED_REASON); + if !is_terminal { + return None; + } + + let message = condition + .get("message") + .and_then(serde_json::Value::as_str) + .filter(|message| !message.is_empty()) + .unwrap_or("backing pod is not owned by this sandbox"); + Some(format!("Kubernetes sandbox stop rejected: {message}")) + }) +} + +async fn kubernetes_sandbox_pod_is_gone( + pod_api: &Api, + pod_name: &str, + deadline: tokio::time::Instant, +) -> Result { + let request_timeout = + KUBE_API_TIMEOUT.min(deadline.saturating_duration_since(tokio::time::Instant::now())); + if request_timeout.is_zero() { + return Ok(false); + } + + match tokio::time::timeout(request_timeout, pod_api.get(pod_name)).await { + Ok(Ok(_)) => Ok(false), + Ok(Err(KubeError::Api(err))) if err.code == 404 => Ok(true), + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!( + "timed out after {}s waiting for Kubernetes API while checking sandbox pod termination", + request_timeout.as_secs() + )), + } +} + +fn kubernetes_sandbox_stop_timeout(obj: &DynamicObject) -> Duration { + let termination_grace_period = obj + .data + .get("spec") + .and_then(|spec| spec.get("podTemplate")) + .and_then(|template| template.get("spec")) + .and_then(|spec| spec.get("terminationGracePeriodSeconds")) + .and_then(serde_json::Value::as_u64) + .map_or(DEFAULT_POD_TERMINATION_GRACE_PERIOD, Duration::from_secs); + + // The controller must observe the desired state, wait for the pod grace + // period and kubelet teardown, then reconcile the deleted pod into the + // Sandbox status. Keep one API timeout of headroom around that grace. + termination_grace_period.saturating_add(KUBE_API_TIMEOUT) +} + +fn next_stop_poll_interval(current: Duration) -> Duration { + current.saturating_mul(2).min(STOP_MAX_POLL_INTERVAL) +} + +fn sandbox_operating_state_patch( + api_version: &str, + resource_version: &str, + running: bool, +) -> serde_json::Value { + if api_version == SANDBOX_VERSION_V1BETA1 { + serde_json::json!({ + "metadata": {"resourceVersion": resource_version}, + "spec": {"operatingMode": if running { "Running" } else { "Suspended" }} + }) + } else { + serde_json::json!({ + "metadata": {"resourceVersion": resource_version}, + "spec": {"replicas": i32::from(running)} + }) + } +} + fn condition_from_value(value: &serde_json::Value) -> Option { let obj = value.as_object()?; Some(SandboxCondition { @@ -3134,6 +4690,241 @@ fn condition_from_value(value: &serde_json::Value) -> Option { }) } +fn spawn_namespace_label_watcher( + client: Client, + label_selector: String, + allowlist: OperatorNamespaceAllowlist, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { + let ns_api: Api = Api::all(client); + let watcher_config = watcher::Config::default().labels(&label_selector); + let jitter_seed = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |duration| { + duration.as_secs() ^ u64::from(duration.subsec_nanos()) + }); + + tokio::spawn(async move { + let mut retry_attempt = 0; + loop { + let mut stream = watcher::watcher(ns_api.clone(), watcher_config.clone()).boxed(); + + loop { + let event = tokio::select! { + result = stream.try_next() => result, + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + return; + } + continue; + } + }; + match event { + Ok(Some(Event::Applied(ns))) => { + retry_attempt = 0; + if let Some(name) = ns.metadata.name.as_deref() + && allowlist.insert(name.to_string()) + { + info!(namespace = name, "operator namespace added to allowlist"); + } + } + Ok(Some(Event::Deleted(ns))) => { + retry_attempt = 0; + if let Some(name) = ns.metadata.name.as_deref() + && allowlist.remove(name) + { + info!( + namespace = name, + "operator namespace removed from allowlist" + ); + } + } + Ok(Some(Event::Restarted(namespaces))) => { + retry_attempt = 0; + let names: std::collections::BTreeSet = namespaces + .into_iter() + .filter_map(|ns| ns.metadata.name) + .collect(); + let count = names.len(); + allowlist.replace(names); + info!( + total = count, + "operator namespace allowlist replaced from full relist" + ); + } + Ok(None) => { + warn!("operator namespace watcher stream ended unexpectedly"); + break; + } + Err(err) => { + warn!(error = %err, "operator namespace watcher stream error"); + break; + } + } + } + + let retry_delay = namespace_watcher_retry_delay(retry_attempt, jitter_seed); + warn!(?retry_delay, "operator namespace watcher reconnecting"); + tokio::select! { + () = tokio::time::sleep(retry_delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + return; + } + } + } + retry_attempt = retry_attempt.saturating_add(1); + } + }); + + info!( + label_selector = %label_selector, + "operator namespace label watcher spawned" + ); +} + +fn namespace_watcher_retry_delay(attempt: u32, jitter_seed: u64) -> Duration { + let base_secs = 2_u64.saturating_mul(1_u64 << attempt.min(4)).min(24); + let max_jitter_secs = base_secs / 4; + let mixed_seed = + jitter_seed.wrapping_add(u64::from(attempt).wrapping_mul(0x9e37_79b9_7f4a_7c15)); + let jitter_secs = mixed_seed % (max_jitter_secs + 1); + Duration::from_secs(base_secs + jitter_secs) +} + +fn load_namespace_file(path: &Path) -> Result, String> { + let contents = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read {}: {e}", path.display()))?; + let names: Vec = serde_json::from_str(&contents) + .map_err(|e| format!("failed to parse {}: {e}", path.display()))?; + Ok(names.into_iter().collect()) +} + +fn spawn_namespace_file_watcher( + path: PathBuf, + allowlist: OperatorNamespaceAllowlist, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { + match load_namespace_file(&path) { + Ok(names) => { + let count = names.len(); + allowlist.replace(names); + info!( + path = %path.display(), + total = count, + "operator namespace allowlist loaded from file" + ); + } + Err(err) => { + warn!( + error = %err, + "failed to load initial operator namespace file, allowlist empty" + ); + } + } + + let watch_dir = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let debounce = Duration::from_secs(1); + + tokio::spawn(async move { + let (tx, mut rx) = mpsc::unbounded_channel(); + + let mut watcher = + match notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res + && matches!( + event.kind, + notify::EventKind::Modify(_) | notify::EventKind::Create(_) + ) + { + let _ = tx.send(()); + } + }) { + Ok(w) => w, + Err(e) => { + warn!( + error = %e, + "failed to start operator namespace file watcher, hot-reload disabled" + ); + return; + } + }; + + if let Err(e) = notify::Watcher::watch( + &mut watcher, + &watch_dir, + notify::RecursiveMode::NonRecursive, + ) { + warn!( + error = %e, + dir = %watch_dir.display(), + "failed to watch operator namespace file directory, hot-reload disabled" + ); + return; + } + + info!( + path = %path.display(), + "operator namespace file watcher started" + ); + + loop { + let got_event = tokio::select! { + event = rx.recv() => event.is_some(), + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + return; + } + continue; + } + }; + if !got_event { + warn!("operator namespace file watcher disconnected"); + break; + } + + loop { + tokio::select! { + () = tokio::time::sleep(debounce) => { + match load_namespace_file(&path) { + Ok(names) => { + let count = names.len(); + allowlist.replace(names); + info!( + total = count, + "operator namespace allowlist reloaded from file" + ); + } + Err(err) => { + warn!( + error = %err, + "failed to reload operator namespace file, keeping existing allowlist" + ); + } + } + break; + } + r = rx.recv() => { + if r.is_some() { + continue; + } + warn!("operator namespace file watcher disconnected"); + return; + } + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + return; + } + } + } + } + } + }); +} + #[cfg(test)] mod tests { use super::*; @@ -3143,10 +4934,81 @@ mod tests { }; use openshell_core::proto::compute::v1::{GpuResourceRequirements, ResourceRequirements}; use prost_types::{Struct, Value, value::Kind}; + use std::collections::BTreeSet; static ENV_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + #[tokio::test] + async fn tracing_create_sandbox_failure_exports_a_kubernetes_operation_span() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + let driver = KubernetesComputeDriver::new_for_test(KubernetesComputeConfig::default()); + + driver + .create_sandbox(&Sandbox::default()) + .with_subscriber(subscriber) + .await + .expect_err("missing sandbox name should fail"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "kubernetes.provision") + .expect("create operation span"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn sandbox_annotation_propagates_the_active_w3c_trace_context() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + + let annotations = tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!("kubernetes.provision"); + let _entered = span.enter(); + let mut annotations = BTreeMap::new(); + add_trace_context_annotation(&mut annotations); + annotations + }); + + let carrier: serde_json::Value = serde_json::from_str( + annotations + .get("opentelemetry.io/trace-context") + .expect("agent-sandbox trace-context annotation"), + ) + .expect("annotation should contain a JSON propagation carrier"); + let traceparent = carrier["traceparent"] + .as_str() + .expect("carrier should contain traceparent"); + assert!(traceparent.starts_with("00-")); + assert_eq!(traceparent.len(), 55); + + provider.shutdown().unwrap(); + } + fn json_struct(value: serde_json::Value) -> Struct { let serde_json::Value::Object(object) = value else { panic!("expected JSON object"); @@ -3175,6 +5037,293 @@ mod tests { }) } + fn expired_watch_error() -> watcher::Error { + watcher::Error::WatchError(kube::core::ErrorResponse { + status: "Failure".to_string(), + message: "too old resource version".to_string(), + reason: "Expired".to_string(), + code: 410, + }) + } + + #[tokio::test] + async fn sandbox_watcher_error_does_not_hide_restarted_recovery_event() { + let recovered = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("recovered-sandbox".to_string()), + ..Default::default() + }, + data: serde_json::json!({}), + }; + let source = futures::stream::iter([ + Err(expired_watch_error()), + Ok(Event::Restarted(vec![recovered])), + ]); + let mut stream = continue_on_watcher_errors(source, "sandbox-resource"); + + let event = stream + .next() + .await + .expect("410 Expired must not terminate the watcher stream"); + let Event::Restarted(objects) = event else { + panic!("expected kube-runtime recovery to emit Restarted"); + }; + assert_eq!(objects.len(), 1); + assert_eq!( + objects[0].metadata.name.as_deref(), + Some("recovered-sandbox") + ); + assert!( + stream.next().await.is_none(), + "source closure must be preserved" + ); + } + + #[tokio::test(start_paused = true)] + async fn outward_watch_stream_survives_expired_error_and_backoff_recovery() { + let recovered = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("recovered-sandbox".to_string()), + namespace: Some("recovered-namespace".to_string()), + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "sandbox-id".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "sandbox-name".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "workspace".to_string()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + let source = futures::stream::iter([ + Err(expired_watch_error()), + Ok(Event::Restarted(vec![recovered])), + ]) + .chain(futures::stream::pending()); + let sandbox_stream = recovering_watcher_stream(source, "sandbox-resource").boxed(); + let mut outward = cluster_wide_watch_stream(sandbox_stream, "default".to_string()); + + let event = outward + .next() + .await + .expect("outward stream must stay open through recovery") + .expect("recoverable watcher error must not reach the outward stream"); + let Some(watch_sandboxes_event::Payload::Sandbox(event)) = event.payload else { + panic!("expected recovered sandbox event"); + }; + let sandbox = event.sandbox.expect("sandbox payload must be populated"); + assert_eq!(sandbox.id, "sandbox-id"); + assert_eq!(sandbox.namespace, "recovered-namespace"); + + let next = outward.next(); + futures::pin_mut!(next); + assert!( + futures::poll!(next).is_pending(), + "outward stream must remain open after the recovered event" + ); + } + + #[tokio::test] + async fn kubernetes_event_watcher_error_does_not_hide_restarted_recovery_event() { + let source = futures::stream::iter([ + Err(expired_watch_error()), + Ok(Event::Restarted(vec![KubeEventObj::default()])), + ]); + let mut stream = continue_on_watcher_errors(source, "kubernetes-event"); + + let event = stream + .next() + .await + .expect("410 Expired must not terminate the watcher stream"); + let Event::Restarted(events) = event else { + panic!("expected kube-runtime recovery to emit Restarted"); + }; + assert_eq!(events.len(), 1); + assert!( + stream.next().await.is_none(), + "source closure must be preserved" + ); + } + + fn authenticated_token_review(username: &str) -> TokenReviewStatus { + TokenReviewStatus { + authenticated: Some(true), + audiences: Some(vec![SANDBOX_TOKEN_AUDIENCE.to_string()]), + user: Some(UserInfo { + username: Some(username.to_string()), + extra: Some(BTreeMap::from([ + (POD_NAME_EXTRA.to_string(), vec!["sandbox-pod".to_string()]), + (POD_UID_EXTRA.to_string(), vec!["pod-uid".to_string()]), + ])), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn token_review_uses_configured_service_account_and_pod_binding() { + let status = authenticated_token_review("system:serviceaccount:workspaces:sandbox-sa"); + let identity = token_review_identity(&status, "sandbox-sa") + .unwrap() + .expect("authenticated identity"); + assert_eq!(identity.namespace, "workspaces"); + assert_eq!(identity.pod_name, "sandbox-pod"); + assert_eq!(identity.pod_uid, "pod-uid"); + } + + #[test] + fn token_review_rejects_a_different_service_account() { + let status = authenticated_token_review("system:serviceaccount:workspaces:other"); + let error = token_review_identity(&status, "sandbox-sa").unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn token_review_rejects_wrong_audience_and_missing_pod_binding() { + let mut wrong_audience = + authenticated_token_review("system:serviceaccount:workspaces:sandbox-sa"); + wrong_audience.audiences = Some(vec!["kubernetes.default.svc".to_string()]); + let error = token_review_identity(&wrong_audience, "sandbox-sa").unwrap_err(); + assert_eq!(error.code(), tonic::Code::Unauthenticated); + + let mut missing_binding = + authenticated_token_review("system:serviceaccount:workspaces:sandbox-sa"); + missing_binding.user.as_mut().unwrap().extra = None; + let error = token_review_identity(&missing_binding, "sandbox-sa").unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn token_review_returns_none_when_not_authenticated() { + let status = TokenReviewStatus { + authenticated: Some(false), + error: Some("token rejected".to_string()), + ..Default::default() + }; + + assert!( + token_review_identity(&status, "sandbox-sa") + .unwrap() + .is_none() + ); + } + + #[test] + fn authentication_namespace_validation_covers_each_workspace_mode() { + let mut config = KubernetesComputeConfig { + namespace: "openshell".to_string(), + ..Default::default() + }; + assert!(accepts_auth_namespace(&config, None, "openshell")); + assert!(!accepts_auth_namespace(&config, None, "other")); + + config.workspace_mode = WorkspaceMode::Managed; + config.gateway_id = "gateway-a".to_string(); + assert!(accepts_auth_namespace( + &config, + None, + "openshell-gateway-a-workspace-a" + )); + assert!(!accepts_auth_namespace( + &config, + None, + "openshell-gateway-b-workspace-a" + )); + + config.workspace_mode = WorkspaceMode::Operator; + let allowlist = OperatorNamespaceAllowlist::from_set(BTreeSet::from([ + "team-a".to_string(), + "team-b".to_string(), + ])); + assert!(accepts_auth_namespace(&config, Some(&allowlist), "team-a")); + assert!(!accepts_auth_namespace(&config, Some(&allowlist), "team-c")); + assert!(!accepts_auth_namespace(&config, None, "team-a")); + } + + fn sandbox_owner_for_test(name: &str, uid: &str) -> OwnerReference { + OwnerReference { + api_version: "agents.x-k8s.io/v1beta1".to_string(), + block_owner_deletion: None, + controller: Some(true), + kind: SANDBOX_KIND.to_string(), + name: name.to_string(), + uid: uid.to_string(), + } + } + + fn sandbox_object_for_test(uid: &str, sandbox_id: &str) -> DynamicObject { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox-a", &resource); + sandbox.metadata.uid = Some(uid.to_string()); + sandbox.metadata.labels = Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + sandbox_id.to_string(), + )])); + sandbox + } + + #[test] + fn pod_identity_requires_matching_uid_annotation_and_controlling_owner() { + let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); + let pod = Pod { + metadata: ObjectMeta { + uid: Some("pod-uid-a".to_string()), + annotations: Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + "sandbox-id-a".to_string(), + )])), + owner_references: Some(vec![owner.clone()]), + ..Default::default() + }, + ..Default::default() + }; + + validate_pod_uid(&pod, "pod-uid-a").expect("matching pod UID"); + assert_eq!(pod_sandbox_id(&pod).unwrap(), "sandbox-id-a"); + assert_eq!(sandbox_owner_reference(&pod).unwrap(), &owner); + + let error = validate_pod_uid(&pod, "other-pod-uid").unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mut missing_annotation = pod.clone(); + missing_annotation.metadata.annotations = None; + let error = pod_sandbox_id(&missing_annotation).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mut non_controlling = pod; + non_controlling.metadata.owner_references.as_mut().unwrap()[0].controller = Some(false); + let error = sandbox_owner_reference(&non_controlling).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn sandbox_owner_identity_requires_matching_uid_and_sandbox_id() { + let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); + let sandbox = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-a"); + validate_sandbox_owner_identity(&owner, "sandbox-id-a", &sandbox) + .expect("matching owner identity"); + + let mismatched_owner = sandbox_object_for_test("sandbox-uid-b", "sandbox-id-a"); + let error = + validate_sandbox_owner_identity(&owner, "sandbox-id-a", &mismatched_owner).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mismatched_annotation = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-b"); + let error = validate_sandbox_owner_identity(&owner, "sandbox-id-a", &mismatched_annotation) + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + #[test] fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { let structured = kube_api_error(404, "could not find the requested resource"); @@ -3184,6 +5333,157 @@ mod tests { assert!(should_try_next_sandbox_api_version(&raw)); } + #[test] + fn lifecycle_patch_uses_version_specific_operating_state() { + let beta_stop = sandbox_operating_state_patch(SANDBOX_VERSION_V1BETA1, "42", false); + assert_eq!(beta_stop["metadata"]["resourceVersion"], "42"); + assert_eq!(beta_stop["spec"]["operatingMode"], "Suspended"); + assert!(beta_stop["spec"].get("replicas").is_none()); + + let alpha_start = sandbox_operating_state_patch(SANDBOX_VERSION_V1ALPHA1, "43", true); + assert_eq!(alpha_start["metadata"]["resourceVersion"], "43"); + assert_eq!(alpha_start["spec"]["replicas"], 1); + assert!(alpha_start["spec"].get("operatingMode").is_none()); + } + + #[test] + fn stop_timeout_includes_pod_grace_period_and_reconcile_headroom() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + + assert_eq!( + kubernetes_sandbox_stop_timeout(&sandbox), + Duration::from_secs(60), + "an omitted grace period uses the Kubernetes 30-second default" + ); + + sandbox.data = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": {"terminationGracePeriodSeconds": 45} + } + } + }); + assert_eq!( + kubernetes_sandbox_stop_timeout(&sandbox), + Duration::from_secs(75) + ); + } + + #[test] + fn stop_poll_interval_backs_off_to_cap() { + let mut interval = STOP_INITIAL_POLL_INTERVAL; + let expected = [ + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(2), + ]; + + for expected_interval in expected { + interval = next_stop_poll_interval(interval); + assert_eq!(interval, expected_interval); + } + } + + #[test] + fn stopped_status_requires_published_condition() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1ALPHA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + sandbox.data = serde_json::json!({"status": {"replicas": 0}}); + + assert!( + !kubernetes_sandbox_has_stopped_condition(&sandbox), + "v1alpha1 omits a zero status replica count on the wire; it is not a usable completion signal" + ); + + sandbox.data = serde_json::json!({ + "status": { + "conditions": [{"type": "Suspended", "status": "True"}] + } + }); + assert!(kubernetes_sandbox_has_stopped_condition(&sandbox)); + } + + #[test] + fn beta_stop_requires_suspended_condition_and_deleted_pod() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + + assert!(!kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + true, + )); + + sandbox.data = serde_json::json!({ + "status": { + "conditions": [{"type": "Suspended", "status": "True"}] + } + }); + assert!(!kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + false, + )); + assert!(kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + true, + )); + assert!(kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1ALPHA1, + &DynamicObject::new("sandbox", &resource), + true, + )); + } + + #[test] + fn stop_failure_only_rejects_terminal_suspension_condition() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + sandbox.data = serde_json::json!({ + "status": { + "conditions": [{ + "type": "Suspended", + "status": "False", + "reason": "PodNotOwned", + "message": "Refused to delete pod because it is not owned by this sandbox" + }] + } + }); + + assert_eq!( + kubernetes_sandbox_stop_failure(&sandbox).as_deref(), + Some( + "Kubernetes sandbox stop rejected: Refused to delete pod because it is not owned by this sandbox" + ) + ); + + sandbox.data["status"]["conditions"][0]["status"] = serde_json::json!("Unknown"); + sandbox.data["status"]["conditions"][0]["reason"] = serde_json::json!("PodStateUnknown"); + assert!( + kubernetes_sandbox_stop_failure(&sandbox).is_none(), + "an unknown pod state can recover on a later controller reconciliation" + ); + } + #[test] fn sandbox_api_version_probe_keeps_non_404_errors() { let err = kube_api_error(403, "sandboxes.agents.x-k8s.io is forbidden"); @@ -4056,7 +6356,8 @@ mod tests { "init container must not depend on a shell" ); - // Agent container command should be overridden to the emptyDir path + // `--workdir` is optional for standalone supervisor invocations and + // has no implicit default, so Kubernetes must pass its fixed workspace. let command = pod_template["spec"]["containers"][0]["command"] .as_array() .expect("command should be set"); @@ -4064,6 +6365,16 @@ mod tests { command[0].as_str().unwrap(), format!("{SUPERVISOR_MOUNT_PATH}/openshell-sandbox") ); + assert_eq!( + command, + serde_json::json!([ + format!("{SUPERVISOR_MOUNT_PATH}/openshell-sandbox"), + "--workdir", + driver_mounts::DEFAULT_WORKSPACE_ROOT + ]) + .as_array() + .unwrap() + ); // Agent volume mount should be read-only let mounts = pod_template["spec"]["containers"][0]["volumeMounts"] @@ -4211,7 +6522,9 @@ mod tests { agent["command"], serde_json::json!([ format!("{SUPERVISOR_MOUNT_PATH}/openshell-sandbox"), - "--mode=process" + "--mode=process", + "--workdir", + driver_mounts::DEFAULT_WORKSPACE_ROOT ]) ); assert_eq!(agent["securityContext"]["runAsUser"], 1500); @@ -5305,15 +7618,7 @@ mod tests { #[test] fn user_namespaces_per_sandbox_override_enables() { let template = SandboxTemplate { - platform_config: Some(Struct { - fields: std::iter::once(( - "host_users".to_string(), - Value { - kind: Some(Kind::BoolValue(false)), - }, - )) - .collect(), - }), + user_namespaces: Some(true), ..SandboxTemplate::default() }; @@ -5329,7 +7634,7 @@ mod tests { assert_eq!( pod_template["spec"]["hostUsers"], serde_json::json!(false), - "per-sandbox host_users: false must enable user namespaces" + "per-sandbox user namespace intent must set hostUsers: false" ); let caps = pod_template["spec"]["containers"][0]["securityContext"]["capabilities"]["add"] .as_array() @@ -5339,23 +7644,53 @@ mod tests { #[test] fn user_namespaces_per_sandbox_override_disables() { + let template = SandboxTemplate { + user_namespaces: Some(false), + ..SandboxTemplate::default() + }; + + let params = SandboxPodParams { + enable_user_namespaces: true, // cluster default is on + ..Default::default() + }; + let pod_template = sandbox_template_to_k8s( + &template, + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ); + + assert!( + pod_template["spec"]["hostUsers"].is_null(), + "per-sandbox user namespace intent must override the cluster default" + ); + let caps = pod_template["spec"]["containers"][0]["securityContext"]["capabilities"]["add"] + .as_array() + .unwrap(); + assert_eq!( + caps.len(), + 4, + "extra capabilities must not be added when user namespaces are disabled" + ); + } + + #[test] + fn user_namespaces_accepts_legacy_host_users_encoding() { let template = SandboxTemplate { platform_config: Some(Struct { fields: std::iter::once(( "host_users".to_string(), Value { - kind: Some(Kind::BoolValue(true)), + kind: Some(Kind::BoolValue(false)), }, )) .collect(), }), - ..SandboxTemplate::default() - }; - - let params = SandboxPodParams { - enable_user_namespaces: true, // cluster default is on - ..Default::default() + ..SandboxTemplate::default() }; + + let params = SandboxPodParams::default(); let pod_template = sandbox_template_to_k8s( &template, false, @@ -5364,17 +7699,10 @@ mod tests { ¶ms, ); - assert!( - pod_template["spec"]["hostUsers"].is_null(), - "per-sandbox host_users: true must disable user namespaces even when cluster default is on" - ); - let caps = pod_template["spec"]["containers"][0]["securityContext"]["capabilities"]["add"] - .as_array() - .unwrap(); assert_eq!( - caps.len(), - 4, - "extra capabilities must not be added when user namespaces are disabled" + pod_template["spec"]["hostUsers"], + serde_json::json!(false), + "legacy host_users: false must still enable user namespaces" ); } @@ -5424,6 +7752,24 @@ mod tests { ); } + #[test] + fn sandbox_template_annotation_is_accepted_by_bootstrap_authentication() { + let params = SandboxPodParams { + sandbox_id: "sandbox-a", + ..Default::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ); + let pod: Pod = serde_json::from_value(pod_template).expect("valid pod template"); + + assert_eq!(pod_sandbox_id(&pod).unwrap(), "sandbox-a"); + } + #[test] fn sandbox_template_omits_empty_image_pull_secrets() { let pod_template = sandbox_template_to_k8s( @@ -5543,43 +7889,6 @@ mod tests { ); } - #[test] - fn platform_config_bool_extracts_value() { - let template = SandboxTemplate { - platform_config: Some(Struct { - fields: std::iter::once(( - "my_bool".to_string(), - Value { - kind: Some(Kind::BoolValue(true)), - }, - )) - .collect(), - }), - ..SandboxTemplate::default() - }; - - assert_eq!(platform_config_bool(&template, "my_bool"), Some(true)); - assert_eq!(platform_config_bool(&template, "missing"), None); - } - - #[test] - fn platform_config_bool_returns_none_for_non_bool() { - let template = SandboxTemplate { - platform_config: Some(Struct { - fields: std::iter::once(( - "a_string".to_string(), - Value { - kind: Some(Kind::StringValue("hello".to_string())), - }, - )) - .collect(), - }), - ..SandboxTemplate::default() - }; - - assert_eq!(platform_config_bool(&template, "a_string"), None); - } - #[test] fn log_level_propagates_as_env_var_to_sandbox_pod() { let spec = SandboxSpec { @@ -5628,6 +7937,29 @@ mod tests { ); } + #[test] + fn sandbox_pod_clears_unsupported_network_capabilities() { + let spec = SandboxSpec { + environment: std::collections::HashMap::from([( + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.to_string(), + )]), + ..SandboxSpec::default() + }; + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); + let env = cr["spec"]["podTemplate"]["spec"]["containers"][0]["env"] + .as_array() + .unwrap(); + let entries = env + .iter() + .filter(|entry| { + entry["name"] == openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES + }) + .collect::>(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["value"], serde_json::json!("")); + } + #[test] fn node_selector_from_platform_config() { let template = SandboxTemplate { @@ -5826,22 +8158,6 @@ mod tests { assert!(validate_kubernetes_dns1123_label("dotted.name", "sandbox name").is_err()); } - #[test] - fn kube_resource_name_qualifies_with_workspace() { - assert_eq!(kube_resource_name("alpha", "work"), "alpha--work"); - assert_eq!( - kube_resource_name("default", "my-sandbox"), - "default--my-sandbox" - ); - } - - #[test] - fn kube_resource_name_different_workspaces_produce_different_names() { - let alpha = kube_resource_name("alpha", "work"); - let beta = kube_resource_name("beta", "work"); - assert_ne!(alpha, beta); - } - #[test] fn kube_resource_name_length_validation_accepts_short_names() { validate_kube_resource_name_length("default", "my-sandbox").unwrap(); @@ -5938,6 +8254,37 @@ mod tests { assert!(result.unwrap_err().contains("not managed by openshell")); } + #[test] + fn sandbox_from_object_uses_object_namespace_over_fallback() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("work".to_string()), + namespace: Some("openshell-gw1-team-a".to_string()), + annotations: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-cross".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "team-a".to_string()), + ])), + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-cross".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "team-a".to_string()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let (_, sandbox) = sandbox_from_object("openshell", obj).unwrap(); + assert_eq!(sandbox.namespace, "openshell-gw1-team-a"); + assert_eq!(sandbox.workspace, "team-a"); + } + #[test] fn sandbox_from_object_warns_on_managed_cr_missing_workspace() { let obj = DynamicObject { @@ -5971,7 +8318,7 @@ mod tests { workspace: "alpha".to_string(), ..Default::default() }; - let labels = sandbox_labels(&sandbox); + let labels = sandbox_labels(&sandbox, None); assert_eq!(labels.get(LABEL_SANDBOX_ID).unwrap(), "uuid-1"); assert_eq!(labels.get(LABEL_SANDBOX_NAME).unwrap(), "work"); assert_eq!(labels.get(LABEL_SANDBOX_WORKSPACE).unwrap(), "alpha"); @@ -5979,6 +8326,19 @@ mod tests { labels.get(LABEL_MANAGED_BY).unwrap(), LABEL_MANAGED_BY_VALUE ); + assert!(!labels.contains_key(LABEL_GATEWAY_ID)); + } + + #[test] + fn sandbox_labels_includes_gateway_id_when_provided() { + let sandbox = Sandbox { + id: "uuid-1".to_string(), + name: "work".to_string(), + workspace: "alpha".to_string(), + ..Default::default() + }; + let labels = sandbox_labels(&sandbox, Some("gw-42")); + assert_eq!(labels.get(LABEL_GATEWAY_ID).unwrap(), "gw-42"); } #[test] @@ -6045,4 +8405,317 @@ mod tests { .is_none() ); } + + #[test] + fn upstream_proxy_is_injected_only_into_network_supervisors() { + let params = SandboxPodParams { + topology: SupervisorTopology::Sidecar, + supervisor_sideload_method: SupervisorSideloadMethod::InitContainer, + supervisor_image: "supervisor-image:latest", + https_proxy: Some("http://proxy.corp.example:8080"), + no_proxy: Some(".svc.cluster.local,10.96.0.0/12"), + proxy_auth_secret_name: Some("corporate-proxy-auth"), + proxy_auth_secret_key: Some("credentials"), + proxy_auth_allow_insecure: true, + proxy_connect_by_hostname: true, + sandbox_uid: 1500, + sandbox_gid: 1500, + ..SandboxPodParams::default() + }; + let pod = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + false, + ¶ms, + ); + let containers = pod["spec"]["containers"].as_array().unwrap(); + let network = containers + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_SIDECAR_NAME) + .unwrap(); + let command = network["command"].as_array().unwrap(); + assert!(command.iter().any(|arg| arg == "--upstream-proxy")); + assert!(command.iter().any(|arg| arg == "--upstream-no-proxy")); + let auth_file_index = command + .iter() + .position(|arg| arg == "--upstream-proxy-auth-file") + .unwrap(); + assert_eq!( + command[auth_file_index + 1], + openshell_core::container_paths::UPSTREAM_PROXY_AUTH_MOUNT_PATH + ); + assert!( + command + .iter() + .any(|arg| arg == "--upstream-proxy-auth-allow-insecure") + ); + assert!( + command + .iter() + .any(|arg| arg == "--upstream-proxy-connect-by-hostname") + ); + assert!( + network["volumeMounts"] + .as_array() + .unwrap() + .iter() + .any(|mount| mount["name"] == UPSTREAM_PROXY_AUTH_VOLUME_NAME) + ); + + let init = pod["spec"]["initContainers"] + .as_array() + .unwrap() + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_INIT_CONTAINER_NAME) + .unwrap(); + assert!(!init["command"].as_array().unwrap().iter().any(|arg| { + arg.as_str() + .is_some_and(|arg| arg.starts_with("--upstream-")) + })); + let agent = containers + .iter() + .find(|container| container["name"] == "agent") + .unwrap(); + assert!( + !agent["volumeMounts"] + .as_array() + .unwrap() + .iter() + .any(|mount| mount["name"] == UPSTREAM_PROXY_AUTH_VOLUME_NAME) + ); + assert!(!agent["env"].as_array().unwrap().iter().any(|entry| { + entry["value"] == "corporate-proxy-auth" || entry["value"] == "credentials" + })); + + let volume = pod["spec"]["volumes"] + .as_array() + .unwrap() + .iter() + .find(|volume| volume["name"] == UPSTREAM_PROXY_AUTH_VOLUME_NAME) + .unwrap(); + assert_eq!(volume["secret"]["secretName"], "corporate-proxy-auth"); + assert_eq!(volume["secret"]["items"][0]["key"], "credentials"); + assert_eq!( + volume["secret"]["items"][0]["path"], + upstream_proxy_auth_file_name() + ); + assert_eq!(volume["secret"]["defaultMode"], 0o440); + } + + #[test] + fn sandbox_lookup_selector_always_includes_gateway_id() { + let sel = sandbox_lookup_selector_for("sb-123", "gw-42"); + assert!( + sel.contains(&format!("{LABEL_GATEWAY_ID}=gw-42")), + "selector must include gateway ID: {sel}" + ); + assert!( + sel.contains(&format!("{LABEL_SANDBOX_ID}=sb-123")), + "selector must include sandbox ID: {sel}" + ); + assert!( + sel.contains(&format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE}")), + "selector must include managed-by: {sel}" + ); + } + + #[test] + fn openshell_sandbox_selector_always_includes_gateway_id() { + let sel = openshell_sandbox_selector_for("gw-99"); + assert!( + sel.contains(&format!("{LABEL_GATEWAY_ID}=gw-99")), + "selector must include gateway ID: {sel}" + ); + assert!( + sel.contains(&format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE}")), + "selector must include managed-by: {sel}" + ); + } + + #[test] + fn gateway_id_backfill_adopts_unlabelled_sandbox() { + let labels = BTreeMap::from([( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + )]); + assert!(gateway_id_label_needs_backfill(Some(&labels), "gw-1")); + } + + #[test] + fn gateway_id_backfill_adopts_sandbox_from_previous_gateway() { + let labels = BTreeMap::from([(LABEL_GATEWAY_ID.to_string(), "gw-old".to_string())]); + assert!(gateway_id_label_needs_backfill(Some(&labels), "gw-1")); + } + + #[test] + fn gateway_id_backfill_skips_sandbox_already_owned_by_gateway() { + let labels = BTreeMap::from([(LABEL_GATEWAY_ID.to_string(), "gw-1".to_string())]); + assert!(!gateway_id_label_needs_backfill(Some(&labels), "gw-1")); + } + + #[test] + fn managed_ssh_policy_allows_only_gateway_peer_on_port_2222() { + let config = KubernetesComputeConfig { + managed_ssh_ingress: crate::config::ManagedSshIngressConfig { + enabled: true, + gateway_namespace: "gateway-ns".to_string(), + gateway_pod_selector: BTreeMap::from([( + "app.kubernetes.io/name".to_string(), + "openshell".to_string(), + )]), + }, + ..KubernetesComputeConfig::default() + }; + let policy = managed_ssh_network_policy("workspace-ns", &config); + let spec = policy.spec.unwrap(); + assert_eq!( + spec.policy_types.as_deref(), + Some(["Ingress".to_string()].as_slice()) + ); + let ingress = &spec.ingress.unwrap()[0]; + assert_eq!( + ingress.ports.as_ref().unwrap()[0].port, + Some(IntOrString::Int(2222)) + ); + let peer = &ingress.from.as_ref().unwrap()[0]; + assert_eq!( + peer.namespace_selector + .as_ref() + .unwrap() + .match_labels + .as_ref() + .unwrap() + .get("kubernetes.io/metadata.name") + .map(String::as_str), + Some("gateway-ns") + ); + assert_eq!( + peer.pod_selector + .as_ref() + .unwrap() + .match_labels + .as_ref() + .unwrap() + .get("app.kubernetes.io/name") + .map(String::as_str), + Some("openshell") + ); + } + + #[test] + fn image_pull_secret_copy_keeps_only_portable_secret_fields() { + let source: Secret = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "regcred", + "namespace": "gateway", + "uid": "source-uid", + "resourceVersion": "42", + "labels": { "source-only": "true" }, + "annotations": { "source-only": "true" }, + "finalizers": ["example.test/finalizer"] + }, + "type": "kubernetes.io/dockerconfigjson", + "data": { ".dockerconfigjson": "e30=" } + })) + .unwrap(); + + let copy = image_pull_secret_copy("regcred", "workspace", source); + assert_eq!(copy.metadata.name.as_deref(), Some("regcred")); + assert_eq!(copy.metadata.namespace.as_deref(), Some("workspace")); + assert_eq!( + copy.type_.as_deref(), + Some("kubernetes.io/dockerconfigjson") + ); + assert!( + copy.data + .as_ref() + .unwrap() + .contains_key(".dockerconfigjson") + ); + assert_eq!( + copy.metadata + .labels + .as_ref() + .unwrap() + .get(LABEL_MANAGED_BY) + .map(String::as_str), + Some(LABEL_MANAGED_BY_VALUE) + ); + assert!(copy.metadata.uid.is_none()); + assert!(copy.metadata.resource_version.is_none()); + assert!(copy.metadata.annotations.is_none()); + assert!(copy.metadata.finalizers.is_none()); + } + + #[test] + fn namespace_owned_with_correct_labels() { + let labels = BTreeMap::from([ + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + (LABEL_GATEWAY_ID.to_string(), "gw-1".to_string()), + ]); + assert!(is_namespace_owned_by_gateway(Some(&labels), "gw-1")); + } + + #[test] + fn namespace_not_owned_missing_managed_by() { + let labels = BTreeMap::from([(LABEL_GATEWAY_ID.to_string(), "gw-1".to_string())]); + assert!(!is_namespace_owned_by_gateway(Some(&labels), "gw-1")); + } + + #[test] + fn namespace_not_owned_wrong_gateway_id() { + let labels = BTreeMap::from([ + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + (LABEL_GATEWAY_ID.to_string(), "gw-other".to_string()), + ]); + assert!(!is_namespace_owned_by_gateway(Some(&labels), "gw-1")); + } + + #[test] + fn namespace_not_owned_no_labels() { + assert!(!is_namespace_owned_by_gateway(None, "gw-1")); + } + + #[test] + fn namespace_delete_is_guarded_by_fetched_uid() { + let params = namespace_delete_params("namespace-uid".to_string()); + assert_eq!( + params + .preconditions + .and_then(|preconditions| preconditions.uid), + Some("namespace-uid".to_string()) + ); + } + + #[test] + fn namespace_watcher_retry_delay_is_bounded_exponential_with_jitter() { + let seed = 42; + let expected_ranges = [(2, 2), (4, 5), (8, 10), (16, 20), (24, 30), (24, 30)]; + + for (attempt, (minimum, maximum)) in expected_ranges.into_iter().enumerate() { + let attempt = u32::try_from(attempt).unwrap(); + let delay = namespace_watcher_retry_delay(attempt, seed).as_secs(); + assert!( + (minimum..=maximum).contains(&delay), + "attempt {attempt} produced {delay}s" + ); + } + } + + #[test] + fn namespace_watcher_retry_delay_uses_seeded_jitter() { + assert_ne!( + namespace_watcher_retry_delay(3, 1), + namespace_watcher_retry_delay(3, 2) + ); + } } diff --git a/crates/openshell-driver-kubernetes/src/grpc.rs b/crates/openshell-driver-kubernetes/src/grpc.rs index 6eeb51cd73..095752d842 100644 --- a/crates/openshell-driver-kubernetes/src/grpc.rs +++ b/crates/openshell-driver-kubernetes/src/grpc.rs @@ -5,159 +5,579 @@ use futures::{Stream, StreamExt}; use openshell_core::proto::compute::v1::{ - CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, + AuthenticateSandboxRequest, AuthenticateSandboxResponse, CreateSandboxRequest, + CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteWorkspaceRequest, + DeleteWorkspaceResponse, EnsureWorkspaceRequest, EnsureWorkspaceResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, - ListSandboxesRequest, ListSandboxesResponse, StopSandboxRequest, StopSandboxResponse, - ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, - WatchSandboxesRequest, compute_driver_server::ComputeDriver, + ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, StartSandboxResponse, + StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, + ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, + compute_driver_server::ComputeDriver, }; use std::pin::Pin; use tonic::{Request, Response, Status}; use crate::KubernetesComputeDriver; +use crate::WorkspaceMode; + +type ComputeDriverWatchStream = + Pin> + Send + 'static>>; + +#[cfg(test)] +type TracedWatchStream = openshell_otel::TracedGrpcStream; #[derive(Debug, Clone)] pub struct ComputeDriverService { driver: KubernetesComputeDriver, + rpc_tracer: openshell_otel::InProcessRpcTracer, } impl ComputeDriverService { #[must_use] pub fn new(driver: KubernetesComputeDriver) -> Self { - Self { driver } + Self { + driver, + rpc_tracer: openshell_otel::InProcessRpcTracer::disabled(), + } + } + + #[must_use] + pub fn new_in_process(driver: KubernetesComputeDriver) -> Self { + Self { + driver, + rpc_tracer: openshell_otel::InProcessRpcTracer::enabled(), + } } } #[tonic::async_trait] impl ComputeDriver for ComputeDriverService { + async fn authenticate_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace(openshell_otel::rpc::AUTHENTICATE_SANDBOX, async { + let credential = request.into_inner().credential; + if credential.is_empty() { + return Err(Status::invalid_argument("credential is required")); + } + let sandbox_id = self.driver.authenticate_sandbox(&credential).await?; + Ok(Response::new(AuthenticateSandboxResponse { sandbox_id })) + }) + .await + } + async fn get_capabilities( &self, _request: Request, ) -> Result, Status> { - self.driver - .capabilities() - .map(Response::new) - .map_err(Status::internal) + self.rpc_tracer + .trace(openshell_otel::rpc::GET_CAPABILITIES, async { + self.driver + .capabilities() + .map(Response::new) + .map_err(Status::internal) + }) + .await } async fn get_gateway_listener_requirements( &self, _request: Request, ) -> Result, Status> { - Ok(Response::new(GetGatewayListenerRequirementsResponse { - requirements: Vec::new(), - })) + self.rpc_tracer + .trace( + openshell_otel::rpc::GET_GATEWAY_LISTENER_REQUIREMENTS, + async { + Ok(Response::new(GetGatewayListenerRequirementsResponse { + requirements: Vec::new(), + })) + }, + ) + .await } async fn validate_sandbox_create( &self, request: Request, ) -> Result, Status> { - let sandbox = request - .into_inner() - .sandbox - .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; - self.driver.validate_sandbox_create(&sandbox).await?; - Ok(Response::new(ValidateSandboxCreateResponse {})) + self.rpc_tracer + .trace(openshell_otel::rpc::VALIDATE_SANDBOX_CREATE, async { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.driver.validate_sandbox_create(&sandbox).await?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + }) + .await } async fn get_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - - let sandbox = self - .driver - .get_sandbox(&request.sandbox_id) + self.rpc_tracer + .trace(openshell_otel::rpc::GET_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + let sandbox = self + .driver + .get_sandbox(&request.sandbox_id) + .await + .map_err(Status::internal)? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + Ok(Response::new(GetSandboxResponse { + sandbox: Some(sandbox), + })) + }) .await - .map_err(Status::internal)? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - - Ok(Response::new(GetSandboxResponse { - sandbox: Some(sandbox), - })) } async fn list_sandboxes( &self, _request: Request, ) -> Result, Status> { - let sandboxes = self - .driver - .list_sandboxes() + self.rpc_tracer + .trace(openshell_otel::rpc::LIST_SANDBOXES, async { + let sandboxes = self + .driver + .list_sandboxes() + .await + .map_err(Status::internal)?; + Ok(Response::new(ListSandboxesResponse { sandboxes })) + }) .await - .map_err(Status::internal)?; - Ok(Response::new(ListSandboxesResponse { sandboxes })) } async fn create_sandbox( &self, request: Request, ) -> Result, Status> { - let sandbox = request - .into_inner() - .sandbox - .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; - self.driver - .create_sandbox(&sandbox) + self.rpc_tracer + .trace(openshell_otel::rpc::CREATE_SANDBOX, async { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.driver + .create_sandbox(&sandbox) + .await + .map_err(|e| Status::from(openshell_core::ComputeDriverError::from(e)))?; + Ok(Response::new(CreateSandboxResponse {})) + }) .await - .map_err(|e| Status::from(openshell_core::ComputeDriverError::from(e)))?; - Ok(Response::new(CreateSandboxResponse {})) } async fn stop_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented( - "stop sandbox is not implemented by the kubernetes compute driver", - )) + self.rpc_tracer + .trace(openshell_otel::rpc::STOP_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + self.driver + .stop_sandbox(&request.sandbox_id) + .await + .map_err(|error| { + Status::from(openshell_core::ComputeDriverError::from(error)) + })?; + Ok(Response::new(StopSandboxResponse {})) + }) + .await + } + + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace(openshell_otel::rpc::START_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + self.driver + .start_sandbox(&request.sandbox_id) + .await + .map_err(|error| { + Status::from(openshell_core::ComputeDriverError::from(error)) + })?; + Ok(Response::new(StartSandboxResponse {})) + }) + .await } async fn delete_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - let deleted = self - .driver - .delete_sandbox(&request.sandbox_id) + self.rpc_tracer + .trace(openshell_otel::rpc::DELETE_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + let deleted = self + .driver + .delete_sandbox(&request.sandbox_id) + .await + .map_err(Status::internal)?; + Ok(Response::new(DeleteSandboxResponse { deleted })) + }) .await - .map_err(Status::internal)?; - Ok(Response::new(DeleteSandboxResponse { deleted })) } - type WatchSandboxesStream = - Pin> + Send + 'static>>; + type WatchSandboxesStream = ComputeDriverWatchStream; async fn watch_sandboxes( &self, _request: Request, ) -> Result, Status> { - let stream = self - .driver - .watch_sandboxes() + let create_stream = async { + let stream = self + .driver + .watch_sandboxes() + .await + .map_err(Status::internal)?; + let stream = stream.map(|item| item.map_err(|err| Status::internal(err.to_string()))); + Ok::(Box::pin(stream)) + }; + self.rpc_tracer + .trace_stream(openshell_otel::rpc::WATCH_SANDBOXES, create_stream) + .await + .map(Response::new) + } + + async fn ensure_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace(openshell_otel::rpc::ENSURE_WORKSPACE, async { + let workspace = request.into_inner().workspace; + if workspace.is_empty() { + return Err(Status::invalid_argument("workspace is required")); + } + self.driver + .validate_workspace_namespace(&workspace) + .map_err(|error| { + Status::from(openshell_core::ComputeDriverError::from(error)) + })?; + match self.driver.workspace_mode() { + WorkspaceMode::Managed => { + self.driver + .ensure_namespace(&workspace) + .await + .map_err(|e| Status::internal(e.to_string()))?; + } + WorkspaceMode::Operator => { + if let Some(allowlist) = self.driver.operator_allowlist() + && !allowlist.contains(&workspace) + { + return Err(Status::permission_denied(format!( + "workspace '{workspace}' is not in the operator namespace allowlist" + ))); + } + } + WorkspaceMode::Shared => {} + } + Ok(Response::new(EnsureWorkspaceResponse {})) + }) + .await + } + + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace(openshell_otel::rpc::DELETE_WORKSPACE, async { + let workspace = request.into_inner().workspace; + if workspace.is_empty() { + return Err(Status::invalid_argument("workspace is required")); + } + if workspace_delete_requires_namespace_access(self.driver.workspace_mode()) { + self.driver + .validate_workspace_namespace(&workspace) + .map_err(|error| { + Status::from(openshell_core::ComputeDriverError::from(error)) + })?; + self.driver + .delete_namespace(&workspace) + .await + .map_err(|e| Status::internal(e.to_string()))?; + } + Ok(Response::new(DeleteWorkspaceResponse {})) + }) .await - .map_err(Status::internal)?; - let stream = stream.map(|item| item.map_err(|err| Status::internal(err.to_string()))); - Ok(Response::new(Box::pin(stream))) } } +fn workspace_delete_requires_namespace_access(mode: WorkspaceMode) -> bool { + matches!(mode, WorkspaceMode::Managed) +} + #[cfg(test)] mod tests { + use super::{WorkspaceMode, workspace_delete_requires_namespace_access}; use crate::KubernetesDriverError; use openshell_core::ComputeDriverError; use tonic::Status; + #[tokio::test] + async fn tracing_in_process_service_preserves_the_driver_rpc_server_boundary() { + use super::*; + use crate::KubernetesComputeConfig; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::{Instrument as _, instrument::WithSubscriber as _}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let gateway_exporter = InMemorySpanExporterBuilder::new().build(); + let gateway_provider = SdkTracerProvider::builder() + .with_simple_exporter(gateway_exporter.clone()) + .build(); + let driver_exporter = InMemorySpanExporterBuilder::new().build(); + let driver_provider = SdkTracerProvider::builder() + .with_simple_exporter(driver_exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(openshell_otel::layer_excluding_target_prefixes( + &gateway_provider, + "gateway-test", + crate::otel_tracing::TRACING.in_process_targets(), + )) + .with(crate::otel_tracing::TRACING.in_process_layer(&driver_provider)); + let service = ComputeDriverService::new_in_process(KubernetesComputeDriver::new_for_test( + KubernetesComputeConfig::default(), + )); + + async { + let gateway_span = tracing::info_span!( + target: "openshell_server::compute", + "driver", + otel.name = "openshell.compute.v1.ComputeDriver/GetCapabilities", + otel.kind = "client" + ); + ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) + .instrument(gateway_span) + .await?; + + ComputeDriver::validate_sandbox_create( + &service, + Request::new(ValidateSandboxCreateRequest { sandbox: None }), + ) + .await + } + .with_subscriber(subscriber) + .await + .expect_err("missing sandbox should fail"); + gateway_provider.force_flush().unwrap(); + driver_provider.force_flush().unwrap(); + + let gateway_spans = gateway_exporter.get_finished_spans().unwrap(); + let driver_spans = driver_exporter.get_finished_spans().unwrap(); + let client = gateway_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .expect("gateway client span"); + let server = driver_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .expect("in-process server span"); + assert_eq!( + server.span_context.trace_id(), + client.span_context.trace_id() + ); + assert_eq!(server.parent_span_id, client.span_context.span_id()); + assert_eq!(server.span_kind, opentelemetry::trace::SpanKind::Server); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.method" + && attribute.value.to_string() + == "openshell.compute.v1.ComputeDriver/GetCapabilities" + })); + assert!( + server + .attributes + .iter() + .all(|attribute| attribute.key.as_str() != "rpc.service"), + "the current RPC semantic conventions integrate the service into rpc.method" + ); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "OK" + })); + let failed = driver_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate") + .expect("failed in-process server span"); + assert!(matches!( + failed.status, + opentelemetry::trace::Status::Error { .. } + )); + gateway_provider.shutdown().unwrap(); + driver_provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn tracing_in_process_stream_span_lives_until_stream_failure() { + use super::*; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(crate::otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: crate::otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::iter([Err( + Status::internal("watch failed"), + )])); + let mut stream = TracedWatchStream::new(inner, span); + + provider.force_flush().unwrap(); + assert!( + exporter.get_finished_spans().unwrap().is_empty(), + "server span must remain open while the response stream is alive" + ); + stream + .next() + .await + .expect("stream item") + .expect_err("stream should fail"); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream ends"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn tracing_in_process_stream_records_ok_when_stream_completes() { + use super::*; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(crate::otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: crate::otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::empty()); + let mut stream = TracedWatchStream::new(inner, span); + + assert!(stream.next().await.is_none()); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream completes"); + assert!(span.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "OK" + })); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn tracing_in_process_stream_leaves_status_unset_when_dropped() { + use super::*; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(crate::otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: crate::otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::pending()); + let stream = TracedWatchStream::new(inner, span); + + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream is dropped"); + assert!(matches!(span.status, opentelemetry::trace::Status::Unset)); + assert!( + span.attributes + .iter() + .all(|attribute| attribute.key.as_str() != "rpc.response.status_code") + ); + provider.shutdown().unwrap(); + } + #[test] fn precondition_driver_errors_map_to_failed_precondition_status() { let status: Status = ComputeDriverError::from(KubernetesDriverError::Precondition( @@ -169,6 +589,17 @@ mod tests { assert_eq!(status.message(), "sandbox agent pod IP is not available"); } + #[test] + fn invalid_workspace_driver_errors_map_to_invalid_argument_status() { + let status: Status = ComputeDriverError::from(KubernetesDriverError::InvalidArgument( + "managed namespace is invalid".to_string(), + )) + .into(); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert_eq!(status.message(), "managed namespace is invalid"); + } + #[test] fn already_exists_driver_errors_map_to_already_exists_status() { let status: Status = ComputeDriverError::from(KubernetesDriverError::AlreadyExists).into(); @@ -176,4 +607,25 @@ mod tests { assert_eq!(status.code(), tonic::Code::AlreadyExists); assert_eq!(status.message(), "sandbox already exists"); } + + #[test] + fn not_found_driver_errors_map_to_not_found_status() { + let status: Status = ComputeDriverError::from(KubernetesDriverError::NotFound).into(); + + assert_eq!(status.code(), tonic::Code::NotFound); + assert_eq!(status.message(), "sandbox not found"); + } + + #[test] + fn only_managed_workspace_delete_accesses_the_namespace() { + assert!(workspace_delete_requires_namespace_access( + WorkspaceMode::Managed + )); + assert!(!workspace_delete_requires_namespace_access( + WorkspaceMode::Operator + )); + assert!(!workspace_delete_requires_namespace_access( + WorkspaceMode::Shared + )); + } } diff --git a/crates/openshell-driver-kubernetes/src/lib.rs b/crates/openshell-driver-kubernetes/src/lib.rs index 7c56c8de5b..28d3c77a7d 100644 --- a/crates/openshell-driver-kubernetes/src/lib.rs +++ b/crates/openshell-driver-kubernetes/src/lib.rs @@ -4,11 +4,14 @@ pub mod config; pub mod driver; pub mod grpc; +pub mod otel_tracing; pub use config::{ - AppArmorProfile, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, + AppArmorProfile, DEFAULT_GATEWAY_ID, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, KubernetesSidecarConfig, - SupervisorSideloadMethod, SupervisorTopology, + ManagedSshIngressConfig, SupervisorSideloadMethod, SupervisorTopology, WorkspaceMode, + managed_namespace_prefix, }; pub use driver::{KubernetesComputeDriver, KubernetesDriverError}; pub use grpc::ComputeDriverService; +pub use openshell_core::DynamicStringAllowlist as OperatorNamespaceAllowlist; diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index c7b0939888..9b11f5da2a 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -3,22 +3,29 @@ use clap::{ArgAction, Parser}; use miette::{IntoDiagnostic, Result}; +use std::collections::BTreeMap; use std::net::SocketAddr; +use std::path::PathBuf; use tracing::info; -use tracing_subscriber::EnvFilter; use openshell_core::VERSION; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; use openshell_driver_kubernetes::{ - AppArmorProfile, ComputeDriverService, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, - KubernetesComputeConfig, KubernetesComputeDriver, KubernetesSidecarConfig, - SupervisorSideloadMethod, SupervisorTopology, + AppArmorProfile, ComputeDriverService, DEFAULT_GATEWAY_ID, DEFAULT_PROXY_UID, + DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, KubernetesComputeConfig, KubernetesComputeDriver, + KubernetesSidecarConfig, ManagedSshIngressConfig, SupervisorSideloadMethod, SupervisorTopology, + WorkspaceMode, }; #[derive(Parser, Debug)] #[command(name = "openshell-driver-kubernetes")] #[command(version = VERSION)] +#[allow(clippy::struct_excessive_bools)] struct Args { + /// Public compute-driver Unix socket used by an external gateway. + #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] + bind_socket: Option, + #[arg( long, env = "OPENSHELL_COMPUTE_DRIVER_BIND", @@ -29,9 +36,31 @@ struct Args { #[arg(long, env = "OPENSHELL_LOG_LEVEL", default_value = "info")] log_level: String, + #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] + otlp_endpoint: Option, + + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + + #[arg(long, env = "OPENSHELL_WORKSPACE_MODE", default_value = "shared")] + workspace_mode: WorkspaceMode, + + #[arg( + long, + env = "OPENSHELL_GATEWAY_ID", + default_value = DEFAULT_GATEWAY_ID + )] + gateway_id: String, + #[arg(long, env = "OPENSHELL_SANDBOX_NAMESPACE", default_value = "default")] sandbox_namespace: String, + #[arg(long, env = "OPENSHELL_OPERATOR_NAMESPACE_LABEL")] + operator_namespace_label: Option, + + #[arg(long, env = "OPENSHELL_OPERATOR_NAMESPACE_FILE")] + operator_namespace_file: Option, + #[arg( long, env = "OPENSHELL_K8S_SANDBOX_SERVICE_ACCOUNT", @@ -52,13 +81,26 @@ struct Args { )] sandbox_image_pull_secrets: Vec, + #[arg(long, env = "OPENSHELL_MANAGED_SSH_INGRESS_ENABLED")] + managed_ssh_ingress_enabled: bool, + + #[arg(long, env = "OPENSHELL_MANAGED_SSH_GATEWAY_NAMESPACE")] + managed_ssh_gateway_namespace: Option, + + #[arg( + long, + env = "OPENSHELL_MANAGED_SSH_GATEWAY_POD_SELECTOR", + value_delimiter = ',' + )] + managed_ssh_gateway_pod_selector: Vec, + #[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")] grpc_endpoint: Option, #[arg( long, env = "OPENSHELL_SANDBOX_SSH_SOCKET_PATH", - default_value = "/run/openshell/ssh.sock" + default_value = openshell_core::container_paths::SSH_SOCKET_PATH )] sandbox_ssh_socket_path: String, @@ -100,6 +142,30 @@ struct Args { )] sidecar_process_binary_aware_network_policy: bool, + /// Corporate HTTP forward proxy for policy-approved TLS CONNECT egress. + #[arg(long, env = "OPENSHELL_UPSTREAM_PROXY")] + https_proxy: Option, + + /// Comma-separated destinations that bypass the corporate proxy. + #[arg(long, env = "OPENSHELL_UPSTREAM_NO_PROXY")] + no_proxy: Option, + + /// Kubernetes Secret name containing the upstream proxy credential. + #[arg(long, env = "OPENSHELL_UPSTREAM_PROXY_AUTH_SECRET_NAME")] + proxy_auth_secret_name: Option, + + /// Kubernetes Secret key containing the upstream proxy credential. + #[arg(long, env = "OPENSHELL_UPSTREAM_PROXY_AUTH_SECRET_KEY")] + proxy_auth_secret_key: Option, + + /// Acknowledge cleartext Basic auth to an http:// upstream proxy. + #[arg(long, env = "OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE", action = ArgAction::SetTrue)] + proxy_auth_allow_insecure: bool, + + /// Send destination hostnames rather than validated IPs in CONNECT. + #[arg(long, env = "OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME", action = ArgAction::SetTrue)] + proxy_connect_by_hostname: bool, + #[arg(long, env = "OPENSHELL_ENABLE_USER_NAMESPACES")] enable_user_namespaces: bool, @@ -123,61 +189,167 @@ struct Args { sandbox_gid: Option, } +async fn shutdown_signal() { + #[cfg(unix)] + { + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + () = terminate => {} + } + } + + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), - ) - .init(); - - let driver = KubernetesComputeDriver::new(KubernetesComputeConfig { - namespace: args.sandbox_namespace, - service_account_name: args.sandbox_service_account, - default_image: args.sandbox_image.unwrap_or_default(), - image_pull_policy: args.sandbox_image_pull_policy.unwrap_or_default(), - image_pull_secrets: args.sandbox_image_pull_secrets, - supervisor_image: args - .supervisor_image - .unwrap_or_else(openshell_core::config::default_supervisor_image), - supervisor_image_pull_policy: args.supervisor_image_pull_policy.unwrap_or_default(), - supervisor_sideload_method: args.supervisor_sideload_method, - topology: args.topology, - sidecar: KubernetesSidecarConfig { - proxy_uid: args.sidecar_proxy_uid, - process_binary_aware_network_policy: args.sidecar_process_binary_aware_network_policy, + let _tracing = openshell_otel::install_driver_tracing( + openshell_driver_kubernetes::otel_tracing::TRACING, + openshell_otel::DriverTracingConfig { + endpoint: args.otlp_endpoint.as_deref(), + gateway_name: args.gateway_name.as_deref(), + service_version: VERSION, + log_level: &args.log_level, + }, + ); + + let managed_ssh_gateway_pod_selector = args + .managed_ssh_gateway_pod_selector + .iter() + .map(|entry| { + entry + .split_once('=') + .map(|(key, value)| (key.to_string(), value.to_string())) + .ok_or_else(|| { + miette::miette!("managed SSH gateway pod selector must use key=value: {entry}") + }) + }) + .collect::>>()?; + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let driver = KubernetesComputeDriver::new( + KubernetesComputeConfig { + workspace_mode: args.workspace_mode, + gateway_id: args.gateway_id, + namespace: args.sandbox_namespace, + operator_namespace_label: args.operator_namespace_label, + operator_namespace_file: args.operator_namespace_file, + service_account_name: args.sandbox_service_account, + default_image: args.sandbox_image.unwrap_or_default(), + image_pull_policy: args.sandbox_image_pull_policy.unwrap_or_default(), + image_pull_secrets: args.sandbox_image_pull_secrets, + managed_ssh_ingress: ManagedSshIngressConfig { + enabled: args.managed_ssh_ingress_enabled, + gateway_namespace: args.managed_ssh_gateway_namespace.unwrap_or_default(), + gateway_pod_selector: managed_ssh_gateway_pod_selector, + }, + supervisor_image: args + .supervisor_image + .unwrap_or_else(openshell_core::config::default_supervisor_image), + supervisor_image_pull_policy: args.supervisor_image_pull_policy.unwrap_or_default(), + supervisor_sideload_method: args.supervisor_sideload_method, + topology: args.topology, + sidecar: KubernetesSidecarConfig { + proxy_uid: args.sidecar_proxy_uid, + process_binary_aware_network_policy: args + .sidecar_process_binary_aware_network_policy, + }, + https_proxy: args.https_proxy, + no_proxy: args.no_proxy, + proxy_auth_secret_name: args.proxy_auth_secret_name, + proxy_auth_secret_key: args.proxy_auth_secret_key, + proxy_auth_allow_insecure: args.proxy_auth_allow_insecure.then_some(true), + proxy_connect_by_hostname: args.proxy_connect_by_hostname.then_some(true), + grpc_endpoint: args.grpc_endpoint.unwrap_or_default(), + ssh_socket_path: args.sandbox_ssh_socket_path, + client_tls_secret_name: args.client_tls_secret_name.unwrap_or_default(), + host_gateway_ip: args.host_gateway_ip.unwrap_or_default(), + enable_user_namespaces: args.enable_user_namespaces, + app_armor_profile: args.app_armor_profile, + workspace_default_storage_size: std::env::var( + "OPENSHELL_K8S_WORKSPACE_DEFAULT_STORAGE_SIZE", + ) + .unwrap_or_else(|_| { + openshell_driver_kubernetes::DEFAULT_WORKSPACE_STORAGE_SIZE.to_string() + }), + workspace_storage_class: std::env::var("OPENSHELL_K8S_WORKSPACE_STORAGE_CLASS") + .unwrap_or_default(), + default_runtime_class_name: std::env::var("OPENSHELL_K8S_DEFAULT_RUNTIME_CLASS_NAME") + .unwrap_or_default(), + sa_token_ttl_secs: args.sa_token_ttl_secs, + provider_spiffe_workload_api_socket_path: args + .provider_spiffe_workload_api_socket_path + .unwrap_or_default(), + sandbox_uid: args.sandbox_uid, + sandbox_gid: args.sandbox_gid, }, - grpc_endpoint: args.grpc_endpoint.unwrap_or_default(), - ssh_socket_path: args.sandbox_ssh_socket_path, - client_tls_secret_name: args.client_tls_secret_name.unwrap_or_default(), - host_gateway_ip: args.host_gateway_ip.unwrap_or_default(), - enable_user_namespaces: args.enable_user_namespaces, - app_armor_profile: args.app_armor_profile, - workspace_default_storage_size: std::env::var( - "OPENSHELL_K8S_WORKSPACE_DEFAULT_STORAGE_SIZE", - ) - .unwrap_or_else(|_| { - openshell_driver_kubernetes::DEFAULT_WORKSPACE_STORAGE_SIZE.to_string() - }), - workspace_storage_class: std::env::var("OPENSHELL_K8S_WORKSPACE_STORAGE_CLASS") - .unwrap_or_default(), - default_runtime_class_name: std::env::var("OPENSHELL_K8S_DEFAULT_RUNTIME_CLASS_NAME") - .unwrap_or_default(), - sa_token_ttl_secs: args.sa_token_ttl_secs, - provider_spiffe_workload_api_socket_path: args - .provider_spiffe_workload_api_socket_path - .unwrap_or_default(), - sandbox_uid: args.sandbox_uid, - sandbox_gid: args.sandbox_gid, - }) + shutdown_rx, + ) .await .into_diagnostic()?; - info!(address = %args.bind_address, "Starting Kubernetes compute driver"); - tonic::transport::Server::builder() - .add_service(ComputeDriverServer::new(ComputeDriverService::new(driver))) - .serve(args.bind_address) - .await - .into_diagnostic() + let service = ComputeDriverServer::new(ComputeDriverService::new(driver)); + let shutdown = async move { + shutdown_signal().await; + let _ = shutdown_tx.send(true); + }; + if let Some(socket_path) = args.bind_socket { + let listener = openshell_core::external_driver_socket::bind_private(&socket_path) + .map_err(|err| miette::miette!("{err}"))?; + let _cleanup = + openshell_core::external_driver_socket::SocketCleanup::new(socket_path.clone()); + info!(socket = %socket_path.display(), "Starting Kubernetes compute driver"); + tonic::transport::Server::builder() + .layer(openshell_otel::compute_driver_rpc_layer()) + .add_service(service) + .serve_with_incoming_shutdown( + openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), + shutdown, + ) + .await + .into_diagnostic() + } else { + info!(address = %args.bind_address, "Starting Kubernetes compute driver"); + tonic::transport::Server::builder() + .layer(openshell_otel::compute_driver_rpc_layer()) + .add_service(service) + .serve_with_shutdown(args.bind_address, shutdown) + .await + .into_diagnostic() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_gateway_otlp_configuration() { + let args = Args::try_parse_from([ + "openshell-driver-kubernetes", + "--otlp-endpoint", + "http://collector.example:4317", + "--gateway-name", + "kubernetes-dev", + ]) + .expect("OTLP endpoint should parse"); + + assert_eq!( + args.otlp_endpoint.as_deref(), + Some("http://collector.example:4317") + ); + assert_eq!(args.gateway_name.as_deref(), Some("kubernetes-dev")); + } } diff --git a/crates/openshell-driver-kubernetes/src/otel_tracing.rs b/crates/openshell-driver-kubernetes/src/otel_tracing.rs new file mode 100644 index 0000000000..37e69c65bd --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/otel_tracing.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenTelemetry tracing identity for the Kubernetes compute driver. + +pub const TRACING: openshell_otel::ComputeDriverTracing = openshell_otel::compute_driver_tracing!(); + +#[cfg(test)] +mod tests { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn driver_spans_reach_otlp_collector_with_resource_identity() { + openshell_otel_test_support::assert_compute_driver_tracing( + super::TRACING, + openshell_core::VERSION, + "kubernetes.provision", + ) + .await; + } +} diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml new file mode 100644 index 0000000000..2f82e50e3b --- /dev/null +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-mxc" +description = "MXC (Windows isolation session) compute driver for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "openshell_driver_mxc" + +[dependencies] +openshell-core = { path = "../openshell-core" } +tokio = { workspace = true } +tonic = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +# tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. +tempfile = "3" +# Used by Windows-only integration tests to parse policy YAML into the typed +# proto. Inert on non-Windows. +openshell-policy = { path = "../openshell-policy" } +# Needed by the real-wxc integration test (wxc_exec_real.rs) which builds +# --config-base64 payloads without going through the async WxcExecInvoker. +# base64 and serde_json are already [dependencies] but dev-dependency resolution +# is independent; explicit entries make them visible to integration tests. +base64 = { workspace = true } +serde_json = { workspace = true } +# Used by the drift guard test (handled_fields_inventory) to parse YAML into a +# generic serde_json::Value for key enumeration. +serde_yml = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md new file mode 100644 index 0000000000..bd0b51f48b --- /dev/null +++ b/crates/openshell-driver-mxc/README.md @@ -0,0 +1,127 @@ +# openshell-driver-mxc + +OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. + +## Design + +This driver implements the gateway's ordinary in-process `ComputeDriver` +contract and is linked into `openshell-gateway`. It sets +`driver_reports_runtime_readiness`, so the gateway accepts driver-reported +readiness without a supervisor session. The canonical create-time +`SandboxPolicy` is carried by `DriverSandboxSpec.policy`. `process_container` launches a one-shot +AppContainer and is the default. The opt-in `isolation_session` backend uses the +state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. +The driver launches and monitors the configured workload itself and self-reports +readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. + +## Capability Matrix + +| Capability | MXC driver | +|---|---| +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| Network policy | Rejected synchronously during sandbox creation until an enforcing egress path is bound. | +| Process policy | Unsupported; MXC supplies OS isolation only. | +| Interactive exec/connect/forward | Unsupported; the configured workload runs in-driver. | +| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | + +The filesystem enforcement proof has two paths: + +- A write to a path granted by the sandbox policy succeeds. +- A `process_container` write outside the sandbox policy fails with Windows access denied, and the driver reports the failed workload. + +## Configuration (`[openshell.drivers.mxc]`) + +Gateway configuration contains only host runtime settings: + +```toml +[openshell.drivers.mxc] +wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" +# Default: process_container. isolation_session is grant-only and opt-in. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +debug = false +``` + +Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: + +```powershell +$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' +openshell sandbox create --name mxc-demo --policy demo.yaml ` + --driver-config-json $config --env MODE=demo --no-tty +``` + +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. + +Network policy support depends on the selected containment path. Policy +replacement and merge updates use the gateway's standard sandbox configuration +revision contract. + +## Prerequisites (live runs) + +- Windows 11 Insider build ≥ 26300.8553 +- `IsoSessionApp.dll` present and registered +- `wxc-exec.exe` built with `--features isolation_session` + +For off-box smoke tests against the in-process mock shim (no `wxc-exec`, +no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. + +## Policy mapping + +The production driver maps the typed `SandboxPolicy` carried by the standard +driver request to MXC configuration before it inserts a registry entry or +invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` +without leaving a partial sandbox. There is no in-process policy side channel +or MXC-specific gateway composition variant. + +`EmbeddedPolicyMapper` calls the embedded [`policy_map`](src/policy_map/) module directly and normalizes filesystem paths to Windows form. It does not add gateway-configured host paths. The policy supplied for the sandbox is the only source of filesystem grants. + +The mapper retains an internal policy-splitting seam for future development, but the runtime exposes no governed-egress switch. Any network rule fails closed until an enforcing proxy is implemented and bound to the sandbox lifecycle. + +Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. + +## Packaging the demo for the demo box + +Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble +the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the +gateway config, and the runbook into one folder, then copy that folder to +the demo Windows host and follow `mxc-demo-runbook.md` inside it. The +script prints a SHA256 manifest so the operator can sanity-check what +landed before moving it. + +## Real-MXC test lane + +Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test +or scenario that requires an absent binary or backend prints a SKIP reason and +exits 0 rather than failing. + +| Task | What it runs | When to use | +|---|---|---| +| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1` | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | +| `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | +| `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | + +**Probe script:** `examples/probe-mxc-host.ps1` is an operator/CI preflight that emits a JSON capability report +(OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, +and a `verdicts` object). Run it before the real-MXC lane to understand what +will PASS vs SKIP on a given host: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +``` + +**Skip semantics:** tests in `wxc_exec_real.rs` are marked +`#[ignore = "requires real wxc-exec"]` — the standard `windows:test:x64` suite +never runs them. `OPENSHELL_WXC_EXEC_PATH` overrides the default +`C:\mxc\wxc-exec.exe` lookup. See `docs4gtb/mxc-box-capabilities.md` for the +empirical capability snapshot of the development box (build 26200, processcontainer +velocity keys not enabled, isolation_session absent). + +## Deferred work + +- **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` +- **Governed egress** remains fail-closed until an enforcing proxy is implemented and bound to sandbox lifecycle. +- **Restart durability** (deprovision orphaned sessions on startup) → follow-on +- **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/demo.yaml b/crates/openshell-driver-mxc/examples/demo.yaml new file mode 100644 index 0000000000..1dcc23144f --- /dev/null +++ b/crates/openshell-driver-mxc/examples/demo.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# demo.yaml — June 15 MXC filesystem-policy proof. +# +# Minimal filesystem policy granting the workload folder read-write. The granted +# path must cover the per-sandbox command executable, working directory, and any +# files that command reads or writes. ProcessContainer denies all ungranted paths. +# +# NOTE: the canonical OpenShell policy YAML key is `filesystem_policy` +# (parsed by the `openshell-policy` crate into SandboxPolicy.filesystem), NOT +# `filesystem`. The MXC driver's policy bridge then re-emits this under the +# `filesystem_policy` key the embedded mapper expects. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-demo" # = OPENSHELL_MXC_SHARE_DIR (host-visible share) + +# No landlock, process, or network policy is present. MXC rejects every network +# policy at create time until an enforcing egress path is available; it never +# silently drops network rules. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml new file mode 100644 index 0000000000..8be1dec9cf --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-empty.yaml — Empty filesystem policy (default-deny) scenario. +# +# No paths are granted. With processcontainer (AppContainer), every write is +# denied by the OS without any host ACL configuration — genuine default-deny. +# This scenario is processcontainer-only (isolation_session has no deny primitive). +# Used by the fs-default-deny-empty scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: [] diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml new file mode 100644 index 0000000000..7fd2d0864b --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-readonly.yaml — Read-only grant + read-write share scenario. +# +# The read_only path is a prepared directory whose content the agent can read +# but not write; the read_write path is DemoDir (host-visible share). +# Used by the fs-readonly scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-e2e-ro-src" + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml new file mode 100644 index 0000000000..38dafa5ed5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-rw.yaml — Filesystem read-write grant scenario. +# +# Grants read-write access to the DemoDir (substituted at runtime). +# Used by the fs-rw-positive-negative scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml new file mode 100644 index 0000000000..e5529eaeb6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# network-reject.yaml — Network policy rejection scenario. +# +# A filesystem grant plus a network_policies rule. The driver rejects sandbox +# create at map time with invalid_argument naming the network rule on every +# backend until MXC has a bound, enforcing egress path. +# +# This scenario requires NO live backend — it passes even on this box and in +# mock mode because the rejection happens in the policy mapper before wxc-exec +# is invoked. It is the only scenario that never SKIPs. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" + +network_policies: + test_rule: + name: test-network-reject + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml new file mode 100644 index 0000000000..4fbd2c41f5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# MXC gateway runtime configuration. +# +# Commands, working directories, and environment variables are sandbox-scoped. +# Supply command and cwd through --driver-config-json when creating a sandbox, +# and environment variables through the standard sandbox --env option. + +[openshell.drivers.mxc] +# Path to wxc-exec.exe. Required for live runs. Leave the default for mock-mode +# smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" + +# process_container is the default because AppContainer enforces default-deny +# filesystem access. isolation_session is an explicit grant-only compatibility +# mode and does not deny access to paths omitted from the sandbox policy. +backend = "process_container" + +# process_container only: request a Less-Privileged AppContainer. +# pc_least_privilege = false +# process_container only: AppContainer capabilities to grant. +# pc_capabilities = [] + +# isolation_session only. Never use "small" (known OS bug). +default_configuration_id = "composable" + +# Enable --debug on wxc-exec invocations. +debug = false diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 new file mode 100644 index 0000000000..4a20e7b86c --- /dev/null +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# probe-mxc-host.ps1 - Operator/CI preflight for a prospective MXC host. +# This diagnostic is not invoked by the driver and does not mutate host state. +# It reports OS, wxc-exec, and backend availability so real tests can skip +# unsupported scenarios with an explicit reason. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 -OutFile caps.json +# +# The script is read-only except for a per-run temp directory. It never enables +# OS features or modifies system state. +# +# Exit codes: +# 0 - report emitted (even if backends are unavailable) +# 1 - unexpected error (should not happen on a healthy box) + +[CmdletBinding()] +param( + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [string] $OutFile, + # Emit the complete JSON report to stdout. Default output is a short + # human-readable summary (the full report still goes to -OutFile if set). + [switch] $Full +) + +$ErrorActionPreference = "Stop" +# Prevent PS 7+ from turning native non-zero exits into terminating errors. +$PSNativeCommandUseErrorActionPreference = $false + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +function Invoke-Native([string[]] $ArgList) { + # Run a native exe and capture all output regardless of exit code. + # In PowerShell 5.1, a non-zero exit from a native exe can emit + # ErrorRecord objects into the output stream when $ErrorActionPreference + # is Stop (via NativeCommandError). We temporarily relax the preference + # and collect both String and ErrorRecord outputs into a single string. + $saved = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $raw = & $ArgList[0] $ArgList[1..($ArgList.Length - 1)] 2>&1 + $code = $LASTEXITCODE + $text = ($raw | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.ToString() + } else { + $_ + } + }) -join "`n" + return @{ ExitCode = $code; Output = $text } + } finally { + $ErrorActionPreference = $saved + } +} + +function Invoke-WxcDryRun([string] $wxc, [hashtable] $config) { + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + return Invoke-Native @($wxc, "--config-base64", $b64, "--dry-run") +} + +function Invoke-WxcPhase([string] $wxc, [hashtable] $config, [switch] $Experimental) { + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + if ($Experimental) { + return Invoke-Native @($wxc, "--config-base64", $b64, "--experimental") + } else { + return Invoke-Native @($wxc, "--config-base64", $b64) + } +} + +function Invoke-WxcProbe([string] $wxc) { + return Invoke-Native @($wxc, "--probe") +} + +# ── OS info ─────────────────────────────────────────────────────────────────── + +$osVersion = [System.Environment]::OSVersion.Version +$osBuild = $osVersion.Build +$osRevision = 0 +try { + $ubr = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -ErrorAction SilentlyContinue).UBR + if ($null -ne $ubr) { $osRevision = $ubr } +} catch {} +$osBuildFull = "$osBuild.$osRevision" +$isoSessionMinBuild = 26300 +$isoSessionMinRevision = 8553 + +# ── wxc-exec info ───────────────────────────────────────────────────────────── + +$wxcInfo = @{ + path = $WxcExecPath + exists = $false + size = $null + mtime = $null +} + +if (Test-Path $WxcExecPath) { + $item = Get-Item $WxcExecPath + $wxcInfo.exists = $true + $wxcInfo.size = $item.Length + $wxcInfo.mtime = $item.LastWriteTime.ToString("o") +} + +# ── Probe section ───────────────────────────────────────────────────────────── + +$probeOutput = $null +$dryRunExitCode = $null +$dryRunOutput = $null +$pcTrialResult = "absent" +$pcTrialMessage = "wxc-exec not found" +$isoTrialResult = "absent" +$isoTrialMessage = "wxc-exec not found" + +if ($wxcInfo.exists) { + # --probe + $probeResult = Invoke-WxcProbe -wxc $WxcExecPath + $probeOutput = $probeResult.Output + + # dry-run trial (minimal processcontainer config) + $dryConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-dryrun" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 0 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + } + $dryResult = Invoke-WxcDryRun -wxc $WxcExecPath -config $dryConfig + $dryRunExitCode = $dryResult.ExitCode + $dryRunOutput = $dryResult.Output + + # processcontainer one-shot trial + $pcConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-pc-oneshot" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + processContainer = @{ + leastPrivilege = $false + } + } + $pcResult = Invoke-WxcPhase -wxc $WxcExecPath -config $pcConfig + $pcOutput = $pcResult.Output + $pcOutputLower = $pcOutput.ToLower() + + if ($pcResult.ExitCode -eq 0) { + $pcTrialResult = "works" + $pcTrialMessage = "processcontainer one-shot exited 0" + } elseif ($pcOutputLower -match "backend_error" -or $pcOutputLower -match "e_notimpl" -or $pcOutputLower -match "velocity") { + $pcTrialResult = "backend_error" + # Try to extract the message from the JSON envelope. + $pcTrialMessage = "backend_error: velocity keys not enabled (E_NOTIMPL)" + try { + $envelope = $pcOutput | ConvertFrom-Json + if ($null -ne $envelope.error) { + $pcTrialMessage = "backend_error: $($envelope.error.message)" + } + } catch {} + } else { + $pcTrialResult = "error" + if ([string]::IsNullOrWhiteSpace($pcOutput)) { + $pcTrialMessage = "exit $($pcResult.ExitCode) with no output captured" + } else { + $pcTrialMessage = "exit $($pcResult.ExitCode): $pcOutput" + } + } + + # isolation_session provision trial + $isoConfig = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ + readwritePaths = @() + readonlyPaths = @() + } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Experimental + $isoOutput = $isoResult.Output + $isoOutputLower = $isoOutput.ToLower() + + if ($isoOutputLower -match "backend_unavailable" -or $isoOutputLower -match "0x80040154") { + $isoTrialResult = "unavailable" + $isoTrialMessage = "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553" + } elseif ($isoResult.ExitCode -eq 0) { + # Provision succeeded — deprovision immediately to avoid orphaning. + $isoTrialResult = "live" + $isoTrialMessage = "isolation_session provision succeeded" + $sandboxId = $null + try { + $envelope = $isoOutput | ConvertFrom-Json + if ($null -ne $envelope.result) { + $sandboxId = $envelope.result.sandboxId + } + } catch {} + + if ($null -ne $sandboxId) { + # Stop first (a provisioned-but-unstarted session may still accept it; + # ignore failures), then deprovision. Surface the deprovision error + # text — an orphaned session blocks the single-session backend. + $stopConfig = @{ + version = "0.6.0-alpha" + phase = "stop" + sandboxId = $sandboxId + experimental = @{ + isolation_session = @{ + # Unit variant: serialize as null, not {} (malformed_request otherwise). + stop = $null + } + } + } + Invoke-WxcPhase -wxc $WxcExecPath -config $stopConfig -Experimental | Out-Null + $deprovConfig = @{ + version = "0.6.0-alpha" + phase = "deprovision" + sandboxId = $sandboxId + experimental = @{ + isolation_session = @{ + deprovision = $null + } + } + } + $deprovResult = Invoke-WxcPhase -wxc $WxcExecPath -config $deprovConfig -Experimental + if ($deprovResult.ExitCode -eq 0) { + $isoTrialMessage = "isolation_session live (provisioned $sandboxId, deprovisioned cleanly)" + } else { + $snippet = $deprovResult.Output + if ($snippet.Length -gt 200) { $snippet = $snippet.Substring(0, 200) } + $isoTrialMessage = "isolation_session live (provisioned $sandboxId; deprovision FAILED exit $($deprovResult.ExitCode): $snippet -- clean up manually before running lifecycle tests)" + } + } + } else { + $isoTrialResult = "error" + $isoTrialMessage = "exit $($isoResult.ExitCode): $isoOutput" + } +} + +# ── Verdicts ────────────────────────────────────────────────────────────────── + +$pcVerdict = $null +if ($pcTrialResult -eq "works") { + $pcVerdict = "live" +} else { + $pcVerdict = "unavailable: $pcTrialMessage" +} + +$isoVerdict = $null +if ($isoTrialResult -eq "live") { + $isoVerdict = "live" +} else { + $isoVerdict = "unavailable: $isoTrialMessage" +} + +$dryRunVerdict = $null +if ($null -eq $dryRunExitCode) { + $dryRunVerdict = "unavailable: wxc-exec not found" +} elseif ($dryRunExitCode -eq 0) { + $dryRunVerdict = "ok" +} else { + $dryRunVerdict = "failed: exit $dryRunExitCode" +} + +# ── Assemble report ─────────────────────────────────────────────────────────── + +$report = [ordered]@{ + generatedAt = (Get-Date).ToString("o") + host = [ordered]@{ + osBuild = $osBuildFull + osBuildNumber = $osBuild + osRevision = $osRevision + isoSessionBuildRequirement = "${isoSessionMinBuild}.${isoSessionMinRevision}" + meetsIsoBuildReq = ($osBuild -gt $isoSessionMinBuild) -or + ($osBuild -eq $isoSessionMinBuild -and $osRevision -ge $isoSessionMinRevision) + } + wxcExec = $wxcInfo + probeOutput = $probeOutput + dryRun = [ordered]@{ + exitCode = $dryRunExitCode + output = $dryRunOutput + } + processcontainerTrial = [ordered]@{ + result = $pcTrialResult + message = $pcTrialMessage + } + isolationSessionTrial = [ordered]@{ + result = $isoTrialResult + message = $isoTrialMessage + } + verdicts = [ordered]@{ + processcontainer = $pcVerdict + isolation_session = $isoVerdict + dryRun = $dryRunVerdict + } +} + +$json = $report | ConvertTo-Json -Depth 10 + +if ($Full) { + Write-Output $json +} else { + $met = "not met" + if ($report.host.meetsIsoBuildReq) { $met = "met" } + Write-Host "MXC host probe - OS build $osBuildFull (isolation_session requires $($report.host.isoSessionBuildRequirement): $met)" + Write-Host "wxc-exec: $WxcExecPath (exists=$($wxcInfo.exists))" + Write-Host "verdicts:" + Write-Host " processcontainer : $pcVerdict" + Write-Host " isolation_session : $isoVerdict" + Write-Host " dry-run : $dryRunVerdict" + Write-Host "(re-run with -Full for the complete JSON report, or -OutFile caps.json to save it)" +} + +if ($OutFile) { + $json | Out-File -FilePath $OutFile -Encoding utf8 + Write-Host "Report written to $OutFile" -ForegroundColor Cyan +} diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 new file mode 100644 index 0000000000..9f8baa3446 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -0,0 +1,484 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-mxc-e2e.ps1 - MXC e2e scenario runner. +# +# Starts the gateway ONCE, runs a table of policy scenarios, emits per-scenario +# PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero only on +# FAIL. Reuses the gateway-start / CLI-register / teardown pattern from +# run-demo.ps1. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage examples: +# +# # Real mode (probe-gated — backends that are absent are SKIPped): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 +# +# # Mock mode (wiring-only; no real wxc-exec or enforcement): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -Mock +# +# # Choose backend / filter scenarios: +# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw +# +# Scenarios & expected verdicts: +# fs-rw - rw grant on DemoDir; in-policy write succeeds. +# Both backends; skipped when backend not live (non-mock). +# fs-readonly - ro grant on a source dir + rw on DemoDir; +# write to ro dir should be denied. +# Both backends; skipped when backend not live. +# fs-default-deny - empty filesystem policy; every write denied. +# processcontainer only (isolation_session has no deny +# primitive); skipped on isolation_session. +# network-reject - rw grant + network_policies rule; +# sandbox create must FAIL (invalid_argument). +# Runs on ANY backend including mock — never skips. + +[CmdletBinding()] +param( + [string] $DemoDir = "C:\work\openshell-mxc-e2e", + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [ValidateSet("isolation_session", "process_container")] + [string] $Backend = "process_container", + [string] $Scenario, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-e2e", + [switch] $Mock, + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } +function Skip([string]$m) { Write-Host "[SKIP] $m" -ForegroundColor Yellow } +function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } + +# ── Pre-flight ──────────────────────────────────────────────────────────────── + +# In real mode, assert OPENSHELL_MXC_MOCK_WXC is NOT set. +# A stale mock env var would silently re-mock a run that should be real. +if (-not $Mock) { + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + + "A stale mock env var would silently re-mock a real run. " + + "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." + } +} + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$toml = Join-Path $here "mxc-gateway.toml" +$policyDir = Join-Path $here "e2e-policies" + +foreach ($f in @($gateway, $cli, $toml)) { + if (-not (Test-Path $f)) { + throw "Missing artifact: $f`nBuild first or run from a demo-package folder." + } +} +if (-not (Test-Path $policyDir)) { + throw "e2e-policies/ directory not found at $policyDir" +} + +# ── Backend probe ───────────────────────────────────────────────────────────── + +# Returns a verdict hash for a given backend: {Live: bool, Reason: string} +function Probe-Backend([string] $backendName, [string] $wxc) { + if ($Mock) { + # In mock mode all backends are "live" — enforcement is simulated. + return @{ Live = $true; Reason = "mock mode" } + } + if (-not (Test-Path $wxc)) { + return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } + } + + if ($backendName -eq "process_container") { + $config = @{ + version = "0.6.0-alpha" + containerId = "e2e-probe-pc" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ readwritePaths = @("%TEMP%") } + processContainer = @{ leastPrivilege = $false } + } + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + $outObj = & $wxc --config-base64 $b64 2>&1 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($exitCode -eq 0) { + return @{ Live = $true; Reason = "process_container probe exit 0" } + } + $reason = "process_container unavailable: exit $exitCode" + if ($output -match "backend_error" -or $output -match "e_notimpl" -or $output -match "velocity") { + $reason = "process_container backend_error (velocity keys not enabled)" + } + return @{ Live = $false; Reason = $reason } + } + + if ($backendName -eq "isolation_session") { + $config = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ readwritePaths = @(); readonlyPaths = @() } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + $outObj = & $wxc --config-base64 $b64 --experimental 2>&1 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($output -match "backend_unavailable" -or $output -match "0x80040154") { + return @{ Live = $false; Reason = "isolation_session backend_unavailable (IsoSessionApp.dll absent)" } + } + if ($exitCode -ne 0) { + return @{ Live = $false; Reason = "isolation_session probe failed: exit $exitCode" } + } + # Provision succeeded — deprovision immediately. + $sandboxId = $null + try { + $rawOut = ($outObj -join "`n") + $parsed = $rawOut | ConvertFrom-Json + $sandboxId = $parsed.result.sandboxId + } catch {} + if ($null -ne $sandboxId) { + $deprovConfig = @{ + version = "0.6.0-alpha" + phase = "deprovision" + sandboxId = $sandboxId + experimental = @{ + # Unit variant: null, not @{} (malformed_request otherwise). + isolation_session = @{ deprovision = $null } + } + } + $deprovJson = $deprovConfig | ConvertTo-Json -Depth 20 -Compress + $deprovBytes = [System.Text.Encoding]::UTF8.GetBytes($deprovJson) + $deprovB64 = [Convert]::ToBase64String($deprovBytes) + & $wxc --config-base64 $deprovB64 --experimental 2>&1 | Out-Null + } + return @{ Live = $true; Reason = "isolation_session probe: provisioned and deprovisioned" } + } + + return @{ Live = $false; Reason = "unknown backend: $backendName" } +} + +# ── Mode setup ──────────────────────────────────────────────────────────────── + +$mode = if ($Mock) { "MOCK" } else { "REAL" } +Step "Pre-flight (mode=$mode, backend=$Backend)" + +if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + Info "OPENSHELL_MXC_MOCK_WXC=1 — mock mode: enforcement simulated" +} else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if (-not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." + } + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + Info "wxc-exec: $WxcExecPath" +} + +# Patch the TOML copy for backend + wxc_exec_path (mirrors run-demo.ps1). +$tomlText = Get-Content $toml -Raw +$backendLine = "backend = `"$Backend`"" +if ($tomlText -match '(?m)^\s*#?\s*backend\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', $backendLine) +} else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$backendLine") +} +if (-not $Mock) { + $escaped = $WxcExecPath.Replace('\', '\\') + $wxcLine = "wxc_exec_path = `"$escaped`"" + if ($tomlText -match '(?m)^\s*#?\s*wxc_exec_path\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$wxcLine") + } +} +Set-Content $toml -Value $tomlText -Encoding UTF8 +Info "patched $(Split-Path $toml -Leaf): backend=$Backend" + +# Probe backend liveness now (used by scenario gate below). +$backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath +if ($backendProbe.Live) { + Ok "Backend '$Backend' is live: $($backendProbe.Reason)" +} else { + Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" + Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." +} + +# ── Port check ──────────────────────────────────────────────────────────────── + +Step "Check gateway port $Port" +$busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue +if ($busy) { + throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." +} +Ok "port $Port free" + +# ── Prepare DemoDir ─────────────────────────────────────────────────────────── + +Step "Prepare DemoDir $DemoDir" +New-Item -ItemType Directory -Force $DemoDir | Out-Null +Ok "DemoDir ready" + +$env:OPENSHELL_DRIVERS = "mxc" +$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir + +# ── Start gateway ───────────────────────────────────────────────────────────── + +Step "Start gateway" +$gwLog = Join-Path $here "gateway.e2e.log" +$gwErrLog = "$gwLog.err" +Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + +$gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + +Info "gateway pid $($gw.Id); logs: $gwLog" + +$results = @() + +try { + # Wait for listening + $deadline = (Get-Date).AddSeconds(30) + $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { + Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($gw.ExitCode)). See log." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + $ready = $true + break + } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start within 30 s." } + Ok "gateway listening on $Port" + + # Register CLI + Step "Register CLI" + $env:OPENSHELL_GATEWAY = "" + try { & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway add: $($_.Exception.Message) (continuing)" } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + Ok "CLI registered" + + # ── Scenario definitions ────────────────────────────────────────────────── + # + # Each scenario is a hashtable: + # Name - unique identifier + # PolicyFile - path to the policy YAML fixture + # Backends - list: "both" / "process_container" / "isolation_session" + # ExpectFail - $true means `sandbox create` itself must fail (invalid_argument) + # ExpectArtifact - whether the workload should create its target file + # Description - human-readable label + + $allScenarios = @( + @{ + Name = "fs-rw" + PolicyFile = Join-Path $policyDir "fs-rw.yaml" + Backends = "both" + ExpectFail = $false + ExpectArtifact = $true + Description = "rw grant on DemoDir; in-policy write should succeed" + }, + @{ + Name = "fs-readonly" + PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + Backends = "both" + ExpectFail = $false + ExpectArtifact = $true + Description = "ro grant + rw share; write to ro dir should be denied" + }, + @{ + Name = "fs-default-deny" + PolicyFile = Join-Path $policyDir "fs-empty.yaml" + Backends = "process_container" + ExpectFail = $false + ExpectArtifact = $false + Description = "empty filesystem policy; all writes denied (process_container only)" + }, + @{ + Name = "network-reject" + PolicyFile = Join-Path $policyDir "network-reject.yaml" + Backends = "both" + ExpectFail = $true + Description = "network_policies rule causes sandbox create to fail (no live backend needed)" + } + ) + + # Apply optional scenario filter. + if ($Scenario) { + $filtered = $allScenarios | Where-Object { $_.Name -eq $Scenario } + if ($filtered.Count -eq 0) { + throw "Scenario '$Scenario' not found. Available: $(($allScenarios | ForEach-Object { $_.Name }) -join ', ')" + } + $allScenarios = $filtered + } + + # ── Run scenarios ───────────────────────────────────────────────────────── + + foreach ($sc in $allScenarios) { + Step "Scenario: $($sc.Name)" + Info $sc.Description + + # Backend gate: skip enforcement scenarios when backend not live (and not mock and not ExpectFail). + $skipReason = $null + if (-not $sc.ExpectFail) { + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live -and -not $Mock) { + $skipReason = "backend not live: $($backendProbe.Reason)" + } + } + + if ($null -ne $skipReason) { + Skip "$($sc.Name): $skipReason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "SKIP"; Reason = $skipReason } + continue + } + + # Policy file must exist. + if (-not (Test-Path $sc.PolicyFile)) { + Bad "$($sc.Name): policy fixture not found at $($sc.PolicyFile)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "policy fixture missing" } + continue + } + + # Build per-sandbox MXC workload config. Commands and working directories + # are create-time inputs, not gateway-wide settings. + $target = Join-Path $DemoDir "$($sc.Name)-result.txt" + Remove-Item $target -Force -ErrorAction SilentlyContinue + $targetFwd = $target.Replace('\', '/') + $demoDirFwd = $DemoDir.Replace('\', '/') + $driverConfig = @{ + mxc = @{ + command = @("cmd", "/c", "echo.ok>$targetFwd") + cwd = $demoDirFwd + } + } | ConvertTo-Json -Compress -Depth 4 + # Windows PowerShell 5.1 removes embedded quotes when it builds the + # native command line. Escape them so the CLI receives valid JSON. + $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { + $driverConfig.Replace('"', '\"') + } else { + $driverConfig + } + # Run sandbox create. + $createOut = $null + $createExitCode = 0 + try { + $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message + $createExitCode = 1 + } + $createOutStr = ($createOut -join "`n") + Info "create exit: $createExitCode" + + # Delete sandbox (best-effort; no-op if create failed). + try { & $cli sandbox delete $sc.Name 2>&1 | Out-Null } catch {} + + # Evaluate. + if ($sc.ExpectFail) { + # network-reject: create must fail. + if ($createExitCode -ne 0) { + Ok "$($sc.Name): create correctly failed (exit $createExitCode)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "create failed as expected" } + } else { + Bad "$($sc.Name): create succeeded but should have failed" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + } + } else { + # Wiring check in mock mode: the artifact must match the policy's + # expected outcome. In particular, default-deny passes only when + # the workload cannot create its target file. + if ($Mock) { + if ($sc.ExpectArtifact) { + $deadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 300 + } + } + $artifactExists = Test-Path $target + if ($artifactExists -eq $sc.ExpectArtifact) { + $outcome = if ($artifactExists) { "present" } else { "absent" } + Ok "$($sc.Name): artifact $outcome as expected (mock wiring OK)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "mock wiring: artifact $outcome as expected" } + } else { + Bad "$($sc.Name): artifact outcome did not match policy (present=$artifactExists, expected=$($sc.ExpectArtifact))" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "mock wiring: artifact present=$artifactExists, expected=$($sc.ExpectArtifact)" } + } + } else { + # Real mode: artifact presence == enforcement worked. + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 500 + } + if ($createExitCode -eq 0 -and (Test-Path $target)) { + Ok "$($sc.Name): in-policy write succeeded" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "in-policy write produced artifact" } + } else { + Bad "$($sc.Name): FAIL (create=$createExitCode, artifact=$(Test-Path $target))" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create=$createExitCode, artifact=$(Test-Path $target)" } + } + } + } + } + +} finally { + if ($KeepRunning) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning)" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup" + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + Info "stopped gateway pid $($gw.Id)" + } +} + +# ── Summary table ───────────────────────────────────────────────────────────── + +Step "Summary" +$results | Format-Table -AutoSize + +$failCount = ($results | Where-Object { $_.Result -eq "FAIL" }).Count +$passCount = ($results | Where-Object { $_.Result -eq "PASS" }).Count +$skipCount = ($results | Where-Object { $_.Result -eq "SKIP" }).Count + +Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" + +if ($failCount -gt 0) { + Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nALL SCENARIOS PASSED (or SKIPPED)" -ForegroundColor Green + exit 0 +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs new file mode 100644 index 0000000000..1ded7130c4 --- /dev/null +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -0,0 +1,1246 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, +//! and self-reported readiness. + +use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use futures::Stream; +use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_core::proto::SandboxPolicy; +use openshell_core::proto::compute::v1::{ + DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, + GetCapabilitiesResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, + WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, +}; +use openshell_core::proto_struct::struct_to_json_value; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::{Mutex, broadcast, mpsc, watch}; +use tokio::task::JoinHandle; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{info, warn}; + +const DRIVER_NAME: &str = "mxc"; +const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Sentinel image name — MXC has no OCI image; this string must be non-empty +/// so the gateway's `default_image` cache is satisfied, but it is not pullable. +const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; + +// ── Config ──────────────────────────────────────────────────────────────────── + +/// Which MXC backend the driver targets. +/// +/// - `IsolationSession`: persistent, attachable session +/// (provision → start → exec → stop → deprovision). Grant-only filesystem +/// policy — it has no deny primitive and is NOT default-deny. +/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a +/// write to any ungranted path is denied by the OS. No persistent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MxcBackend { + IsolationSession, + #[default] + ProcessContainer, +} + +/// Configuration for the MXC compute driver. +/// +/// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from +/// environment variables / CLI flags via the standard gateway precedence chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct MxcComputeConfig { + /// Path to `wxc-exec.exe`. Required for live runs. + pub wxc_exec_path: String, + /// Backend to target. Default: `process_container`. + pub backend: MxcBackend, + /// `processContainer` only: request a Less-Privileged `AppContainer`. + pub pc_least_privilege: bool, + /// `processContainer` only: `AppContainer` capabilities to grant. + pub pc_capabilities: Vec, + /// MXC `configurationId` for isolation session. Default: `"composable"`. + /// Never use `"small"` (known OS bug). + pub default_configuration_id: String, + + /// Enable `--debug` flag on `wxc-exec` invocations. + pub debug: bool, +} + +impl Default for MxcComputeConfig { + fn default() -> Self { + Self { + wxc_exec_path: "wxc-exec.exe".into(), + backend: MxcBackend::default(), + pc_least_privilege: false, + pc_capabilities: Vec::new(), + default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + + debug: false, + } + } +} + +/// Per-sandbox MXC workload settings supplied through +/// `template.driver_config.mxc` / `--driver-config-json`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct MxcSandboxConfig { + command: Vec, + #[serde(default)] + cwd: String, +} + +// ── Registry entry ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PhaseState { + Starting, + Running, + Stopped, + Failed(String), +} + +struct SandboxEntry { + sandbox: DriverSandbox, + iso_sandbox_id: Option, + isolation_stopped: bool, + phase_state: PhaseState, + /// Serializes stop/delete with provisioning and process launch. + lifecycle_gate: Arc>, + monitor_cancel: Option>, + monitor_task: Option>, +} + +impl std::fmt::Debug for SandboxEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SandboxEntry") + .field("sandbox_id", &self.sandbox.id) + .field("iso_sandbox_id", &self.iso_sandbox_id) + .field("isolation_stopped", &self.isolation_stopped) + .field("phase_state", &self.phase_state) + .finish_non_exhaustive() + } +} + +// ── Watch stream helpers ────────────────────────────────────────────────────── + +pub type WatchStream = Pin< + Box> + Send>, +>; + +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), + } +} + +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), + } +} + +fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + WatchSandboxesPlatformEvent { + sandbox_id, + event: Some(DriverPlatformEvent { + timestamp_ms: 0, + source: "mxc-driver".into(), + r#type: "Warning".into(), + reason: reason.to_string(), + message, + metadata: HashMap::new(), + }), + }, + )), + } +} + +// ── Driver ──────────────────────────────────────────────────────────────────── + +/// In-process MXC compute driver. +pub struct MxcComputeBackend { + config: MxcComputeConfig, + invoker: WxcExecInvoker, + registry: Arc>>, + watch_tx: Arc>, + policy_mapper: Arc, +} + +impl std::fmt::Debug for MxcComputeBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MxcComputeBackend") + .field("wxc_exec_path", &self.config.wxc_exec_path) + .finish_non_exhaustive() + } +} + +fn sandbox_config(sandbox: &DriverSandbox) -> Result { + let config = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with a non-empty command array", + ) + })?; + let config: MxcSandboxConfig = + serde_json::from_value(struct_to_json_value(config)).map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?; + if config.command.is_empty() || config.command[0].is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.command must contain a non-empty executable", + )); + } + Ok(config) +} + +fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { + let Some(spec) = sandbox.spec.as_ref() else { + return Vec::new(); + }; + let mut environment = spec + .template + .as_ref() + .map_or_else(HashMap::new, |template| template.environment.clone()); + environment.extend(spec.environment.clone()); + let mut environment = environment + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>(); + environment.sort_unstable(); + environment +} + +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|arg| quote_windows_argument(arg)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(arg: &str) -> String { + if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { + return arg.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in arg.chars() { + match ch { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(ch); + } + } + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} +impl MxcComputeBackend { + pub fn new(config: MxcComputeConfig) -> Self { + let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); + let (watch_tx, _) = broadcast::channel(256); + Self { + invoker, + config, + registry: Arc::new(Mutex::new(HashMap::new())), + watch_tx: Arc::new(watch_tx), + // Production policy translation is always handled by the embedded + // mapper before any MXC lifecycle side effects begin. + policy_mapper: Arc::new(EmbeddedPolicyMapper), + } + } + + /// 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); + backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); + backend + } + + pub fn capabilities(&self) -> GetCapabilitiesResponse { + GetCapabilitiesResponse { + driver_name: DRIVER_NAME.to_string(), + driver_version: DRIVER_VERSION.to_string(), + default_image: DEFAULT_IMAGE_SENTINEL.to_string(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: true, + } + } + + fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + if let Some(spec) = &sandbox.spec { + if effective_driver_gpu_count(driver_gpu_requirements( + spec.resource_requirements.as_ref(), + )) + .map_err(tonic::Status::invalid_argument)? + .is_some() + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support GPU sandboxes", + )); + } + if let Some(tmpl) = &spec.template + && !tmpl.agent_socket_path.is_empty() + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", + )); + } + } + sandbox_config(sandbox)?; + Ok(()) + } + + fn map_sandbox_policy( + &self, + sandbox_id: &str, + policy: Option<&SandboxPolicy>, + ) -> Result { + self.policy_mapper + .map( + policy, + &MapCtx { + sandbox_id: sandbox_id.to_string(), + egress: None, + }, + ) + .map_err(|error| tonic::Status::invalid_argument(error.to_string())) + } + + pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + self.validate_sandbox_fields(sandbox)?; + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + self.map_sandbox_policy(&sandbox.id, policy)?; + Ok(()) + } + pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { + let registry = self.registry.lock().await; + registry + .values() + .find(|e| e.sandbox.name == sandbox_name) + .map(|e| e.sandbox.clone()) + } + + pub async fn list_sandboxes(&self) -> Vec { + let registry = self.registry.lock().await; + registry.values().map(|e| e.sandbox.clone()).collect() + } + + pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + let sandbox_id = sandbox.id.clone(); + + self.validate_sandbox_fields(sandbox)?; + let sandbox_config = sandbox_config(sandbox)?; + + // Policy translation is deterministic and side-effect free. Do it before + // inserting the registry entry or launching MXC so invalid requests fail + // synchronously at the CreateSandbox boundary. + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + let mapped = self.map_sandbox_policy(&sandbox_id, policy)?; + + if sandbox + .spec + .as_ref() + .is_none_or(|spec| spec.sandbox_token.is_empty()) + { + tracing::debug!( + sandbox = %sandbox.name, + "no sandbox_token minted (no supervisor consumer on MXC)" + ); + } + + let sandbox_name = sandbox.name.clone(); + let lifecycle_gate = Arc::new(Mutex::new(())); + // Take the gate before publishing the entry. stop/delete can discover the + // sandbox immediately, but cannot pass this guard until startup has either + // installed a cancellable child monitor or failed. + let startup_guard = lifecycle_gate.clone().lock_owned().await; + { + let mut registry = self.registry.lock().await; + if registry.contains_key(&sandbox_id) { + return Err(tonic::Status::already_exists(format!( + "sandbox {sandbox_name} already exists" + ))); + } + let initial = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Starting".into(), + message: "MXC lifecycle starting".into(), + last_transition_time: String::new(), + }, + false, + ); + let _ = self.watch_tx.send(sandbox_event(initial.clone())); + registry.insert( + sandbox_id.clone(), + SandboxEntry { + sandbox: initial, + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Starting, + lifecycle_gate, + monitor_cancel: None, + monitor_task: None, + }, + ); + } + + let invoker = self.invoker.clone(); + let config = self.config.clone(); + let registry = self.registry.clone(); + let watch_tx = self.watch_tx.clone(); + let sandbox = sandbox.clone(); + tokio::spawn(async move { + run_lifecycle( + invoker, + config, + registry, + watch_tx, + sandbox, + sandbox_config, + mapped, + startup_guard, + ) + .await; + }); + + Ok(()) + } + pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { + let (sandbox_id, lifecycle_gate) = { + let registry = self.registry.lock().await; + let entry = registry + .values() + .find(|entry| entry.sandbox.name == sandbox_name) + .ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) + }; + + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.take(), + ) + }; + if let Some(cancel) = cancel { + let _ = cancel.send(true); + } + if let Some(task) = monitor_task { + task.await.map_err(|error| { + tonic::Status::internal(format!("mxc process monitor failed: {error}")) + })?; + } + if let Some(ref iso_id) = iso_id + && !isolation_stopped + { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + isolation_stopped = true; + } + + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.isolation_stopped = isolation_stopped; + entry.phase_state = PhaseState::Stopped; + entry.sandbox = make_sandbox_with_condition( + &entry.sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Stopped".into(), + message: "MXC sandbox stopped".into(), + last_transition_time: String::new(), + }, + false, + ); + let snapshot = entry.sandbox.clone(); + drop(registry); + let _ = self.watch_tx.send(sandbox_event(snapshot)); + } + Ok(()) + } + pub async fn delete_sandbox( + &self, + sandbox_id: &str, + sandbox_name: &str, + ) -> Result { + let lifecycle_gate = { + let registry = self.registry.lock().await; + let Some(entry) = registry.get(sandbox_id) else { + return Ok(false); + }; + if entry.sandbox.name != sandbox_name { + return Err(tonic::Status::failed_precondition( + "sandbox_id did not match sandbox_name", + )); + } + entry.lifecycle_gate.clone() + }; + + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let Some(entry) = registry.get_mut(sandbox_id) else { + return Ok(false); + }; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.take(), + ) + }; + if let Some(cancel) = cancel { + let _ = cancel.send(true); + } + if let Some(task) = monitor_task { + task.await.map_err(|error| { + tonic::Status::internal(format!("mxc process monitor failed: {error}")) + })?; + } + if let Some(ref iso_id) = iso_id { + if !isolation_stopped { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + // Persist phase progress before deprovision. If deprovision + // fails, a retry resumes here instead of stopping twice. + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(sandbox_id) { + entry.isolation_stopped = true; + } + } + self.invoker.deprovision(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) + })?; + } + + let mut registry = self.registry.lock().await; + if registry.remove(sandbox_id).is_some() { + let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); + return Ok(true); + } + Ok(false) + } + /// Returns a stream of watch events. + /// + /// First emits a snapshot of all current sandboxes, then forwards live + /// events from the broadcast channel. + pub async fn watch_sandboxes(&self) -> WatchStream { + let (tx, rx) = + mpsc::channel::>(256); + + // Subscribe while holding the registry lock. Every transition is then + // represented by either this snapshot or the live receiver. + let (snapshots, mut broadcast_rx): (Vec, _) = { + let registry = self.registry.lock().await; + let broadcast_rx = self.watch_tx.subscribe(); + let snapshots = registry + .values() + .map(|entry| entry.sandbox.clone()) + .collect(); + (snapshots, broadcast_rx) + }; + + let tx_clone = tx.clone(); + tokio::spawn(async move { + // Deliver initial snapshots. + for sb in snapshots { + if tx_clone.send(Ok(sandbox_event(sb))).await.is_err() { + return; + } + } + // Forward live events. + loop { + match broadcast_rx.recv().await { + Ok(event) => { + if tx_clone.send(Ok(event)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + // Drop lagged events — the gateway re-syncs via Get/List. + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + Box::pin(ReceiverStream::new(rx)) + } +} + +// ── Lifecycle task ──────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn run_lifecycle( + invoker: WxcExecInvoker, + config: MxcComputeConfig, + registry: Arc>>, + watch_tx: Arc>, + sandbox: DriverSandbox, + sandbox_config: MxcSandboxConfig, + mapped: MappedConfig, + _startup_guard: tokio::sync::OwnedMutexGuard<()>, +) { + let sandbox_id = sandbox.id.clone(); + let sandbox_name = sandbox.name.clone(); + let filesystem = MxcFilesystem { + readwrite_paths: mapped.readwrite_paths, + readonly_paths: mapped.readonly_paths, + // OpenShell's policy model has no explicit deny field; default-deny is + // implicit and enforced by processContainer at the OS boundary. + denied_paths: Vec::new(), + }; + let command_line = encode_windows_command_line(&sandbox_config.command); + let process = MxcProcess { + command_line: command_line.clone(), + cwd: sandbox_config.cwd, + env: sandbox_environment(&sandbox), + timeout: 0, + }; + + let child = match config.backend { + MxcBackend::IsolationSession => { + let iso_sandbox_id = match invoker + .provision(&config.default_configuration_id, filesystem, None) + .await + { + Ok(id) => id, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + }; + info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); + { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + // Publish cleanup identity before any later lifecycle await. + entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); + entry.isolation_stopped = false; + } + } + if let Err(error) = invoker.start(&iso_sandbox_id).await { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + info!(sandbox = %sandbox_name, "MXC started"); + match invoker.spawn_exec(&iso_sandbox_id, process).await { + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + } + } + MxcBackend::ProcessContainer => { + let process_container = MxcProcessContainer { + least_privilege: config.pc_least_privilege, + capabilities: config.pc_capabilities.clone(), + }; + match invoker + .run_oneshot(&sandbox_id, filesystem, process_container, process, None) + .await + { + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + } + } + }; + info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); + + let ready_sandbox = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentRunning".into(), + message: format!("Agent exec launched: {command_line}"), + last_transition_time: String::new(), + }, + false, + ); + let (cancel_tx, cancel_rx) = watch::channel(false); + { + // Publish cancellation state before the monitor can observe a fast + // process exit. Holding the registry lock while spawning prevents a + // completed child from being overwritten with AgentRunning. + let mut registry_guard = registry.lock().await; + if let Some(entry) = registry_guard.get_mut(&sandbox_id) { + entry.sandbox = ready_sandbox.clone(); + entry.phase_state = PhaseState::Running; + entry.monitor_cancel = Some(cancel_tx); + entry.monitor_task = Some(tokio::spawn(monitor_exec( + registry.clone(), + watch_tx.clone(), + sandbox.clone(), + sandbox_id.clone(), + cancel_rx, + child, + ))); + } + } + let _ = watch_tx.send(sandbox_event(ready_sandbox)); +} + +async fn monitor_exec( + registry: Arc>>, + watch_tx: Arc>, + sandbox: DriverSandbox, + sandbox_id: String, + mut cancel_rx: watch::Receiver, + mut child: tokio::process::Child, +) { + let status = tokio::select! { + status = child.wait() => status, + changed = cancel_rx.changed() => { + let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); + if should_kill { + if let Err(error) = child.kill().await { + warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); + } + // `kill` waits on current Tokio releases, but an explicit wait is + // harmless and guarantees the OS process handle is reaped. + let _ = child.wait().await; + } + return; + } + }; + + match status { + Ok(status) if status.success() => { + info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); + let done = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentCompleted".into(), + message: "Agent exec finished successfully (exit code 0)".into(), + last_transition_time: String::new(), + }, + false, + ); + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.sandbox = done.clone(); + entry.phase_state = PhaseState::Running; + } + drop(registry); + let _ = watch_tx.send(sandbox_event(done)); + } + Ok(status) => { + let code = status.code().unwrap_or(-1); + warn!(sandbox = %sandbox.name, exit_code = code, "MXC agent exec exited non-zero"); + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "AgentExecFailed", + format!("agent exited with code {code}; possible out-of-policy write"), + )); + let failed = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ExecFailed".into(), + message: format!("Agent exec exited {code}"), + last_transition_time: String::new(), + }, + false, + ); + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(format!("exit code {code}")); + } + drop(registry); + let _ = watch_tx.send(sandbox_event(failed)); + } + Err(error) => { + warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); + } + } +} +async fn set_failed( + registry: &Arc>>, + watch_tx: &Arc>, + sandbox: &DriverSandbox, + sandbox_id: &str, + message: &str, +) { + warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); + let failed = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ProvisionFailed".into(), + message: message.to_string(), + last_transition_time: String::new(), + }, + false, + ); + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(message.to_string()); + } + drop(reg); + let _ = watch_tx.send(sandbox_event(failed)); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn make_sandbox_with_condition( + base: &DriverSandbox, + condition: &DriverCondition, + deleting: bool, +) -> DriverSandbox { + DriverSandbox { + id: base.id.clone(), + name: base.name.clone(), + namespace: base.namespace.clone(), + workspace: base.workspace.clone(), + spec: base.spec.clone(), + status: Some(DriverSandboxStatus { + sandbox_name: base.name.clone(), + instance_id: String::new(), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![condition.clone()], + deleting, + }), + } +} + +// ── Lifecycle + policy-proof tests (mock wxc-exec) ───────────────────────────── +// +// These drive the full create → provision → start → exec → self-report Ready +// flow against the in-process mock shim, proving the positive (in-policy write +// succeeds, Ready reached) and negative (out-of-policy write denied + denial +// event) paths WITHOUT the demo box. Windows-only (the crate is Windows-gated), +// run by the `windows:test:x64` mise lane. +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use futures::StreamExt; + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; + use std::time::Duration; + + fn driver_sandbox(id: &str) -> DriverSandbox { + driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + } + + fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { + let serde_json::Value::Object(driver_config) = serde_json::json!({ + "command": command, + "cwd": cwd, + }) else { + unreachable!(); + }; + DriverSandbox { + id: id.to_string(), + name: id.to_string(), + namespace: String::new(), + workspace: String::new(), + spec: Some(DriverSandboxSpec { + sandbox_token: "test-token".into(), + template: Some(DriverSandboxTemplate { + driver_config: Some( + openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), + ), + ..Default::default() + }), + ..Default::default() + }), + status: None, + } + } + fn fs_policy(read_write: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: read_write.iter().map(ToString::to_string).collect(), + }), + ..Default::default() + } + } + + fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { + sandbox.spec.as_mut().unwrap().policy = Some(policy); + sandbox + } + + fn ready_condition(sb: &DriverSandbox) -> Option { + sb.status + .as_ref()? + .conditions + .iter() + .find(|c| c.r#type == "Ready") + .cloned() + } + + /// Poll the backend registry until the predicate matches or the deadline hits. + async fn wait_for( + backend: &MxcComputeBackend, + name: &str, + mut pred: F, + ) -> Option + where + F: FnMut(&DriverSandbox) -> bool, + { + for _ in 0..100 { + if let Some(sandbox) = backend.get_sandbox(name).await + && pred(&sandbox) + { + return Some(sandbox); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + None + } + + #[test] + fn mxc_config_defaults_to_default_deny_process_container() { + assert_eq!( + MxcComputeConfig::default().backend, + MxcBackend::ProcessContainer + ); + } + + #[test] + fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { + let mut sandbox = driver_sandbox("sb-env"); + let spec = sandbox.spec.as_mut().unwrap(); + spec.template + .as_mut() + .unwrap() + .environment + .insert("SHARED".into(), "template".into()); + spec.environment.insert("SHARED".into(), "spec".into()); + spec.environment.insert("TOKEN".into(), "value".into()); + assert_eq!( + sandbox_environment(&sandbox), + vec!["SHARED=spec".to_string(), "TOKEN=value".to_string()] + ); + } + + #[test] + fn windows_command_line_preserves_argument_boundaries() { + assert_eq!( + encode_windows_command_line(&[ + r"C:\Program Files\Agent\agent.exe".into(), + "hello world".into(), + String::new(), + ]), + r#""C:\Program Files\Agent\agent.exe" "hello world" """# + ); + assert_eq!( + quote_windows_argument(r#"say "hello""#), + r#""say \"hello\"""# + ); + assert_eq!( + quote_windows_argument("trailing slash\\ "), + r#""trailing slash\ ""# + ); + } + #[tokio::test] + async fn positive_in_policy_write_reaches_ready_and_materializes_file() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + let policy = fs_policy(&[&share]); + let sb = with_policy(driver_sandbox_with_command("sb-pos", &share, cmd), policy); + backend.create_sandbox(&sb).await.expect("create accepted"); + + // Self-reported Ready=True (no supervisor) once the agent exec launches. + let ready = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!(ready.is_some(), "sandbox should self-report Ready=True"); + + // Positive proof: the in-policy write materializes the host artifact. + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(found, "hello.txt should appear in the granted share folder"); + + // A successful one-shot agent (exit 0) must STAY Ready, not demote to + // Error. Assert the terminal condition is Ready=True/AgentCompleted so the + // positive demo shows a green Ready phase, not a red Error. + let completed = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") + }) + .await; + assert!( + completed.is_some(), + "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" + ); + } + + #[tokio::test] + async fn processcontainer_one_shot_in_policy_write_reaches_ready() { + // The processContainer backend skips provision/start and runs a single + // one-shot. The mock routes through `run_oneshot`, deriving grants from + // the filesystem (not a provision step), so the in-policy write should + // materialize and the sandbox should reach Ready=True. + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + let policy = fs_policy(&[&share]); + let sb = with_policy(driver_sandbox_with_command("sb-pc", &share, cmd), policy); + backend.create_sandbox(&sb).await.expect("create accepted"); + + let ready = wait_for(&backend, "sb-pc", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!( + ready.is_some(), + "processContainer sandbox should self-report Ready=True" + ); + let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); + assert!( + recorded.get("network").is_none(), + "coarse path must not emit an MXC network block" + ); + + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + found, + "in-policy write should materialize under processContainer" + ); + } + + #[tokio::test] + async fn negative_out_of_policy_write_is_denied_with_event() { + let share_tmp = tempfile::tempdir().unwrap(); + let out_tmp = tempfile::tempdir().unwrap(); + let share = share_tmp.path().to_string_lossy().replace('\\', "/"); + let out_path = format!( + "{}/hello.txt", + out_tmp.path().to_string_lossy().replace('\\', "/") + ); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {out_path} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + // Subscribe to the watch stream BEFORE create so we catch the denial event. + let mut stream = backend.watch_sandboxes().await; + + let policy = fs_policy(&[&share]); + let sandbox = with_policy(driver_sandbox_with_command("sb-neg", &share, cmd), policy); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + + // Collect events until we observe the AgentExecFailed platform event. + let mut saw_denial = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { + Ok(Some(Ok(ev))) => { + if let Some(watch_sandboxes_event::Payload::PlatformEvent(event)) = ev.payload + && event + .event + .as_ref() + .is_some_and(|event| event.reason == "AgentExecFailed") + { + saw_denial = true; + break; + } + } + Ok(_) => break, + Err(_) => {} + } + } + assert!( + saw_denial, + "expected an AgentExecFailed denial platform event" + ); + + // The out-of-policy artifact must NOT have been written by the mock. + let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); + assert!(!out_fs.exists(), "out-of-policy write must be denied"); + + // And the sandbox surfaces a terminal ExecFailed Ready=False condition. + let failed = wait_for(&backend, "sb-neg", |s| { + ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ExecFailed") + }) + .await; + assert!(failed.is_some(), "sandbox should report ExecFailed"); + } + + #[tokio::test] + async fn stop_terminates_and_reaps_a_running_process_container() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("$null = '{share}'; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy(driver_sandbox_with_command("sb-stop", "", command), policy); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-stop", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + let running = backend.get_sandbox("sb-stop").await.unwrap(); + assert_eq!(ready_condition(&running).unwrap().reason, "AgentRunning"); + + tokio::time::timeout(Duration::from_secs(5), backend.stop_sandbox("sb-stop")) + .await + .expect("stop should not wait for the child sleep") + .expect("stop should terminate and reap the child"); + let stopped = backend.get_sandbox("sb-stop").await.unwrap(); + assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); + } + + #[tokio::test] + async fn unmappable_network_policy_fails_create_lifecycle() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let sandbox = with_policy(driver_sandbox("sb-net"), policy); + let error = backend + .create_sandbox(&sandbox) + .await + .expect_err("unmappable policy must fail CreateSandbox synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(backend.get_sandbox("sb-net").await.is_none()); + } +} diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs new file mode 100644 index 0000000000..dfe8f66b29 --- /dev/null +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Thin tonic adapter: delegates to `MxcComputeBackend` and maps errors to +//! gRPC `Status`. + +#![allow(clippy::result_large_err)] + +use crate::driver::MxcComputeBackend; +use futures::{Stream, StreamExt}; +use openshell_core::proto::compute::v1::{ + AuthenticateSandboxRequest, AuthenticateSandboxResponse, CreateSandboxRequest, + CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteWorkspaceRequest, + DeleteWorkspaceResponse, EnsureWorkspaceRequest, EnsureWorkspaceResponse, + GetCapabilitiesRequest, GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, + GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, + ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, StartSandboxResponse, + StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, + ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, + compute_driver_server::ComputeDriver, +}; +use std::pin::Pin; +use tonic::{Request, Response, Status}; + +#[derive(Debug)] +pub struct ComputeDriverService { + backend: MxcComputeBackend, +} + +impl ComputeDriverService { + pub fn new(backend: MxcComputeBackend) -> Self { + Self { backend } + } +} + +#[tonic::async_trait] +impl ComputeDriver for ComputeDriverService { + async fn get_capabilities( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(self.backend.capabilities())) + } + + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "mxc does not authenticate sandbox credentials", + )) + } + + async fn get_gateway_listener_requirements( + &self, + _request: Request, + ) -> Result, Status> { + // MXC is an in-process, single-host driver: it needs no extra gateway + // listeners (no relay/surrogate/remote endpoint), so it reports none. + Ok(Response::new(GetGatewayListenerRequirementsResponse { + requirements: Vec::new(), + })) + } + + async fn validate_sandbox_create( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let sandbox = request + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.validate_sandbox_create(&sandbox)?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + } + + async fn get_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let sandbox = self + .backend + .get_sandbox(&req.sandbox_name) + .await + .ok_or_else(|| Status::not_found(format!("sandbox {} not found", req.sandbox_name)))?; + if !req.sandbox_id.is_empty() && req.sandbox_id != sandbox.id { + return Err(Status::failed_precondition( + "sandbox_id did not match the fetched sandbox", + )); + } + Ok(Response::new(GetSandboxResponse { + sandbox: Some(sandbox), + })) + } + + async fn list_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let sandboxes = self.backend.list_sandboxes().await; + Ok(Response::new(ListSandboxesResponse { sandboxes })) + } + + async fn create_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let sandbox = request + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.create_sandbox(&sandbox).await?; + Ok(Response::new(CreateSandboxResponse {})) + } + + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + self.backend.stop_sandbox(&req.sandbox_name).await?; + Ok(Response::new(StopSandboxResponse {})) + } + + async fn start_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "mxc driver does not support restarting stopped sandboxes", + )) + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let deleted = self + .backend + .delete_sandbox(&req.sandbox_id, &req.sandbox_name) + .await?; + Ok(Response::new(DeleteSandboxResponse { deleted })) + } + + type WatchSandboxesStream = + Pin> + Send + 'static>>; + + async fn watch_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let stream = self.backend.watch_sandboxes().await; + let mapped = stream.map(|item| item.map_err(|e| Status::internal(e.to_string()))); + Ok(Response::new(Box::pin(mapped))) + } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(DeleteWorkspaceResponse {})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::driver::MxcComputeConfig; + + #[tokio::test] + async fn start_sandbox_reports_one_shot_lifecycle() { + let service = + ComputeDriverService::new(MxcComputeBackend::new(MxcComputeConfig::default())); + + let error = service + .start_sandbox(Request::new(StartSandboxRequest::default())) + .await + .expect_err("MXC must not restart a stopped one-shot workload"); + + assert_eq!(error.code(), tonic::Code::Unimplemented); + assert!(error.message().contains("does not support restarting")); + } + + #[tokio::test] + async fn workspace_lifecycle_is_an_idempotent_no_op() { + let service = + ComputeDriverService::new(MxcComputeBackend::new(MxcComputeConfig::default())); + + service + .ensure_workspace(Request::new(EnsureWorkspaceRequest::default())) + .await + .expect("MXC has no driver-owned workspace resource to provision"); + service + .delete_workspace(Request::new(DeleteWorkspaceRequest::default())) + .await + .expect("MXC workspace deletion must remain idempotent"); + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs new file mode 100644 index 0000000000..dc251649f8 --- /dev/null +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `OpenShell` MXC compute driver. +//! +//! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft +//! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent +//! directly (exec-in-driver), and self-reports `Ready` — there is no +//! in-sandbox supervisor, no host-side surrogate, and no `ConnectSupervisor` +//! relay. +//! +//! This crate compiles to an **empty stub** on non-Windows targets so the +//! Linux build stays green. All implementation code is gated on +//! `#[cfg(target_os = "windows")]`. + +#![allow(clippy::result_large_err)] + +#[cfg(target_os = "windows")] +mod driver; +#[cfg(target_os = "windows")] +mod grpc; +#[cfg(target_os = "windows")] +mod mxc; +#[cfg(target_os = "windows")] +mod policy; +// Embedded mapper logic (source of truth; was the `openshell-policy-mapper` +// crate). Windows-only — MXC and the policy mapper are not built for Linux/WSL. +#[cfg(target_os = "windows")] +mod policy_map; + +#[cfg(target_os = "windows")] +pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; +#[cfg(target_os = "windows")] +pub use grpc::ComputeDriverService; +// Re-export the embedded mapper API so the windows-only example and integration +// test can reach it without making `policy_map` a public module. +#[cfg(target_os = "windows")] +pub use policy::{EmbeddedPolicyMapper, MapCtx, MapError, MappedConfig, PolicyMapper}; +#[cfg(target_os = "windows")] +pub use policy_map::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, + MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, build_loss_report, map_to_mxc, + render_readme, split_policy, +}; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs new file mode 100644 index 0000000000..32d7006d90 --- /dev/null +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -0,0 +1,867 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `wxc-exec` invoker and MXC request/response types. +//! +//! Builds state-aware MXC config JSON, base64-encodes it, runs `wxc-exec`, +//! and parses the response envelope. The exec phase is special: its stdout is +//! live process output (not JSON) and its exit code is the agent exit code. + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use thiserror::Error; +use tokio::process::Command; +use tracing::debug; + +/// MXC config schema version. +pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; + +/// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). +pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; + +/// Environment flag selecting the in-process mock `wxc-exec` shim. When set to +/// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits +/// canned provision/start/stop/deprovision results and simulates `AppContainer` +/// filesystem-policy enforcement for the exec phase. This is what makes the +/// full create → Ready → policy-proof round trip runnable off the demo box. +pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; + +fn mock_enabled() -> bool { + std::env::var(MOCK_ENV_VAR).is_ok_and(|value| value == "1") +} + +/// Normalize a path/command fragment to lowercase backslash form for the mock's +/// in-policy substring check. +fn mock_normalize(s: &str) -> String { + s.replace('/', "\\").to_lowercase() +} + +/// Per-process mock state: `iso:` sandbox id → granted read-write paths +/// (normalized). Populated by the mock provision, consumed by the mock exec to +/// decide whether the agent's write target is in-policy. +fn mock_grants() -> &'static Mutex>> { + static GRANTS: OnceLock>>> = OnceLock::new(); + GRANTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +// ── Request types ───────────────────────────────────────────────────────────── + +/// Filesystem shares for the sandbox. +/// +/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no +/// deny primitive). `processContainer` additionally honors `denied_paths` +/// because the `AppContainer` backend can stamp deny ACEs; it is also genuinely +/// default-deny, so anything not granted is already inaccessible. +#[derive(Debug, Default)] +#[allow(clippy::struct_field_names)] +pub struct MxcFilesystem { + pub readwrite_paths: Vec, + pub readonly_paths: Vec, + pub denied_paths: Vec, +} + +/// Network redirect fragment emitted when governed egress is enabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcNetwork { + pub default_policy: String, + pub proxy: Option, +} + +/// `processContainer`-specific knobs (one-shot `AppContainer` backend). +#[derive(Debug, Default, Clone)] +pub struct MxcProcessContainer { + /// Request a Less-Privileged `AppContainer` (stricter default-deny). + pub least_privilege: bool, + /// `AppContainer` capabilities to grant (e.g. `internetClient`). + pub capabilities: Vec, +} + +/// Process config for the exec phase. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MxcProcess { + pub command_line: String, + pub cwd: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub env: Vec, + /// 0 = no timeout (long-lived agent). + pub timeout: u64, +} + +fn network_json(network: &MxcNetwork) -> serde_json::Value { + // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. + // {"host": ..., "port": ...} and every other shape is rejected — verified + // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. + // The MxcNetwork.proxy field remains SocketAddr so callers keep full + // precision; only the port is serialized into the localhost key. + let mut value = serde_json::json!({ + "defaultPolicy": network.default_policy.as_str(), + "allowedHosts": [], + "blockedHosts": [], + }); + if let Some(proxy) = network.proxy { + value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); + } + value +} + +fn provision_config_json( + configuration_id: &str, + filesystem: &MxcFilesystem, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": &filesystem.readwrite_paths, + "readonlyPaths": &filesystem.readonly_paths, + }, + "experimental": { + "isolation_session": { + "configurationId": configuration_id, + "provision": {} + } + } + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +fn oneshot_config_json( + container_id: &str, + filesystem: &MxcFilesystem, + pc: &MxcProcessContainer, + process: &MxcProcess, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut filesystem_json = serde_json::Map::new(); + if !filesystem.readwrite_paths.is_empty() { + filesystem_json.insert( + "readwritePaths".into(), + filesystem.readwrite_paths.clone().into(), + ); + } + if !filesystem.readonly_paths.is_empty() { + filesystem_json.insert( + "readonlyPaths".into(), + filesystem.readonly_paths.clone().into(), + ); + } + if !filesystem.denied_paths.is_empty() { + filesystem_json.insert("deniedPaths".into(), filesystem.denied_paths.clone().into()); + } + + let mut pc_json = serde_json::Map::new(); + pc_json.insert("leastPrivilege".into(), pc.least_privilege.into()); + if !pc.capabilities.is_empty() { + pc_json.insert("capabilities".into(), pc.capabilities.clone().into()); + } + + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": container_id, + "containment": "processcontainer", + "process": { + "commandLine": process.command_line.as_str(), + "cwd": process.cwd.as_str(), + "env": &process.env, + "timeout": process.timeout, + }, + "processContainer": serde_json::Value::Object(pc_json), + "filesystem": serde_json::Value::Object(filesystem_json), + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +#[cfg(test)] +fn mock_configs() -> &'static Mutex> { + static CONFIGS: OnceLock>> = OnceLock::new(); + CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +pub fn mock_recorded_config(id: &str) -> Option { + mock_configs().lock().unwrap().get(id).cloned() +} + +// ── Response envelope ───────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ProvisionResult { + #[serde(rename = "sandboxId")] + pub sandbox_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MxcEnvelope { + Ok { + #[allow(dead_code)] + result: serde_json::Value, + }, + Err { + error: MxcErrorBody, + }, +} + +#[derive(Debug, Deserialize)] +pub struct MxcErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct ProvisionEnvelope { + pub result: Option, + pub error: Option, +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Error)] +pub enum InvokerError { + #[error("wxc-exec spawn failed: {0}")] + Spawn(#[from] std::io::Error), + #[error("wxc-exec config serialization failed: {0}")] + Serialize(#[from] serde_json::Error), + #[error("wxc-exec envelope parse failed (stdout={stdout:?}): {source}")] + Parse { + stdout: String, + source: serde_json::Error, + }, + #[error("wxc-exec process failed with no envelope (exit={exit_code}, stderr={stderr:?})")] + NoEnvelope { exit_code: i32, stderr: String }, + #[error("MXC error [{code}]: {message}")] + Mxc { code: String, message: String }, + /// Exec phase returned a non-zero exit code (the agent's own exit status). + /// Surfaced through the watch stream rather than as a gRPC error. + #[allow(dead_code)] + #[error("wxc-exec exec phase exited with code {0}")] + ExecNonZero(i32), +} + +impl InvokerError { + #[allow(dead_code)] + pub fn to_tonic_status(&self) -> tonic::Status { + match self { + Self::Mxc { code, message } => match code.as_str() { + "malformed_request" | "unsupported_phase" => { + tonic::Status::internal(format!("driver bug: {message}")) + } + "unsupported_containment" + | "not_provisioned" + | "not_started" + | "already_started" + | "already_stopped" => tonic::Status::failed_precondition(message.clone()), + "malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()), + "policy_validation" => tonic::Status::invalid_argument(message.clone()), + "backend_unavailable" => tonic::Status::unavailable(message.clone()), + _ => tonic::Status::internal(message.clone()), + }, + Self::Spawn(e) => tonic::Status::internal(format!("wxc-exec spawn: {e}")), + Self::Serialize(e) => tonic::Status::internal(format!("config serialize: {e}")), + Self::Parse { .. } | Self::NoEnvelope { .. } => { + tonic::Status::internal(self.to_string()) + } + Self::ExecNonZero(code) => { + tonic::Status::internal(format!("agent exited with code {code}")) + } + } + } +} + +// ── Invoker ─────────────────────────────────────────────────────────────────── + +/// Wraps `wxc-exec` invocations for the MXC state-aware lifecycle. +#[derive(Debug, Clone)] +pub struct WxcExecInvoker { + exec_path: PathBuf, + debug: bool, + /// When true, use the in-process mock instead of spawning `wxc-exec.exe`. + mock: bool, +} + +impl WxcExecInvoker { + pub fn new(exec_path: impl Into, debug: bool) -> Self { + Self { + exec_path: exec_path.into(), + debug, + mock: mock_enabled(), + } + } + + /// Test-only constructor that forces mock mode without touching the + /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across + /// parallel tests under edition 2024's `unsafe` `set_var`). + #[cfg(test)] + pub(crate) fn mocked(exec_path: impl Into) -> Self { + Self { + exec_path: exec_path.into(), + debug: false, + mock: true, + } + } + + /// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope. + /// Use this for all **non-exec** phases (provision/start/stop/deprovision). + pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> { + if self.mock { + // Mock start/stop/deprovision: canned `{"result":{}}` success. + debug!(phase = ?config.get("phase"), "mock wxc-exec phase (no-op success)"); + return Ok(()); + } + let json = serde_json::to_string(config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec phase"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + if let Ok(MxcEnvelope::Err { error }) = serde_json::from_str::(&stdout) { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); + } + let code = output.status.code().unwrap_or(-1); + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + // Success — parse envelope to surface any embedded error field. + match serde_json::from_str::(&stdout) { + Ok(MxcEnvelope::Err { error }) => Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }), + Ok(MxcEnvelope::Ok { .. }) => Ok(()), + Err(_) if stdout.trim().is_empty() => { + // Some phases return empty stdout on success. + Ok(()) + } + Err(e) => Err(InvokerError::Parse { stdout, source: e }), + } + } + + /// Run the provision phase and return the `sandboxId` from the response. + pub async fn provision( + &self, + configuration_id: &str, + filesystem: MxcFilesystem, + network: Option, + ) -> Result { + if self.mock { + // Mock provision: mint a synthetic `iso:` id and record the granted + // read-write paths so the mock exec can enforce the policy. + let id = format!("iso:mock-{}", uuid::Uuid::new_v4()); + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + mock_grants().lock().unwrap().insert(id.clone(), grants); + #[cfg(test)] + { + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + mock_configs().lock().unwrap().insert(id.clone(), config); + } + debug!(sandbox_id = %id, "mock wxc-exec provision"); + return Ok(id); + } + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec provision"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + if let Ok(ProvisionEnvelope { + error: Some(error), .. + }) = serde_json::from_str::(&stdout) + { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); + } + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + let env: ProvisionEnvelope = + serde_json::from_str(&stdout).map_err(|e| InvokerError::Parse { + stdout: stdout.clone(), + source: e, + })?; + + if let Some(err) = env.error { + return Err(InvokerError::Mxc { + code: err.code, + message: err.message, + }); + } + + env.result + .map(|r| r.sandbox_id) + .ok_or_else(|| InvokerError::NoEnvelope { + exit_code: 0, + stderr: "provision result missing sandboxId".to_string(), + }) + } + + /// Run the start phase for an already-provisioned sandbox. + pub async fn start(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "start", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + self.run_phase(&config).await + } + + /// Spawn the exec phase (agent command). Returns the child process handle. + /// **Stdout is raw agent output, not a JSON envelope. Exit code == agent exit code.** + pub async fn spawn_exec( + &self, + iso_sandbox_id: &str, + process: MxcProcess, + ) -> Result { + if self.mock { + return Self::mock_spawn_exec(iso_sandbox_id, &process); + } + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "exec", + "sandboxId": iso_sandbox_id, + "process": { + "commandLine": process.command_line, + "cwd": process.cwd, + "env": process.env, + "timeout": process.timeout, + } + }); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if self.debug { + cmd.arg("--debug"); + } + + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); + let child = cmd.spawn()?; + Ok(child) + } + + /// Mock exec: simulate `AppContainer` filesystem-policy enforcement. + /// + /// The agent's write target is considered **in-policy** iff the command line + /// references one of the granted read-write paths recorded at mock provision. + fn mock_spawn_exec( + iso_sandbox_id: &str, + process: &MxcProcess, + ) -> Result { + let grants = mock_grants() + .lock() + .unwrap() + .get(iso_sandbox_id) + .cloned() + .unwrap_or_default(); + Self::mock_spawn_with_grants(process, &grants) + } + + /// Shared mock enforcement used by both the `isolation_session` exec phase + /// and the one-shot `processContainer` path. + /// + /// In-policy → run the real agent command (so the positive-proof artifact, + /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy + /// → refuse with an access-denied message on stderr and a non-zero exit, + /// mirroring how the `AppContainer` denies the write on the demo box. + fn mock_spawn_with_grants( + process: &MxcProcess, + grants: &[String], + ) -> Result { + let cmd_norm = mock_normalize(&process.command_line); + let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); + + let mut cmd = Command::new("cmd"); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if in_policy { + debug!(command = %process.command_line, "mock exec: in-policy, running agent"); + // `command_line` is already encoded with Windows quoting rules. + // Pass it raw so this mock matches wxc-exec/CreateProcess instead + // of asking Rust to quote the entire command as one cmd.exe argv. + cmd.raw_arg(format!("/d /s /c \"{}\"", process.command_line)); + } else { + debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); + cmd.arg("/c").arg( + "echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1", + ); + } + let child = cmd.spawn()?; + Ok(child) + } + + /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. + /// + /// Unlike the `isolation_session` lifecycle (provision → start → exec → + /// stop → deprovision), `processContainer` is a single ephemeral + /// `AppContainer`: one `wxc-exec` invocation creates the container, runs the + /// one process, and tears down when it exits. The `AppContainer` is genuinely + /// default-deny, so a write to any ungranted path is denied by the OS. + /// + /// **Stdout is raw agent output; the exit code is the agent's own exit code.** + pub async fn run_oneshot( + &self, + container_id: &str, + filesystem: MxcFilesystem, + pc: MxcProcessContainer, + process: MxcProcess, + network: Option, + ) -> Result { + let config = + oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + if self.mock { + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + #[cfg(test)] + mock_configs() + .lock() + .unwrap() + .insert(container_id.to_owned(), config); + return Self::mock_spawn_with_grants(&process, &grants); + } + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if self.debug { + cmd.arg("--debug"); + } + + debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + let child = cmd.spawn()?; + Ok(child) + } + + /// Run the stop phase. + /// + /// `stop`/`deprovision` are **unit** variants in the wxc-exec schema: they + /// must serialize as `null`, not `{}`. Empirical (build 26300.8553, + /// wxc-exec 2026-06-10): `"stop": {}` is rejected with `malformed_request` + /// ("invalid type: map, expected unit"); `provision`/`start` accept maps. + pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "stop", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "stop": null + } + } + }); + self.run_phase(&config).await + } + + /// Run the deprovision phase (unit variant — see [`Self::stop`]). + pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "deprovision", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "deprovision": null + } + } + }); + self.run_phase(&config).await + } +} + +// ── Tests (pure serde — compile and run cross-platform) ────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provision_envelope_parse_success() { + let json = r#"{"result":{"sandboxId":"iso:wxc-abc123","metadata":{}}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(env.result.unwrap().sandbox_id, "iso:wxc-abc123"); + assert!(env.error.is_none()); + } + + #[test] + fn provision_envelope_parse_error() { + let json = + r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert!(env.result.is_none()); + let err = env.error.unwrap(); + assert_eq!(err.code, "backend_unavailable"); + } + + #[test] + fn mxc_envelope_success_variant() { + let json = r#"{"result":{}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Ok { .. })); + } + + #[test] + fn mxc_envelope_error_variant() { + let json = r#"{"error":{"code":"not_provisioned","message":"call provision first"}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Err { .. })); + } + + #[test] + fn provision_config_json_shape() { + // Verify the JSON we send wxc-exec has the expected shape. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": DEFAULT_CONFIGURATION_ID, + "provision": {} + } + } + }); + assert_eq!(config["phase"], "provision"); + assert_eq!(config["containment"], "isolation_session"); + assert_eq!( + config["experimental"]["isolation_session"]["configurationId"], + "composable" + ); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + } + + #[test] + fn oneshot_processcontainer_config_json_shape() { + // Mirror the JSON `run_oneshot` builds for the one-shot processContainer + // path: no `phase` (routes to one-shot), `containment: processcontainer`, + // a `process` block, the `processContainer` knobs, and filesystem grants + // incl. deniedPaths. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": "sb-1", + "containment": "processcontainer", + "process": { + "commandLine": "C:\\work\\demo\\agent.exe", + "cwd": "C:\\work\\demo", + "env": Vec::::new(), + "timeout": 0, + }, + "processContainer": { "leastPrivilege": true }, + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "deniedPaths": ["C:\\secret"], + }, + }); + assert_eq!(config["containment"], "processcontainer"); + assert!( + config.get("phase").is_none(), + "one-shot config must omit phase" + ); + assert_eq!(config["processContainer"]["leastPrivilege"], true); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + assert_eq!(config["filesystem"]["deniedPaths"][0], "C:\\secret"); + } + + #[test] + fn provision_config_json_includes_network_proxy_when_supplied() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); + + assert_eq!(config["network"]["defaultPolicy"], "block"); + assert!( + config["network"]["allowedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + config["network"]["blockedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(config["network"]["proxy"]["localhost"], 18080); + assert!( + config["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + config["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + } + + #[test] + fn network_json_emits_localhost_port_shape() { + // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only + // {"proxy": {"localhost": N}} — verified against the real binary via + // --dry-run. This test pins the exact emitted JSON shape. + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let value = network_json(&network); + assert_eq!(value["proxy"]["localhost"], 18080); + assert!(value["proxy"].get("host").is_none()); + assert!(value["proxy"].get("port").is_none()); + } + + #[test] + fn oneshot_config_json_omits_network_without_proxy() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, + }; + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + + assert!(config.get("network").is_none()); + } + + #[test] + fn stop_and_deprovision_serialize_as_unit_variants() { + // Pins the empirical schema contract (test box, build 26300.8553): + // stop/deprovision are unit variants and must be `null`; `{}` is + // rejected with malformed_request "invalid type: map, expected unit". + for phase in ["stop", "deprovision"] { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": phase, + "sandboxId": "iso:wxc-test", + "experimental": { + "isolation_session": { + phase: null + } + } + }); + assert!( + config["experimental"]["isolation_session"][phase].is_null(), + "{phase} must serialize as null (unit variant)" + ); + } + } + + #[test] + fn invoker_error_maps_backend_unavailable_to_unavailable() { + let err = InvokerError::Mxc { + code: "backend_unavailable".into(), + message: "missing DLL".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::Unavailable); + } + + #[test] + fn invoker_error_maps_policy_validation_to_invalid_argument() { + let err = InvokerError::Mxc { + code: "policy_validation".into(), + message: "path denied".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } + + #[test] + fn invoker_error_maps_stale_id_to_not_found() { + let err = InvokerError::Mxc { + code: "stale_id".into(), + message: "session expired".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::NotFound); + } +} diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs new file mode 100644 index 0000000000..a909238851 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `PolicyMapper` seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. +//! +//! This file does **not** write the actual policy mapping rules — that logic is +//! **embedded** as the [`crate::policy_map`] module (the source of truth; it was +//! the standalone `openshell-policy-mapper` crate). This file defines the trait +//! seam plus: +//! +//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Calls +//! [`crate::policy_map::map_to_mxc`] directly on the typed `SandboxPolicy` +//! proto (no YAML bridge), extracts the MXC filesystem shares, normalizes +//! their paths to Windows form, and rejects the create on any `error`-severity +//! loss. +//! +//! **Rule: never silently drop policy.** Unmappable rules surface as +//! `MapError::Unsupported` and are rejected by `CreateSandbox` before lifecycle side effects. + +use std::net::SocketAddr; + +use openshell_core::proto::SandboxPolicy; +use thiserror::Error; + +/// The MXC config fragment derived from a `SandboxPolicy`. +/// +/// Carries filesystem share lists for the MXC provision phase, plus the +/// Pattern-C governed-egress handoff when enabled. +#[derive(Debug, Default, Clone)] +pub struct MappedConfig { + /// Paths granted read-write access inside the sandbox. + pub readwrite_paths: Vec, + /// Paths granted read-only access inside the sandbox. + pub readonly_paths: Vec, + /// Network-only policy for the host CONNECT proxy. `None` on the coarse + /// filesystem-only path. + pub trimmed_policy: Option, + /// Loopback address MXC redirects sandbox egress to. `None` when governed + /// egress is disabled. + pub proxy_addr: Option, +} + +/// Context passed to the mapper alongside the policy. +#[derive(Debug)] +pub struct MapCtx { + /// Sandbox ID (gateway-assigned). Used as the MXC `containerId` and to + /// correlate diagnostics. + pub sandbox_id: String, + /// Pattern-C governed-egress redirect address. When set, the embedded + /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. + pub egress: Option, +} + +/// A policy rule that the active mapper cannot enforce. +#[derive(Debug, Clone)] +pub struct LossItem { + pub rule_kind: String, + pub detail: String, +} + +/// Error returned when policy translation fails or is incomplete. +#[derive(Debug, Error)] +pub enum MapError { + #[error("policy rule(s) cannot be enforced by the MXC driver: {}", format_loss(.0))] + Unsupported(Vec), + #[error("policy mapper internal error: {0}")] + Internal(String), +} + +fn format_loss(items: &[LossItem]) -> String { + items + .iter() + .map(|i| format!("{}: {}", i.rule_kind, i.detail)) + .collect::>() + .join("; ") +} + +/// Translates an `OpenShell` `SandboxPolicy` into an MXC `ContainerConfig` +/// fragment, returning a loss report of anything unrepresentable. +pub trait PolicyMapper: Send + Sync { + /// `policy` is `None` only when the create request omitted it. The MXC path + /// treats that as a hard error because it cannot launch without enforcement. + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result; +} + +// ── Path normalization ────────────────────────────────────────────────────── + +/// Normalize forward-slash paths to Windows backslash form. Path normalization +/// lives here, in one place — the embedded mapper copies path strings through +/// unchanged. +fn normalize_path(p: &str) -> String { + p.replace('/', "\\") +} + +// ── Embedded mapper (primary impl) ────────────────────────────────────────── + +/// Primary `PolicyMapper`: calls the embedded `policy_map` module (the source of +/// truth) directly on the typed `SandboxPolicy` proto. +pub struct EmbeddedPolicyMapper; + +fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { + config["filesystem"][key] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +impl PolicyMapper for EmbeddedPolicyMapper { + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { + let policy = policy.ok_or_else(|| { + MapError::Internal( + "MXC driver requires a sandbox policy, but none was staged for this sandbox" + .to_owned(), + ) + })?; + + let (config, loss, trimmed_policy, proxy_addr) = if let Some(addr) = ctx.egress { + // Pattern C: MXC handles filesystem + a proxy redirect, while the + // host CONNECT proxy receives the network-only trimmed policy. + let opts = crate::policy_map::MxcMappingOptions { + containment: "processcontainer".to_owned(), + container_id: ctx.sandbox_id.clone(), + proxy_redirect: Some(addr), + ..Default::default() + }; + let result = crate::policy_map::split_policy(policy, &opts).ok_or_else(|| { + MapError::Internal( + "egress proxy was enabled but no proxy address was supplied".into(), + ) + })?; + ( + result.mxc_config, + result.loss, + Some(result.proxy_policy), + Some(addr), + ) + } else { + // Map directly off the typed proto. The default MXC driver path runs + // an isolation session, so use that containment: its network branch + // yields an `error` loss for any host allowlist, which rejects + // network policy below. + let opts = crate::policy_map::MxcMappingOptions { + containment: "isolation_session".to_owned(), + container_id: ctx.sandbox_id.clone(), + ..Default::default() + }; + let result = crate::policy_map::map_to_mxc(policy, &opts); + (result.config, result.loss, None, None) + }; + + // Reject the create on any error-severity loss. Warnings/info (e.g. the + // filesystem default-deny note) are advisory and do not block. + let errors: Vec = loss + .iter() + .filter(|i| i.severity == "error") + .map(|i| LossItem { + rule_kind: i.path.clone(), + detail: i.message.clone(), + }) + .collect(); + if !errors.is_empty() { + return Err(MapError::Unsupported(errors)); + } + + // The embedded mapper copies paths verbatim; normalize them to Windows + // backslash form here, in one place. + let readwrite: Vec = extract_paths(&config, "readwritePaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); + let readonly: Vec = extract_paths(&config, "readonlyPaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); + + Ok(MappedConfig { + readwrite_paths: readwrite, + readonly_paths: readonly, + trimmed_policy, + proxy_addr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::FilesystemPolicy; + + fn demo_ctx() -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + egress: None, + } + } + + fn fs_policy(rw: &[&str], ro: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: ro.iter().map(ToString::to_string).collect(), + read_write: rw.iter().map(ToString::to_string).collect(), + }), + ..Default::default() + } + } + + #[test] + fn embedded_maps_policy_read_write_to_share() { + let mapper = EmbeddedPolicyMapper; + let policy = fs_policy(&["C:/work/openshell-mxc-demo"], &["C:/tools"]); + let ctx = demo_ctx(); + let config = mapper.map(Some(&policy), &ctx).unwrap(); + // Forward slashes normalized to Windows backslashes by the bridge. + assert!( + config + .readwrite_paths + .contains(&"C:\\work\\openshell-mxc-demo".to_string()) + ); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + } + + #[test] + fn embedded_rejects_missing_policy() { + let mapper = EmbeddedPolicyMapper; + let ctx = demo_ctx(); + let err = mapper.map(None, &ctx).unwrap_err(); + assert!(matches!(err, MapError::Internal(_))); + } + + #[test] + fn embedded_rejects_network_policy_on_isolation_session() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &[]); + policy.network_policies.insert( + "api".to_string(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let ctx = demo_ctx(); + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!(matches!(err, MapError::Unsupported(_))); + } + + #[test] + fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &["C:/tools"]); + policy.version = 1; + policy.network_policies.insert( + "api".to_string(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ports: vec![443], + protocol: "rest".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + let proxy_addr = "127.0.0.1:18080".parse().unwrap(); + let ctx = MapCtx { + sandbox_id: "sb-egress".into(), + + egress: Some(proxy_addr), + }; + + let config = mapper.map(Some(&policy), &ctx).unwrap(); + assert_eq!(config.readwrite_paths, vec!["C:\\work\\demo"]); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + assert_eq!(config.proxy_addr, Some(proxy_addr)); + let trimmed = config.trimmed_policy.expect("trimmed policy"); + assert_eq!(trimmed.version, policy.version); + assert_eq!(trimmed.network_policies, policy.network_policies); + assert!(trimmed.filesystem.is_none()); + } +} diff --git a/crates/openshell-driver-mxc/src/policy_map/config.rs b/crates/openshell-driver-mxc/src/policy_map/config.rs new file mode 100644 index 0000000000..f64b64a54a --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC `ContainerConfig` defaults and backend-specific behavior. + +use openshell_core::proto::SandboxPolicy; +use serde_json::{Value, json}; + +use super::loss::{LossItem, add_loss}; + +/// Placeholder command written into `process.commandLine` when the caller does +/// not supply a real workload command. +pub const DEFAULT_COMMAND: &str = "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; + +/// Default MXC schema version emitted in `version`. +pub const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; + +/// Default MXC containment backend for the coarse mapping. +pub const DEFAULT_CONTAINMENT: &str = "bubblewrap"; + +/// Select a default `network.enforcementMode` for the backend, or `None` to +/// omit the field (backends that derive enforcement from host lists / proxy). +pub fn default_enforcement_mode( + containment: &str, + allowed_hosts: &[String], +) -> Option<&'static str> { + if allowed_hosts.is_empty() { + return None; + } + match containment { + "processcontainer" | "process" => Some("both"), + "wslc" | "seatbelt" | "microvm" | "vm" | "windows_sandbox" => None, + // lxc, bubblewrap, hyperlight, and anything else default to firewall. + _ => Some("firewall"), + } +} + +/// Backend-specific advisory about how filesystem default-deny differs from +/// `OpenShell` Landlock. +pub fn filesystem_default_deny_message(containment: &str) -> String { + match containment { + "bubblewrap" => "Bubblewrap policy is not strict OpenShell filesystem parity: MXC \ + may bind host root read-only and overlay policy mounts." + .to_owned(), + "lxc" => "LXC exposes the container rootfs and bind-mounts selected host \ + paths; this is not identical to OpenShell Landlock." + .to_owned(), + "wslc" => "WSLC mounts selected Windows paths, but default-deny behavior is \ + runner/backend specific." + .to_owned(), + "seatbelt" => "Seatbelt starts from a deny-default profile with baseline system \ + allowances, not OpenShell Landlock." + .to_owned(), + _ => "MXC filesystem behavior is backend-specific and not equivalent to \ + OpenShell Landlock by construction." + .to_owned(), + } +} + +/// Add backend-specific config blocks (and reject unsupported backends). +pub fn add_backend_specific_config( + config: &mut Value, + containment: &str, + allowed_hosts: &[String], + items: &mut Vec, +) { + match containment { + "processcontainer" | "process" if !allowed_hosts.is_empty() => { + config["processContainer"] = json!({ "capabilities": ["internetClient"] }); + } + "lxc" => { + config["lxc"] = json!({ "distribution": "alpine", "release": "3.20" }); + } + backend @ ("windows_sandbox" | "isolation_session" | "vm") if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + &format!("{backend} is not a v0 target for OpenShell network policy mapping."), + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for this backend.", + ); + } + "microvm" if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + "microvm network policy enforcement is not defined for this mapper.", + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for microvm.", + ); + } + _ => {} + } +} + +/// Add a backend-specific advisory about host-allowlist fidelity. Only fires +/// when the source policy declares network rules. +pub fn add_backend_network_loss( + policy: &SandboxPolicy, + containment: &str, + items: &mut Vec, +) { + if policy.network_policies.is_empty() { + return; + } + match containment { + "seatbelt" => add_loss( + items, + "network_policies", + "error", + "MXC Seatbelt cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Seatbelt allowlists can broaden to allow-all outbound.", + ), + "processcontainer" | "process" => add_loss( + items, + "network_policies", + "warning", + "Windows ProcessContainer host allowlists are possible but fragile.", + "host allowlist", + "Review firewall/capability behavior before treating this as parity.", + ), + "wslc" => add_loss( + items, + "network_policies", + "warning", + "WSLC host filtering relies on bridged networking plus in-container iptables.", + "host allowlist", + "Backend privileges and runner behavior determine parity.", + ), + "vm" | "windows_sandbox" => add_loss( + items, + "network_policies", + "error", + "MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Network policy enforcement is unsupported or unknown for this backend.", + ), + _ => {} + } +} diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs new file mode 100644 index 0000000000..9e83c20a78 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report model shared by the coarse map and the lossless split. + +use std::collections::HashSet; + +use serde::Serialize; + +/// A single mapping observation: something that could not be represented in +/// MXC, was delegated elsewhere, or is informational. +/// +/// `severity` is one of `"error"`, `"warning"`, or `"info"`. `"error"` marks a +/// semantic broadening or an unsupported parity gap; `"warning"` marks lost +/// information that does not obviously broaden access; `"info"` is advisory. +#[derive(Clone, Debug, Serialize)] +pub struct LossItem { + pub path: String, + pub severity: String, + pub message: String, + pub openshell_feature: String, + pub mxc_impact: String, +} + +/// MXC capabilities that have no `OpenShell` *policy* equivalent. Surfaced in the +/// loss report so reviewers understand the mapping is not symmetric. +pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ + "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", + "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", + "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", + "MXC explicit deniedPaths are not expressible in current OpenShell policy YAML, which relies on default-deny filesystem behavior instead.", + "MXC fallback.allowDaclMutation (host DACL mutation consent) has no OpenShell policy equivalent.", + "MXC network.allowLocalNetwork (inbound bind/listen permission) has no OpenShell policy equivalent.", + "MXC network.proxy configuration has no OpenShell policy equivalent.", + "MXC experimental backend blocks (windows_sandbox, wslc, seatbelt, isolation_session) are outside OpenShell policy YAML.", +]; + +pub fn add_loss( + items: &mut Vec, + path: &str, + severity: &str, + message: &str, + openshell_feature: &str, + mxc_impact: &str, +) { + items.push(LossItem { + path: path.to_owned(), + severity: severity.to_owned(), + message: message.to_owned(), + openshell_feature: openshell_feature.to_owned(), + mxc_impact: mxc_impact.to_owned(), + }); +} + +/// Distinct `OpenShell` features that were lost or degraded, in first-seen order. +pub fn summarize_missing_mxc(items: &[LossItem]) -> Vec { + let mut seen: HashSet<&str> = HashSet::new(); + let mut summary = Vec::new(); + for item in items { + if (item.severity == "error" || item.severity == "warning") + && seen.insert(item.openshell_feature.as_str()) + { + summary.push(item.openshell_feature.clone()); + } + } + summary +} diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs new file mode 100644 index 0000000000..7d9025e5a6 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -0,0 +1,704 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Coarse OpenShell-policy → MXC `ContainerConfig` mapping. +//! +//! Operates on the typed [`SandboxPolicy`] (parse it with +//! `openshell_policy::parse_sandbox_policy`). Network policy is flattened into +//! an MXC host allowlist; everything MXC cannot express is recorded as a loss +//! item. The top-level `network_policies` map is iterated in sorted key order +//! so the output is deterministic (the proto map is unordered). + +use std::net::SocketAddr; + +use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use serde_json::{Value, json}; + +use super::config::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss, + add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, +}; +use super::loss::{LossItem, add_loss}; + +/// Options controlling the generated MXC config. Fields not relevant to the +/// coarse map (e.g. `proxy_redirect`) are reserved for the lossless split. +#[derive(Clone, Debug)] +pub struct MxcMappingOptions { + /// MXC schema version written into `version`. + pub mxc_version: String, + /// MXC containment backend. + pub containment: String, + /// `process.commandLine` value. + pub command: String, + /// Resolved `containerId`. + pub container_id: String, + /// Working directory; resolves `filesystem_policy.include_workdir`. + pub cwd: Option, + /// `KEY=VALUE` entries added to `process.env`. + pub env: Vec, + /// `process.timeout`. + pub timeout_ms: u64, + /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. + pub allow_wildcards: bool, + /// Governed-egress redirect address (used by the lossless split, not the + /// coarse map). + pub proxy_redirect: Option, +} + +impl Default for MxcMappingOptions { + fn default() -> Self { + Self { + mxc_version: DEFAULT_MXC_VERSION.to_owned(), + containment: DEFAULT_CONTAINMENT.to_owned(), + command: DEFAULT_COMMAND.to_owned(), + container_id: "openshell-policy".to_owned(), + cwd: None, + env: Vec::new(), + timeout_ms: 0, + allow_wildcards: false, + proxy_redirect: None, + } + } +} + +/// Result of a coarse mapping: the MXC config plus the loss items. +#[derive(Clone, Debug)] +pub struct MxcMappingResult { + pub config: Value, + pub loss: Vec, +} + +/// Result of the lossless split: the MXC config carries filesystem grants and a +/// proxy redirect; the full network policy is returned unchanged for the +/// `OpenShell` CONNECT proxy to enforce. +#[derive(Clone, Debug)] +pub struct SplitPolicyResult { + /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// + /// `network.allowedHosts` is empty — direct egress is blocked at the MXC + /// layer. All outbound connections flow through the proxy; the proxy enforces + /// the full `OpenShell` network policy. + pub mxc_config: Value, + /// Full `OpenShell` network policy preserved verbatim for the host CONNECT + /// proxy. Only `network_policies` is populated; the proxy does not enforce + /// filesystem rules. + pub proxy_policy: SandboxPolicy, + /// Loss items from the filesystem side only. Network rules produce no losses + /// here — they are delegated to the proxy rather than approximated. + pub loss: Vec, +} + +/// Map an `OpenShell` policy to a coarse MXC `ContainerConfig`. +pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappingResult { + let mut loss = Vec::new(); + let config = build_mxc_config(policy, opts, &mut loss); + MxcMappingResult { config, loss } +} + +/// Lossless split: map filesystem + containment to MXC, delegate network to the +/// `OpenShell` CONNECT proxy. +/// +/// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to +/// `opts.proxy_redirect` and leaves `allowedHosts` empty — direct +/// egress is blocked at the MXC layer and all outbound connections flow through +/// the proxy. [`SplitPolicyResult::proxy_policy`] carries the original +/// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard +/// loss items are generated for the network side. +/// +/// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] +/// for the standalone coarse path when no proxy is in the loop. +pub fn split_policy(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> Option { + let proxy_addr = opts.proxy_redirect?; + let mut loss = Vec::new(); + let mxc_config = build_split_mxc_config(policy, opts, proxy_addr, &mut loss); + let proxy_policy = SandboxPolicy { + version: policy.version, + network_policies: policy.network_policies.clone(), + network_middlewares: policy.network_middlewares.clone(), + ..Default::default() + }; + Some(SplitPolicyResult { + mxc_config, + proxy_policy, + loss, + }) +} + +fn build_split_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + proxy_addr: SocketAddr, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + let filesystem = map_filesystem(policy, opts, items); + + let proxy_supported = matches!(opts.containment.as_str(), "processcontainer" | "process"); + if !proxy_supported { + add_loss( + items, + "containment", + "error", + &format!( + "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + opts.containment + ), + "governed egress proxy redirect", + "The generated MXC config omits network.proxy for this backend.", + ); + } + if !policy.network_policies.is_empty() { + add_loss( + items, + "network_policies", + "info", + &format!( + "{} network rule(s) delegated to the OpenShell host CONNECT proxy.", + policy.network_policies.len() + ), + "governed egress", + "The host proxy receives the trimmed policy and enforces network rules.", + ); + } + + // Direct egress is blocked; all outbound flows through the OpenShell proxy. + // allowedHosts is intentionally empty — the proxy enforces the full policy. + // + // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. + // {"host": ..., "port": ...} and every other shape is rejected — verified + // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + add_loss( + items, + "network.proxy", + "error", + &format!( + "MXC schema 0.6.0-alpha can only express a localhost port \ + ({{\"localhost\": N}}); non-127.0.0.1 redirect address \ + {proxy_addr} is not representable." + ), + "per-sandbox egress attribution", + "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + ); + } + let mut network = json!({ + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + }); + if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { + network["proxy"] = json!({ "localhost": proxy_addr.port() }); + } + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + // No network hosts, so backend-specific network blocks (processContainer + // internetClient, etc.) are not added — correct for the proxy path. + add_backend_specific_config(&mut config, &opts.containment, &[], items); + add_static_policy_loss(policy, opts, items); + config +} + +fn build_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + let filesystem = map_filesystem(policy, opts, items); + let allowed_hosts = map_network(policy, opts, items); + + let mut network = json!({ + "defaultPolicy": "block", + "allowedHosts": allowed_hosts, + "blockedHosts": [], + }); + if let Some(mode) = default_enforcement_mode(&opts.containment, &allowed_hosts) { + network["enforcementMode"] = json!(mode); + } + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); + add_static_policy_loss(policy, opts, items); + config +} + +fn map_filesystem( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut readwrite: Vec = Vec::new(); + let mut readonly: Vec = Vec::new(); + + match &policy.filesystem { + Some(fs) => { + readwrite.clone_from(&fs.read_write); + readonly.clone_from(&fs.read_only); + if fs.include_workdir { + if let Some(cwd) = &opts.cwd { + append_unique(&mut readwrite, cwd.clone()); + } else { + add_loss( + items, + "filesystem_policy.include_workdir", + "info", + "OpenShell includes the runtime workdir, but no --cwd was supplied.", + "include_workdir", + "The generated MXC config cannot add the workdir path grant.", + ); + } + } + } + None => add_loss( + items, + "filesystem_policy", + "warning", + "No OpenShell filesystem_policy was present.", + "default filesystem policy", + "MXC receives empty filesystem lists; backend defaults determine visibility.", + ), + } + + add_loss( + items, + "filesystem_policy", + "warning", + &filesystem_default_deny_message(&opts.containment), + "OpenShell Landlock/default-deny filesystem model", + "MXC filesystem default-deny parity is backend-specific.", + ); + + json!({ + "readwritePaths": readwrite, + "readonlyPaths": readonly, + "deniedPaths": [], + }) +} + +fn map_network( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Vec { + if !policy.network_middlewares.is_empty() { + add_loss( + items, + "network_middlewares", + "error", + &format!( + "{} network middleware config(s) require the OpenShell host proxy and cannot be enforced by MXC directly.", + policy.network_middlewares.len() + ), + "network egress middleware", + "Middleware transformations and failure behavior would not be applied on the coarse MXC path.", + ); + } + + if policy.network_policies.is_empty() { + add_backend_network_loss(policy, &opts.containment, items); + return Vec::new(); + } + + // The proto map is unordered; sort by rule key for deterministic output. + let mut rules: Vec<(&String, &NetworkPolicyRule)> = policy.network_policies.iter().collect(); + rules.sort_by(|a, b| a.0.cmp(b.0)); + + let mut allowed_hosts: Vec = Vec::new(); + + for (key, rule) in rules { + let rule_path = format!("network_policies.{key}"); + + if rule.endpoints.is_empty() { + add_loss( + items, + &format!("{rule_path}.endpoints"), + "error", + "OpenShell policy entry has no endpoints.", + "network endpoints", + "No MXC host allowlist entries were produced for this policy.", + ); + } + for (index, endpoint) in rule.endpoints.iter().enumerate() { + let endpoint_path = format!("{rule_path}.endpoints[{index}]"); + map_endpoint(endpoint, &endpoint_path, &mut allowed_hosts, opts, items); + } + + if rule.binaries.is_empty() { + add_loss( + items, + &format!("{rule_path}.binaries"), + "error", + "OpenShell requires binary-scoped network grants; this entry has no binaries.", + "binary-scoped network policy", + "MXC cannot represent per-binary grants and scopes network to the sandbox.", + ); + } else { + for (index, binary) in rule.binaries.iter().enumerate() { + add_loss( + items, + &format!("{rule_path}.binaries[{index}].path"), + "error", + &format!( + "Binary scope is not representable in MXC: '{}'.", + binary.path + ), + "binary-scoped network policy", + "Dropping this would broaden access from one executable to the whole sandbox.", + ); + } + } + } + + add_backend_network_loss(policy, &opts.containment, items); + allowed_hosts +} + +fn map_endpoint( + endpoint: &NetworkEndpoint, + path: &str, + allowed_hosts: &mut Vec, + opts: &MxcMappingOptions, + items: &mut Vec, +) { + // host + if endpoint.host.is_empty() { + add_loss( + items, + &format!("{path}.host"), + "error", + "Endpoint has no host.", + "network endpoint host", + "Endpoint was not added to MXC allowedHosts.", + ); + } else if contains_wildcard(&endpoint.host) { + let (message, impact) = if opts.allow_wildcards { + append_unique(allowed_hosts, endpoint.host.clone()); + ( + format!( + "Wildcard host emitted despite non-portable MXC semantics: {}.", + endpoint.host + ), + "Backend behavior is not portable and may fail or broaden access.", + ) + } else { + ( + format!( + "Wildcard host omitted because MXC has no portable syntax: {}.", + endpoint.host + ), + "Generated MXC config is more restrictive for this endpoint.", + ) + }; + add_loss( + items, + &format!("{path}.host"), + "error", + &message, + "OpenShell wildcard host matching", + impact, + ); + } else { + append_unique(allowed_hosts, endpoint.host.clone()); + } + + // port / ports (the proto normalizes a single port into `ports`) + if !endpoint.ports.is_empty() { + let (field, repr) = if endpoint.ports.len() == 1 { + ("port", endpoint.ports[0].to_string()) + } else { + ("ports", format!("{:?}", endpoint.ports)) + }; + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC allowedHosts cannot encode port constraint {repr}."), + "port-scoped outbound policy", + "MXC allows or blocks the host as a whole.", + ); + } + + // allowed_ips + for ip in &endpoint.allowed_ips { + append_unique(allowed_hosts, ip.clone()); + add_loss( + items, + &format!("{path}.allowed_ips"), + "warning", + &format!( + "MXC can carry CIDR/IP '{ip}', but cannot bind it to DNS for '{}'.", + endpoint.host + ), + "DNS result pinning / SSRF override", + "The CIDR/IP becomes a standalone allowed destination.", + ); + } + + report_endpoint_l7_losses(endpoint, path, items); +} + +fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut Vec) { + if !endpoint.protocol.is_empty() { + add_loss( + items, + &format!("{path}.protocol"), + "error", + &format!( + "MXC has no protocol-aware policy equivalent for '{}'.", + endpoint.protocol + ), + "protocol-aware proxy policy", + "MXC host filtering cannot enforce REST/WebSocket/GraphQL semantics.", + ); + } + + if !endpoint.tls.is_empty() { + let severity = if endpoint.tls == "skip" { + "warning" + } else { + "error" + }; + add_loss( + items, + &format!("{path}.tls"), + severity, + &format!( + "MXC has no OpenShell TLS inspection mode equivalent for '{}'.", + endpoint.tls + ), + "TLS inspection mode", + "MXC network policy is host-level only.", + ); + } + + if !endpoint.enforcement.is_empty() { + if endpoint.enforcement == "audit" { + add_loss( + items, + &format!("{path}.enforcement"), + "error", + "MXC has no audit-only network policy mode.", + "audit-mode endpoint", + "Generated MXC config enforces host-level default block instead.", + ); + } else { + add_loss( + items, + &format!("{path}.enforcement"), + "warning", + "MXC enforcementMode is backend-wide, not per endpoint.", + "per-endpoint enforcement", + "The mapper chooses a backend-level enforcement mode.", + ); + } + } + + if !endpoint.access.is_empty() { + add_loss( + items, + &format!("{path}.access"), + "error", + &format!( + "MXC has no access preset equivalent for '{}'.", + endpoint.access + ), + "REST/WebSocket/GraphQL access preset", + "MXC cannot enforce method or operation-level access.", + ); + } + + if !endpoint.rules.is_empty() { + add_loss( + items, + &format!("{path}.rules"), + "error", + "MXC has no L7 allow-rule equivalent.", + "REST/WebSocket/GraphQL allow rules", + "Method/path/query/operation restrictions are lost.", + ); + } + + if !endpoint.deny_rules.is_empty() { + add_loss( + items, + &format!("{path}.deny_rules"), + "error", + "MXC has no L7 deny-rule equivalent.", + "L7 deny rules", + "Deny precedence over broad allows is lost.", + ); + } + + let bool_losses: &[(bool, &str, &str)] = &[ + ( + endpoint.allow_encoded_slash, + "allow_encoded_slash", + "encoded slash handling", + ), + ( + endpoint.websocket_credential_rewrite, + "websocket_credential_rewrite", + "WebSocket credential rewrite", + ), + ( + endpoint.request_body_credential_rewrite, + "request_body_credential_rewrite", + "request-body credential rewrite", + ), + ]; + for (set, field, feature) in bool_losses { + if *set { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no equivalent for {feature}."), + feature, + "Generated config cannot preserve this proxy behavior.", + ); + } + } + + // GraphQL + if !endpoint.persisted_queries.is_empty() { + add_graphql_loss(items, path, "persisted_queries"); + } + if !endpoint.graphql_persisted_queries.is_empty() { + add_graphql_loss(items, path, "graphql_persisted_queries"); + } + if endpoint.graphql_max_body_bytes > 0 { + add_graphql_loss(items, path, "graphql_max_body_bytes"); + } +} + +fn add_graphql_loss(items: &mut Vec, path: &str, field: &str) { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no GraphQL policy equivalent for {field}."), + "GraphQL operation policy", + "GraphQL inspection and persisted-query behavior is lost.", + ); +} + +fn add_static_policy_loss( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) { + if policy.landlock.is_some() { + add_loss( + items, + "landlock", + "warning", + "MXC has no Landlock compatibility mode field.", + "Landlock LSM enforcement", + "Backend filesystem controls may not fail like OpenShell best_effort/hard_requirement.", + ); + } + + if let Some(process) = &policy.process { + if !process.run_as_user.is_empty() { + add_process_identity_loss(items, "run_as_user"); + } + if !process.run_as_group.is_empty() { + add_process_identity_loss(items, "run_as_group"); + } + } + + if opts.containment == "processcontainer" + && let Some(fs) = &policy.filesystem + { + let any_linux_path = fs + .read_only + .iter() + .chain(fs.read_write.iter()) + .any(|p| p.starts_with('/')); + if any_linux_path { + add_loss( + items, + "filesystem_policy", + "warning", + "OpenShell example paths are Linux paths; Windows ProcessContainer expects Windows paths.", + "filesystem path syntax", + "Run with path translation or target a Linux-like MXC backend.", + ); + } + } +} + +fn add_process_identity_loss(items: &mut Vec, field: &str) { + add_loss( + items, + &format!("process.{field}"), + "warning", + &format!("MXC has no portable equivalent for OpenShell {field}."), + "process identity", + "MXC backend identity is selected outside this policy mapping.", + ); +} + +fn append_unique(list: &mut Vec, value: String) { + if !list.contains(&value) { + list.push(value); + } +} + +fn contains_wildcard(host: &str) -> bool { + host.contains('*') +} diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs new file mode 100644 index 0000000000..52dc7f186c --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Map an `OpenShell` sandbox policy to a Microsoft MXC `ContainerConfig`. +//! +//! This module is the **source of truth** for the OpenShell→MXC policy mapping +//! (it was the standalone `openshell-policy-mapper` crate). It is embedded in the +//! MXC driver as a module, consumed by the [`crate::policy`] seam. +//! +//! It reuses the canonical typed [`SandboxPolicy`] from `openshell-policy` +//! (obtained via `openshell_policy::parse_sandbox_policy`) rather than re-parsing +//! YAML, so the policy schema has a single source of truth. +//! +//! Two mapping shapes are intended: +//! +//! - [`map_to_mxc`] — the *coarse / standalone* mapping. `OpenShell` network +//! policy is flattened into an MXC host allowlist (`network.allowedHosts`), +//! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) +//! is recorded in the loss report. Use this when MXC enforces network on its +//! own, with no `OpenShell` proxy in the loop. +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! while the full `OpenShell` network policy is preserved in a trimmed policy +//! enforced by the host CONNECT proxy. +//! +//! The report/loss-report helpers are only exercised by the example and the +//! integration tests, so the Windows lib build would otherwise warn on them; +//! `#![allow(dead_code)]` keeps the module quiet without per-item churn. +//! +//! [`SandboxPolicy`]: openshell_core::proto::SandboxPolicy + +#![allow(dead_code)] + +mod config; +mod loss; +mod map; +mod report; + +pub use config::{DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION}; +pub use loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS}; +pub use map::{MxcMappingOptions, MxcMappingResult, SplitPolicyResult, map_to_mxc, split_policy}; +pub use report::{build_loss_report, render_readme}; diff --git a/crates/openshell-driver-mxc/src/policy_map/report.rs b/crates/openshell-driver-mxc/src/policy_map/report.rs new file mode 100644 index 0000000000..0b1ec37b77 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report JSON and human-readable README rendering. + +use serde_json::{Value, json}; + +use super::loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS, summarize_missing_mxc}; + +/// Build the structured `loss-report.json` value. +pub fn build_loss_report( + source_policy: &str, + generated_config: &str, + items: &[LossItem], + schema_errors: &[LossItem], + mxc_version: &str, + containment: &str, + schema: Option<&str>, +) -> Value { + let count = |severity: &str| items.iter().filter(|i| i.severity == severity).count(); + + json!({ + "sourcePolicy": source_policy, + "generatedConfig": generated_config, + "target": { + "schemaVersion": mxc_version, + "containment": containment, + }, + "schemaValidation": { + "schema": schema, + "valid": schema_errors.is_empty(), + }, + "lossy": !items.is_empty(), + "counts": { + "error": count("error"), + "warning": count("warning"), + "info": count("info"), + }, + "items": items, + "openShellFieldsNotInMxc": summarize_missing_mxc(items), + "mxcFieldsNotInOpenShellPolicy": OPEN_SHELL_SUPERSET_GAPS, + }) +} + +/// Render the human-readable `README.md` summarizing the mapping. +pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> String { + let container_id = config["containerId"].as_str().unwrap_or(""); + let containment = config["containment"].as_str().unwrap_or(""); + let schema_valid = report["schemaValidation"]["valid"] + .as_bool() + .unwrap_or(false); + + let join_strs = |value: &Value| -> String { + let parts: Vec<&str> = value + .as_array() + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + if parts.is_empty() { + "(none)".to_owned() + } else { + parts.join(", ") + } + }; + + let allowed = join_strs(&config["network"]["allowedHosts"]); + let rw = join_strs(&config["filesystem"]["readwritePaths"]); + let ro = join_strs(&config["filesystem"]["readonlyPaths"]); + + let mut lines: Vec = vec![ + format!("# {container_id}"), + String::new(), + "## Generated Files".to_owned(), + String::new(), + format!( + "- `mxc-config.json`: direct MXC `ContainerConfig` generated from `{source_policy}`." + ), + "- `loss-report.json`: structured mapping loss report.".to_owned(), + String::new(), + "## MXC Consumption".to_owned(), + String::new(), + "The generated config is intended to be consumable by MXC's direct JSON path.".to_owned(), + "It uses a harmless placeholder `process.commandLine`; replace it with the".to_owned(), + "real workload command before running anything meaningful.".to_owned(), + String::new(), + format!("- Containment: `{containment}`"), + format!("- Schema validation: `{schema_valid}`"), + format!("- Allowed hosts: `{allowed}`"), + format!("- Read-write paths: `{rw}`"), + format!("- Read-only paths: `{ro}`"), + String::new(), + "## Missing In MXC For This OpenShell Policy".to_owned(), + String::new(), + ]; + + let notable: Vec<&Value> = report["items"] + .as_array() + .map(|a| { + a.iter() + .filter(|item| matches!(item["severity"].as_str(), Some("error" | "warning"))) + .collect() + }) + .unwrap_or_default(); + + if notable.is_empty() { + lines.push("- No lossy OpenShell-to-MXC policy mappings were detected.".to_owned()); + } else { + for item in notable { + lines.push(format!( + "- `{}` `{}`: {} Impact: {}", + item["severity"].as_str().unwrap_or(""), + item["path"].as_str().unwrap_or(""), + item["message"].as_str().unwrap_or(""), + item["mxc_impact"].as_str().unwrap_or(""), + )); + } + } + + lines.extend([ + String::new(), + "## Missing In OpenShell Policy For MXC".to_owned(), + String::new(), + ]); + if let Some(gaps) = report["mxcFieldsNotInOpenShellPolicy"].as_array() { + for gap in gaps.iter().filter_map(Value::as_str) { + lines.push(format!("- {gap}")); + } + } + + lines.extend([ + String::new(), + "## Notes".to_owned(), + String::new(), + "- OpenShell network policies are binary-, port-, protocol-, and often L7-scoped.".to_owned(), + "- This coarse mapper emits only MXC host/IP/CIDR allowlists plus filesystem lists.".to_owned(), + "- Treat any `error` item in the loss report as a semantic broadening or unsupported parity gap.".to_owned(), + String::new(), + ]); + + lines.join("\n") +} diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs new file mode 100644 index 0000000000..4d3a3cfa5c --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -0,0 +1,492 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Parity/invariant tests for the embedded coarse map over the repository's +//! example policies. +//! +//! Windows-only: the embedded mapper API is gated on `target_os = "windows"`, +//! so this whole file compiles to nothing elsewhere and runs in full on a +//! Windows test lane. +//! +//! Byte-for-byte parity with the previous raw-YAML mapper is intentionally +//! *not* asserted: routing through the canonical typed `SandboxPolicy` +//! normalizes ports and uses an unordered proto map (we sort keys). Instead we +//! assert the substantive invariants — filesystem fidelity, the host allowlist, +//! deny-by-default, and that broadening features are flagged as losses. + +#![cfg(target_os = "windows")] + +use std::path::{Path, PathBuf}; + +use openshell_driver_mxc::{MxcMappingOptions, map_to_mxc, split_policy}; +use openshell_policy::{parse_sandbox_policy, serialize_sandbox_policy, validate_sandbox_policy}; +use serde_json::Value; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +fn discover(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + discover(&path, out); + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.contains("policy") + && path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("yaml")) + { + out.push(path); + } + } +} + +fn str_list(value: &Value) -> Vec { + value + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + +#[test] +fn all_example_policies_map_with_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + // Deny-by-default network posture is always emitted. + assert_eq!( + cfg["network"]["defaultPolicy"], + "block", + "{} must emit defaultPolicy=block", + path.display() + ); + + // Filesystem fidelity: read_write / read_only copied exactly. + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "readonly mismatch for {}", + path.display() + ); + } + + // Every non-wildcard endpoint host appears in allowedHosts. + let allowed = str_list(&cfg["network"]["allowedHosts"]); + for rule in policy.network_policies.values() { + for ep in &rule.endpoints { + if !ep.host.is_empty() && !ep.host.contains('*') { + assert!( + allowed.contains(&ep.host), + "{} missing host {} in allowedHosts", + path.display(), + ep.host + ); + } + } + } + + // allowedHosts is deduplicated. + let mut sorted = allowed.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + allowed.len(), + "duplicate hosts for {}", + path.display() + ); + + // Deterministic: mapping twice yields identical config. + let again = map_to_mxc(&policy, &MxcMappingOptions::default()); + assert_eq!( + result.config, + again.config, + "non-deterministic for {}", + path.display() + ); + } +} + +#[test] +fn all_example_policies_split_with_lossless_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts) + .unwrap_or_else(|| panic!("split returned None for {}", path.display())); + let cfg = &result.mxc_config; + + assert_eq!( + result.proxy_policy.network_policies, + policy.network_policies, + "proxy_policy must carry network rules verbatim for {}", + path.display() + ); + assert_eq!( + result.proxy_policy.version, + policy.version, + "proxy_policy must preserve version for {}", + path.display() + ); + validate_sandbox_policy(&result.proxy_policy).unwrap_or_else(|e| { + panic!("trimmed policy must validate for {}: {e:?}", path.display()) + }); + let serialized = serialize_sandbox_policy(&result.proxy_policy) + .unwrap_or_else(|e| panic!("serialize trimmed policy for {}: {e}", path.display())); + let round_trip = parse_sandbox_policy(&serialized) + .unwrap_or_else(|e| panic!("parse trimmed round-trip for {}: {e}", path.display())); + assert_eq!( + round_trip, + result.proxy_policy, + "trimmed policy must round-trip for {}", + path.display() + ); + + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "split readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "split readonly mismatch for {}", + path.display() + ); + } else { + assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); + assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); + } + + assert_eq!(cfg["network"]["defaultPolicy"], "block"); + assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); + assert!(cfg["network"]["proxy"].get("host").is_none()); + assert!(cfg["network"]["proxy"].get("port").is_none()); + assert!( + result.loss.iter().all(|i| i.severity != "error"), + "processcontainer split must not emit error losses for {}: {:?}", + path.display(), + result.loss + ); + } +} + +#[test] +fn quickstart_coarse_mapping() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + assert_eq!( + str_list(&cfg["network"]["allowedHosts"]), + vec!["api.github.com".to_owned()] + ); + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + vec!["/sandbox", "/tmp", "/dev/null"] + ); + assert_eq!(cfg["containment"], "bubblewrap"); + + // The github_api endpoint loses port, protocol, access, and binary scope. + let has = |severity: &str, needle: &str| { + result + .loss + .iter() + .any(|i| i.severity == severity && i.path.contains(needle)) + }; + assert!(has("error", "endpoints[0].port"), "expected port loss"); + assert!( + has("error", "endpoints[0].protocol"), + "expected protocol loss" + ); + assert!( + has("error", "endpoints[0].access"), + "expected access preset loss" + ); + assert!( + has("error", "binaries[0].path"), + "expected binary-scope loss" + ); +} + +#[test] +fn split_policy_routes_network_to_proxy() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.2:8080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); + let cfg = &result.mxc_config; + + // Proxy redirect is emitted. 127.0.0.2 is not the loopback 127.0.0.1 so + // the mapper records an error loss and omits the proxy block entirely. + // (MXC 0.6.0-alpha can only encode {"localhost": N}; non-127.0.0.1 is + // not representable.) + assert!( + cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), + "non-127.0.0.1 redirect must NOT produce a proxy block: {:?}", + cfg["network"].get("proxy") + ); + let has_proxy_loss = result + .loss + .iter() + .any(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + has_proxy_loss, + "non-127.0.0.1 redirect must produce an error loss item" + ); + + // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). + assert_eq!(cfg["network"]["defaultPolicy"], "block"); + assert!( + str_list(&cfg["network"]["allowedHosts"]).is_empty(), + "split path must not populate allowedHosts" + ); + + // Filesystem grants are preserved unchanged. + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + policy.filesystem.as_ref().unwrap().read_write + ); + + // Network policy is returned verbatim for the proxy. + assert_eq!( + result.proxy_policy.network_policies, policy.network_policies, + "proxy_policy must carry all network rules verbatim" + ); + assert_eq!(result.proxy_policy.version, policy.version); + assert!( + result.proxy_policy.filesystem.is_none(), + "proxy_policy must not carry filesystem rules" + ); + + // No binary-scope, port, or protocol losses — those are delegated to the proxy. + let net_losses: Vec<_> = result + .loss + .iter() + .filter(|i| i.path.starts_with("network_policies") && i.severity != "info") + .collect(); + assert!( + net_losses.is_empty(), + "split path must not generate lossy network items: {net_losses:?}" + ); + assert!(result.loss.iter().any(|i| { + i.path == "network_policies" && i.severity == "info" && i.message.contains("delegated") + })); +} + +#[test] +fn split_policy_returns_none_without_proxy_addr() { + let opts = MxcMappingOptions::default(); + let policy = parse_sandbox_policy("").unwrap_or_default(); + assert!( + split_policy(&policy, &opts).is_none(), + "split_policy must return None when proxy_redirect is not set" + ); +} + +#[test] +fn split_policy_deterministic() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:9999".parse().unwrap()), + ..Default::default() + }; + let a = split_policy(&policy, &opts).unwrap(); + let b = split_policy(&policy, &opts).unwrap(); + assert_eq!( + a.mxc_config, b.mxc_config, + "split_policy must be deterministic" + ); +} + +#[test] +fn split_policy_rejects_proxy_redirect_on_isolation_session() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let opts = MxcMappingOptions { + containment: "isolation_session".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).unwrap(); + let errors: Vec<_> = result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect(); + assert_eq!( + errors.len(), + 1, + "expected one containment error: {errors:?}" + ); + assert_eq!(errors[0].path, "containment"); + assert!(errors[0].message.contains("MXC M1")); + assert!(result.mxc_config["network"].get("proxy").is_none()); +} + +#[test] +fn network_only_policy_has_empty_filesystem() { + // policy-advisor is a network-only seed (no filesystem_policy). + let path = examples_root().join("policy-advisor/sandbox-policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read policy-advisor"); + let policy = parse_sandbox_policy(&yaml).expect("parse policy-advisor"); + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); + assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); + assert_eq!( + str_list(&cfg["network"]["allowedHosts"]), + vec!["api.anthropic.com".to_owned()] + ); +} + +// ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── + +#[test] +fn split_with_loopback_addr_emits_localhost_port_shape() { + // MXC 0.6.0-alpha accepts ONLY {"proxy": {"localhost": N}}. + // Verified against the real wxc-exec 0.6.0-alpha binary via --dry-run. + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + assert_eq!( + cfg["network"]["proxy"]["localhost"], 18080, + "proxy must use {{\"localhost\": N}} shape" + ); + assert!( + cfg["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + cfg["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + // No error losses — 127.0.0.1 is representable. + assert!( + result.loss.iter().all(|i| i.severity != "error"), + "127.0.0.1 proxy must not emit error losses: {:?}", + result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect::>() + ); +} + +#[test] +fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { + // Non-127.0.0.1 redirect addresses are not representable in MXC 0.6.0-alpha. + // The mapper must record an error loss and omit the proxy block. + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.5:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + // Proxy block must be absent. + assert!( + cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), + "non-127.0.0.1 redirect must not produce a proxy block: {:?}", + cfg["network"].get("proxy") + ); + + // An error loss for "network.proxy" must be present. + let proxy_loss = result + .loss + .iter() + .find(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + proxy_loss.is_some(), + "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + result.loss + ); + let loss = proxy_loss.unwrap(); + assert_eq!(loss.openshell_feature, "per-sandbox egress attribution"); + assert!( + loss.message.contains("localhost"), + "loss message should mention 'localhost': {}", + loss.message + ); +} diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs new file mode 100644 index 0000000000..39a94bee9b --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -0,0 +1,1332 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tier-0 coverage matrix + schema drift guard for the OpenShell→MXC policy mapper. +//! +//! Three quadrants: +//! A — mappable fields (`OpenShell` ∩ MXC): assert exact MXC output. +//! B — OpenShell-only features MXC cannot express: assert one loss item per +//! field with the documented severity. +//! C — MXC restrictive defaults ("default deny posture"): empty policy maps +//! to the most-restrictive possible MXC config. +//! +//! Plus: `handled_fields_inventory` — the schema drift guard that fails when +//! `openshell-policy` gains a serialized field the mapper does not account for. + +#![cfg(target_os = "windows")] +#![allow( + clippy::doc_link_with_quotes, + clippy::doc_markdown, + clippy::needless_collect, + clippy::uninlined_format_args +)] + +use openshell_core::proto::{ + FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, + MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, +}; +use openshell_driver_mxc::{ + EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, + split_policy, +}; +use openshell_policy::{serialize_sandbox_policy, validate_sandbox_policy}; +use serde_json::Value; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn middleware_config() -> NetworkMiddlewareConfig { + NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/regex".into(), + config: None, + on_error: "fail_closed".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + order: 0, + } +} + +fn str_list(v: &Value) -> Vec { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|e| e.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn default_opts() -> MxcMappingOptions { + MxcMappingOptions::default() // bubblewrap containment +} + +fn bubblewrap_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "bubblewrap".to_owned(), + ..Default::default() + } +} + +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + +fn pc_split_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + } +} + +/// Build a minimal policy with one network rule whose endpoints carry a single +/// endpoint set up by the caller. +fn net_policy(key: &str, ep: NetworkEndpoint) -> SandboxPolicy { + let mut p = SandboxPolicy::default(); + p.network_policies.insert( + key.to_owned(), + NetworkPolicyRule { + name: key.to_owned(), + endpoints: vec![ep], + binaries: Vec::new(), + }, + ); + p +} + +/// Assert exactly one loss item whose `path` contains `needle` and whose +/// `severity` equals `want_severity`. +fn assert_single_loss( + loss: &[openshell_driver_mxc::LossItem], + needle: &str, + want_severity: &str, + context: &str, +) { + let matching: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity == want_severity) + .collect(); + assert!( + !matching.is_empty(), + "{context}: expected a '{want_severity}' loss with path containing '{needle}', got: {loss:?}" + ); + // There should not be more than one item with a DIFFERENT severity for the same path. + let other_severity: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity != want_severity) + .collect(); + assert!( + other_severity.is_empty(), + "{context}: unexpected additional loss item(s) for '{needle}' with wrong severity: {other_severity:?}" + ); +} + +// ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── + +/// filesystem.read_write → readwritePaths verbatim, order preserved. +#[test] +fn a_rw_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["/work".into(), "/tmp".into(), "/data".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readwritePaths"]), + vec!["/work", "/tmp", "/data"], + "readwritePaths must be verbatim, in order" + ); +} + +/// filesystem.read_only → readonlyPaths verbatim, order preserved. +#[test] +fn a_ro_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_only: vec!["/usr".into(), "/lib".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readonlyPaths"]), + vec!["/usr", "/lib"], + "readonlyPaths must be verbatim, in order" + ); +} + +/// include_workdir=true + opts.cwd set → cwd appended (unique) to readwritePaths. +#[test] +fn a_include_workdir_with_cwd_appended_unique() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let opts = MxcMappingOptions { + cwd: Some("/work".to_owned()), // already present — must not duplicate + ..default_opts() + }; + let r = map_to_mxc(&policy, &opts); + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert!( + rw.contains(&"/work".to_owned()), + "cwd must appear in readwritePaths" + ); + let count = rw.iter().filter(|p| p.as_str() == "/work").count(); + assert_eq!(count, 1, "cwd must not be duplicated"); + + // Also test where cwd is new. + let opts2 = MxcMappingOptions { + cwd: Some("/newcwd".to_owned()), + ..default_opts() + }; + let policy2 = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r2 = map_to_mxc(&policy2, &opts2); + let rw2 = str_list(&r2.config["filesystem"]["readwritePaths"]); + assert!( + rw2.contains(&"/newcwd".to_owned()), + "new cwd must be appended to readwritePaths" + ); +} + +/// include_workdir=true, no cwd → an "info" loss item, no extra path added. +#[test] +fn a_include_workdir_no_cwd_emits_info_loss() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); // cwd is None + let has_info = r + .loss + .iter() + .any(|i| i.severity == "info" && i.path.contains("include_workdir")); + assert!( + has_info, + "expected info loss for include_workdir without cwd; got: {:?}", + r.loss + ); + // The path list must not have grown beyond the source list. + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert_eq!(rw, vec!["/work"]); +} + +/// Plain endpoint host → appears in allowedHosts. +#[test] +fn a_plain_host_in_allowed_hosts() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"api.example.com".to_owned()), + "host must appear in allowedHosts; got: {hosts:?}" + ); +} + +/// endpoint.allowed_ips → each IP appended to allowedHosts + a "warning" loss. +#[test] +fn a_allowed_ips_appended_with_warning() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "db.internal".into(), + allowed_ips: vec!["10.0.0.1".into(), "10.0.0.2".into()], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"10.0.0.1".to_owned()), + "IP 10.0.0.1 must be in allowedHosts" + ); + assert!( + hosts.contains(&"10.0.0.2".to_owned()), + "IP 10.0.0.2 must be in allowedHosts" + ); + let warnings: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "warning" && i.path.contains("allowed_ips")) + .collect(); + assert!( + !warnings.is_empty(), + "expected warning loss for allowed_ips; got: {:?}", + r.loss + ); +} + +/// Duplicate hosts across rules → deduplicated in allowedHosts. +#[test] +fn a_duplicate_hosts_deduplicated() { + let mut policy = SandboxPolicy::default(); + let ep_a = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + let ep_b = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + policy.network_policies.insert( + "rule_a".to_owned(), + NetworkPolicyRule { + name: "rule_a".to_owned(), + endpoints: vec![ep_a], + binaries: Vec::new(), + }, + ); + policy.network_policies.insert( + "rule_b".to_owned(), + NetworkPolicyRule { + name: "rule_b".to_owned(), + endpoints: vec![ep_b], + binaries: Vec::new(), + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + let count = hosts + .iter() + .filter(|h| h.as_str() == "shared.example.com") + .count(); + assert_eq!( + count, 1, + "duplicate hosts must be deduplicated; got: {hosts:?}" + ); +} + +/// Determinism: a policy with 3+ network rules (unordered map) maps twice → identical config JSON. +#[test] +fn a_deterministic_with_multiple_rules() { + let mut policy = SandboxPolicy::default(); + for (key, host) in &[ + ("rule_z", "z.example.com"), + ("rule_a", "a.example.com"), + ("rule_m", "m.example.com"), + ("rule_b", "b.example.com"), + ] { + policy.network_policies.insert( + key.to_string(), + NetworkPolicyRule { + name: key.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + } + let r1 = map_to_mxc(&policy, &bubblewrap_opts()); + let r2 = map_to_mxc(&policy, &bubblewrap_opts()); + assert_eq!( + r1.config, r2.config, + "map_to_mxc must be deterministic across two calls" + ); +} + +/// split path: proxy_policy.network_policies == source's, proxy_policy.version preserved. +#[test] +fn a_split_network_verbatim_and_version_preserved() { + let mut policy = SandboxPolicy { + version: 42, + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.proxy_policy.network_policies, policy.network_policies, + "split: proxy_policy.network_policies must equal source" + ); + assert_eq!( + result.proxy_policy.version, 42, + "split: proxy_policy.version must equal source" + ); +} + +/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +#[test] +fn a_split_proxy_localhost_port() { + let policy = SandboxPolicy::default(); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost == port" + ); + // allowedHosts stays empty on the split path. + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path must have empty allowedHosts" + ); +} + +// ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── +// +// For each field: build a minimal policy setting only that field (plus the +// minimum needed to reach the code path), map with default bubblewrap +// containment, assert exactly one loss item with the documented path fragment +// and severity. + +/// endpoint.ports → "error" (path contains ".port"). +#[test] +fn b_ports_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".port", "error", "endpoint.ports"); +} + +/// endpoint.protocol → "error". +#[test] +fn b_protocol_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + protocol: "rest".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".protocol", "error", "endpoint.protocol"); +} + +/// endpoint.tls = "skip" → "warning". +#[test] +fn b_tls_skip_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "skip".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "warning", "endpoint.tls=skip"); +} + +/// endpoint.tls = "full" (any non-skip) → "error". +#[test] +fn b_tls_non_skip_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "terminate".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "error", "endpoint.tls=terminate"); +} + +/// endpoint.enforcement = "audit" → "error". +#[test] +fn b_enforcement_audit_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "audit".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "error", + "endpoint.enforcement=audit", + ); +} + +/// endpoint.enforcement = "enforce" (non-audit) → "warning". +#[test] +fn b_enforcement_non_audit_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "enforce".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "warning", + "endpoint.enforcement=enforce", + ); +} + +/// endpoint.access → "error". +#[test] +fn b_access_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + access: "read-only".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".access", "error", "endpoint.access"); +} + +/// endpoint.rules (one allow rule) → "error". +#[test] +fn b_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/api".into(), + ..Default::default() + }), + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".rules", "error", "endpoint.rules"); +} + +/// endpoint.deny_rules → "error". +#[test] +fn b_deny_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/admin".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".deny_rules", "error", "endpoint.deny_rules"); +} + +/// endpoint.allow_encoded_slash=true → "error". +#[test] +fn b_allow_encoded_slash_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + allow_encoded_slash: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".allow_encoded_slash", + "error", + "allow_encoded_slash", + ); +} + +/// endpoint.websocket_credential_rewrite=true → "error". +#[test] +fn b_websocket_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + websocket_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".websocket_credential_rewrite", + "error", + "websocket_credential_rewrite", + ); +} + +/// endpoint.request_body_credential_rewrite=true → "error". +#[test] +fn b_request_body_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + request_body_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".request_body_credential_rewrite", + "error", + "request_body_credential_rewrite", + ); +} + +/// endpoint.persisted_queries → "error". +#[test] +fn b_persisted_queries_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + persisted_queries: "deny".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".persisted_queries", "error", "persisted_queries"); +} + +/// endpoint.graphql_persisted_queries → "error". +#[test] +fn b_graphql_persisted_queries_error() { + let mut ep = NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }; + ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + let policy = net_policy("r", ep); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_persisted_queries", + "error", + "graphql_persisted_queries", + ); +} + +/// endpoint.graphql_max_body_bytes > 0 → "error". +#[test] +fn b_graphql_max_body_bytes_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + graphql_max_body_bytes: 65536, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_max_body_bytes", + "error", + "graphql_max_body_bytes", + ); +} + +/// Wildcard host ("*.example.com") with allow_wildcards=false → "error", host NOT in allowedHosts. +#[test] +fn b_wildcard_host_deny_wildcards_error_host_absent() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..Default::default() + }, + ); + let opts = MxcMappingOptions { + allow_wildcards: false, + containment: "bubblewrap".to_owned(), + ..Default::default() + }; + let r = map_to_mxc(&policy, &opts); + // Must have an "error" loss for the wildcard host. + let err_loss: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !err_loss.is_empty(), + "expected error loss for wildcard host; got: {:?}", + r.loss + ); + // Host must NOT be in allowedHosts when allow_wildcards=false. + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + !hosts.contains(&"*.example.com".to_owned()), + "wildcard host must be absent from allowedHosts when allow_wildcards=false; got: {hosts:?}" + ); +} + +/// Wildcard host with allow_wildcards=true → "error" (semantics warning), host IS in allowedHosts. +#[test] +fn b_wildcard_host_allow_wildcards_error_host_present() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..Default::default() + }, + ); + let opts = MxcMappingOptions { + allow_wildcards: true, + containment: "bubblewrap".to_owned(), + ..Default::default() + }; + let r = map_to_mxc(&policy, &opts); + // Still an "error" loss (MXC semantics warning), even though we emitted the host. + let err_loss: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !err_loss.is_empty(), + "expected error loss for wildcard host even with allow_wildcards=true; got: {:?}", + r.loss + ); + // Host IS in allowedHosts when allow_wildcards=true. + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"*.example.com".to_owned()), + "wildcard host must appear in allowedHosts when allow_wildcards=true; got: {hosts:?}" + ); +} + +/// rule.binaries non-empty → "error" per binary. +#[test] +fn b_binaries_error_per_binary() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: vec![ + NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }, + NetworkBinary { + path: "/usr/bin/wget".into(), + ..Default::default() + }, + ], + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let binary_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains("binaries[")) + .collect(); + assert_eq!( + binary_errors.len(), + 2, + "expected one error loss per binary; got: {:?}", + r.loss + ); +} + +/// rule with empty endpoints → "error". +#[test] +fn b_empty_endpoints_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: Vec::new(), // empty + binaries: Vec::new(), + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let endpoint_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".endpoints")) + .collect(); + assert!( + !endpoint_errors.is_empty(), + "expected error loss for empty endpoints; got: {:?}", + r.loss + ); +} + +/// endpoint with empty host → "error". +#[test] +fn b_empty_host_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: String::new(), // empty + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let host_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !host_errors.is_empty(), + "expected error loss for empty host; got: {:?}", + r.loss + ); +} + +/// rule with empty binaries → "error". +#[test] +fn b_empty_binaries_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), // empty + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let bin_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".binaries")) + .collect(); + assert!( + !bin_errors.is_empty(), + "expected error loss for empty binaries; got: {:?}", + r.loss + ); +} + +/// policy.landlock = Some(default) → "warning". +#[test] +fn b_landlock_warning() { + let policy = SandboxPolicy { + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "landlock", "warning", "landlock"); +} + +/// process.run_as_user non-empty → "warning". +#[test] +fn b_run_as_user_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: String::new(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "run_as_user", "warning", "process.run_as_user"); +} + +/// process.run_as_group non-empty → "warning". +#[test] +fn b_run_as_group_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: String::new(), + run_as_group: "sandboxers".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "run_as_group", "warning", "process.run_as_group"); +} + +/// Seam-level: EmbeddedPolicyMapper.map over a policy with one error-class field +/// (a port) via MapCtx{egress: None} returns Err(MapError::Unsupported(_)). +#[test] +fn b_seam_returns_unsupported_on_error_field() { + let mapper = EmbeddedPolicyMapper; + // isolation_session containment + network policy → error loss from add_backend_specific_config. + let mut policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["C:/work".into()], + ..Default::default() + }), + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let ctx = MapCtx { + sandbox_id: "sb-test".into(), + egress: None, // coarse path → isolation_session → network policy errors + }; + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!( + matches!(err, MapError::Unsupported(_)), + "seam must return MapError::Unsupported for error-class losses; got: {err:?}" + ); +} + +// ─── QUADRANT C: restrictive defaults ("default deny posture") ─────────────── + +/// Empty SandboxPolicy (all None/empty) with default options produces the most +/// restrictive possible MXC config. +#[test] +fn c_empty_policy_default_deny_posture() { + let policy = SandboxPolicy::default(); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let cfg = &r.config; + + // Network: default deny, no allowed/blocked hosts. + assert_eq!( + cfg["network"]["defaultPolicy"], "block", + "defaultPolicy must be 'block' on empty policy" + ); + assert!( + str_list(&cfg["network"]["allowedHosts"]).is_empty(), + "allowedHosts must be [] on empty policy" + ); + assert!( + str_list(&cfg["network"]["blockedHosts"]).is_empty(), + "blockedHosts must be [] on empty policy" + ); + + // UI: fully locked down. + assert_eq!(cfg["ui"]["disable"], true, "ui.disable must be true"); + assert_eq!( + cfg["ui"]["clipboard"], "none", + "ui.clipboard must be 'none'" + ); + assert_eq!(cfg["ui"]["injection"], false, "ui.injection must be false"); + + // Lifecycle: destroyOnExit + no policy preservation. + assert_eq!( + cfg["lifecycle"]["destroyOnExit"], true, + "lifecycle.destroyOnExit must be true" + ); + assert_eq!( + cfg["lifecycle"]["preservePolicy"], false, + "lifecycle.preservePolicy must be false" + ); + + // Filesystem: all lists empty, deniedPaths empty. + assert!( + str_list(&cfg["filesystem"]["readwritePaths"]).is_empty(), + "readwritePaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty(), + "readonlyPaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["deniedPaths"]).is_empty(), + "deniedPaths must be [] on empty policy" + ); + + // No processContainer key when no hosts are granted. + assert!( + cfg.get("processContainer").is_none(), + "processContainer must be absent when no hosts are mapped" + ); + + // No enforcementMode key when allowedHosts is empty. + assert!( + cfg["network"].get("enforcementMode").is_none(), + "enforcementMode must be absent when allowedHosts is empty" + ); +} + +/// Split path with network rules present: allowedHosts stays empty. +#[test] +fn c_split_empty_allowed_hosts_with_network_rules() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path allowedHosts must be empty even with network rules; got: {:?}", + result.mxc_config["network"]["allowedHosts"] + ); + // But proxy redirect is present. + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost" + ); +} + +// ─── DRIFT GUARD ───────────────────────────────────────────────────────────── +// +// Serialize policies via openshell_policy::serialize_sandbox_policy, collect +// YAML keys, compare against HANDLED_* const slices. Fails when a new field +// is added to the schema without updating the mapper. + +/// Top-level fields of the YAML policy schema that the mapper handles today. +/// Derived from what map.rs and build_split_mxc_config actually read. +/// +/// "version" — emitted into mxc_config["version"] (not a loss) +/// "filesystem_policy" — mapped via map_filesystem +/// "landlock" — loss item emitted in add_static_policy_loss +/// "process" — loss items for run_as_user / run_as_group +/// "network_policies" — mapped via map_network / delegated in split +const HANDLED_TOPLEVEL: &[&str] = &[ + "version", + "filesystem_policy", + "landlock", + "process", + "network_policies", + "network_middlewares", +]; + +/// Per-rule keys under each network_policies entry that the mapper handles. +const HANDLED_RULE_KEYS: &[&str] = &["name", "endpoints", "binaries"]; + +/// Per-endpoint keys that the mapper accounts for (mapping or loss item). +/// +/// "host" — mapped to allowedHosts (or loss if wildcard/empty) +/// "port" — normalized to ports at parse; covered by ports loss +/// "ports" — error loss +/// "protocol" — error loss +/// "tls" — warning (skip) or error loss +/// "enforcement" — error (audit) or warning (other) loss +/// "access" — error loss +/// "rules" — error loss +/// "allowed_ips" — appended to allowedHosts + warning loss +/// "deny_rules" — error loss +/// "allow_encoded_slash" — error loss +/// "websocket_credential_rewrite" — error loss +/// "request_body_credential_rewrite" — error loss +/// "persisted_queries" — error loss +/// "graphql_persisted_queries" — error loss +/// "graphql_max_body_bytes" — error loss +/// "path" — not currently read by the mapper (no loss emitted); +/// included here so the drift guard does not trip on +/// existing schema fields the mapper silently ignores. +/// If the mapper needs to enforce path-scoped routing, +/// remove this entry and add an explicit loss item. +const HANDLED_ENDPOINT_KEYS: &[&str] = &[ + "host", + "port", + "ports", + "protocol", + "tls", + "enforcement", + "access", + "rules", + "allowed_ips", + "deny_rules", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "path", +]; + +#[test] +fn handled_fields_inventory() { + use std::collections::BTreeSet; + + // ── (1) Top-level keys: serialize a SandboxPolicy with every section + // present-but-minimal, then collect YAML keys. ────────────────────────── + let full_toplevel_policy = SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: vec!["/usr".into()], + read_write: vec!["/work".into()], + }), + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: "sandbox".into(), + }), + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + port: 443, + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + m + }, + network_middlewares: { + let mut m = std::collections::HashMap::new(); + m.insert("redactor".to_owned(), middleware_config()); + m + }, + }; + + // Validate so the test itself doesn't carry a bad policy. + validate_sandbox_policy(&full_toplevel_policy).expect("test policy must be valid"); + + let yaml_toplevel = + serialize_sandbox_policy(&full_toplevel_policy).expect("serialize full_toplevel_policy"); + let top_value: Value = + serde_yml::from_str::(&yaml_toplevel).expect("re-parse as JSON value"); + let top_obj = top_value + .as_object() + .expect("top-level must be a JSON object"); + let observed_toplevel: BTreeSet<&str> = top_obj.keys().map(String::as_str).collect(); + let expected_toplevel: BTreeSet<&str> = HANDLED_TOPLEVEL.iter().copied().collect(); + + let unhandled_top: Vec<&&str> = observed_toplevel + .iter() + .filter(|k| !expected_toplevel.contains(**k)) + .collect(); + assert!( + unhandled_top.is_empty(), + "openshell-policy gained top-level field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_TOPLEVEL in this test.", + unhandled_top + ); + + let missing_top: Vec<&&str> = expected_toplevel + .iter() + .filter(|k| !observed_toplevel.contains(**k)) + .collect(); + assert!( + missing_top.is_empty(), + "HANDLED_TOPLEVEL lists field(s) {:?} that are no longer emitted by \ + serialize_sandbox_policy — remove them from HANDLED_TOPLEVEL.", + missing_top + ); + + // ── (2) Per-rule and per-endpoint keys: serialize a policy with one fully- + // populated NetworkPolicyRule / NetworkEndpoint. ───────────────────────── + // + // Note: `port` (scalar) and `ports` (array) are mutually exclusive in the + // serialized form — single port emits `port`; multiple ports emit `ports`. + // To cover both variants we use TWO endpoints: the first with multi-port + // (triggers `ports` key), the second with single-port (triggers `port`). + // The drift guard collects the UNION of all endpoint keys observed. + let mut full_ep = NetworkEndpoint { + host: "api.example.com".into(), + path: "/graphql".into(), + // Two ports → serializes as `ports: [80, 443]` (array form). + ports: vec![80, 443], + protocol: "graphql".into(), + tls: "skip".into(), + enforcement: "enforce".into(), + access: "full".into(), + allowed_ips: vec!["10.0.0.1".into()], + allow_encoded_slash: true, + websocket_credential_rewrite: true, + request_body_credential_rewrite: true, + persisted_queries: "deny".into(), + graphql_max_body_bytes: 65536, + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/foo".into(), + ..Default::default() + }), + }], + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/bar".into(), + ..Default::default() + }], + ..Default::default() + }; + full_ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + // Second endpoint: single port → serializes as `port: 443` (scalar form). + let single_port_ep = NetworkEndpoint { + host: "other.example.com".into(), + ports: vec![443], + ..Default::default() + }; + + let full_rule_policy = SandboxPolicy { + version: 1, + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![full_ep, single_port_ep], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + m + }, + ..Default::default() + }; + + let yaml_rule = + serialize_sandbox_policy(&full_rule_policy).expect("serialize full_rule_policy"); + let rule_value: Value = + serde_yml::from_str::(&yaml_rule).expect("re-parse rule policy as JSON value"); + + // Collect per-rule keys. + let network_policies_obj = rule_value["network_policies"] + .as_object() + .expect("network_policies must be an object"); + let first_rule = network_policies_obj + .values() + .next() + .expect("at least one rule") + .as_object() + .expect("rule must be an object"); + let observed_rule_keys: BTreeSet<&str> = first_rule.keys().map(String::as_str).collect(); + let expected_rule_keys: BTreeSet<&str> = HANDLED_RULE_KEYS.iter().copied().collect(); + + let unhandled_rule: Vec<&&str> = observed_rule_keys + .iter() + .filter(|k| !expected_rule_keys.contains(**k)) + .collect(); + assert!( + unhandled_rule.is_empty(), + "openshell-policy gained network rule field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_RULE_KEYS in this test.", + unhandled_rule + ); + + let missing_rule: Vec<&&str> = expected_rule_keys + .iter() + .filter(|k| !observed_rule_keys.contains(**k)) + .collect(); + assert!( + missing_rule.is_empty(), + "HANDLED_RULE_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_rule + ); + + // Collect per-endpoint keys: union across ALL endpoints so that mutually- + // exclusive fields like `port` (single-port form) and `ports` (multi-port + // form) are both captured. + let endpoints_arr = first_rule["endpoints"] + .as_array() + .expect("endpoints must be an array"); + let observed_ep_keys: BTreeSet<&str> = endpoints_arr + .iter() + .filter_map(|ep| ep.as_object()) + .flat_map(|obj| obj.keys().map(String::as_str)) + .collect(); + let expected_ep_keys: BTreeSet<&str> = HANDLED_ENDPOINT_KEYS.iter().copied().collect(); + + let unhandled_ep: Vec<&&str> = observed_ep_keys + .iter() + .filter(|k| !expected_ep_keys.contains(**k)) + .collect(); + assert!( + unhandled_ep.is_empty(), + "openshell-policy gained endpoint field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_ENDPOINT_KEYS in this test.", + unhandled_ep + ); + + let missing_ep: Vec<&&str> = expected_ep_keys + .iter() + .filter(|k| !observed_ep_keys.contains(**k)) + .collect(); + assert!( + missing_ep.is_empty(), + "HANDLED_ENDPOINT_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_ep + ); +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs new file mode 100644 index 0000000000..dd16836e4b --- /dev/null +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -0,0 +1,795 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real-`wxc-exec` integration tests (Tier 2). +//! +//! These tests drive the actual `wxc-exec.exe` binary — no mock shim. Every +//! test is `#[ignore = "requires real wxc-exec"]` so the regular `cargo test` +//! suite (`windows:test:x64`) never blocks on hardware. Run them with: +//! +//! ```powershell +//! $env:OPENSHELL_WXC_EXEC_PATH = "C:\mxc\wxc-exec.exe" +//! cargo test -p openshell-driver-mxc --test wxc_exec_real -- --ignored --test-threads=1 +//! ``` +//! +//! Two families: +//! +//! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on +//! schema acceptance. Some `wxc-exec` builds select the DACL fallback during +//! dry-run and validate filesystem grants, so these tests use owned temporary +//! directories with concrete Windows paths. +//! +//! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason +//! and return early when the backend is not live. The probe distinguishes +//! "binary absent", "`backend_error` / velocity keys not enabled", and +//! "`backend_unavailable`". +//! +//! IMPORTANT: `OPENSHELL_MXC_MOCK_WXC` must NOT be set when running this file. +//! The probe-gated enforcement tests assert that it is absent so a stale env +//! var can never silently re-mock a "real" run. + +#![cfg(target_os = "windows")] + +use base64::Engine as _; +use std::path::PathBuf; +use std::process::Command; + +// ── Path resolution ────────────────────────────────────────────────────────── + +/// Resolve the path to `wxc-exec.exe`. +/// +/// Checks `OPENSHELL_WXC_EXEC_PATH` first, then the canonical demo-box +/// location `C:\mxc\wxc-exec.exe`. Returns `None` when neither path exists so +/// callers can skip rather than fail. +fn wxc_path() -> Option { + if let Ok(p) = std::env::var("OPENSHELL_WXC_EXEC_PATH") { + let pb = PathBuf::from(&p); + if pb.exists() { + return Some(pb); + } + // Env var was set but path is absent — still treat as "not found" so + // tests skip with a clear reason rather than erroring on spawn. + eprintln!("SKIP: OPENSHELL_WXC_EXEC_PATH={p} does not exist"); + return None; + } + let default = PathBuf::from(r"C:\mxc\wxc-exec.exe"); + if default.exists() { + return Some(default); + } + None +} + +// ── Dry-run helper ──────────────────────────────────────────────────────────── + +/// Invoke `wxc-exec --config-base64 --dry-run` synchronously. +/// Returns `(exit_code, stdout, stderr)`. +fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { + let json = serde_json::to_string(config).expect("config serialize"); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--dry-run") + .output() + .expect("wxc-exec spawn"); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + (code, stdout, stderr) +} + +// ── (a) Dry-run contract tests ──────────────────────────────────────────────── +// +// These PASS on any box that has the wxc-exec binary — no enforcement backend +// is required because --dry-run only validates the JSON schema. + +/// Minimal processcontainer one-shot config accepted by `--dry-run`. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_minimal_processcontainer_config() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-minimal", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "minimal processcontainer config rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// Network block without proxy (defaultPolicy block, empty host lists) accepted. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_network_block_without_proxy() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-net-block", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "network block without proxy rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// The ONLY accepted proxy shape in MXC 0.6.0-alpha: `{"localhost": }`. +/// Verified empirically against the real binary — any other shape is rejected +/// with "Request error". +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_localhost_proxy_shape() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-localhost", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + "proxy": { "localhost": 18080 }, + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "{{\"localhost\": N}} proxy shape rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// The `{"host": ..., "port": ...}` proxy shape is REJECTED by MXC 0.6.0-alpha. +/// This test guards the schema contract discovered via dry-run bisection. +/// See docs4gtb/mxc-box-capabilities.md §"Schema contract findings". +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_rejects_host_port_proxy_shape() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-hostport", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + // MXC 0.6.0-alpha rejects {"host","port"} — verified empirically. + "proxy": { "host": "127.0.0.1", "port": 18080 }, + }, + }); + + let (code, _stdout, _stderr) = dry_run(&wxc, &config); + assert_ne!( + code, 0, + "{{\"host\",\"port\"}} proxy shape was unexpectedly ACCEPTED — \ + schema may have widened in a newer wxc-exec build" + ); +} + +/// Unknown containment value is rejected. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_rejects_unknown_containment() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-bad-containment", + "containment": "nonsense", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + }); + + let (code, _stdout, _stderr) = dry_run(&wxc, &config); + assert_ne!(code, 0, "unknown containment 'nonsense' should be rejected"); +} + +/// The most important dry-run test: parse the quickstart example policy with +/// `openshell_policy`, run `split_policy` (`proxy_redirect` 127.0.0.1:18080, +/// containment "processcontainer"), take the resulting `mxc_config`, inject a +/// real process block with a valid cwd, and verify that `--dry-run` exits 0. +/// +/// This proves that the mapper's emitted JSON is accepted by the real binary — +/// the central contract of the policy-mapper integration. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_split_policy_output() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + // Find the quickstart policy relative to CARGO_MANIFEST_DIR. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let policy_path = manifest_dir.join("../../examples/sandbox-policy-quickstart/policy.yaml"); + + if !policy_path.exists() { + eprintln!( + "SKIP: quickstart policy not found at {}", + policy_path.display() + ); + return; + } + + let yaml = std::fs::read_to_string(&policy_path).expect("read policy YAML"); + let policy = openshell_policy::parse_sandbox_policy(&yaml).expect("parse quickstart policy"); + + let opts = openshell_driver_mxc::MxcMappingOptions { + containment: "processcontainer".to_string(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + + let result = openshell_driver_mxc::split_policy(&policy, &opts) + .expect("split_policy must return Some when proxy_redirect is set"); + // The quickstart policy has network_policies with error-level losses on + // isolation_session, but on processcontainer there should be zero error + // losses from the split itself. Warn if there are any error losses so the + // test is informative even when it proceeds. + let error_losses: Vec<_> = result + .loss + .iter() + .filter(|l| l.severity == "error") + .collect(); + if !error_losses.is_empty() { + eprintln!( + "split_policy emitted {} error loss item(s); proceeding to dry-run:\n{:#?}", + error_losses.len(), + error_losses + ); + } + + // Take the mapper's MXC config and inject the required process block. + // The split config does not include a process block (that comes from the + // gateway TOML at runtime); wxc-exec --dry-run requires one. + // + // The quickstart policy uses sandbox-internal Unix paths. Replace only the + // environment-dependent filesystem paths with an owned Windows directory: + // this test verifies the mapper's MXC JSON shape, while mapper unit tests + // cover the exact filesystem translation. + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let mut mxc_config = result.mxc_config.clone(); + mxc_config["filesystem"] = serde_json::json!({ + "readwritePaths": [tmpdir_str], + "readonlyPaths": [], + "deniedPaths": [], + }); + mxc_config["process"] = serde_json::json!({ + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }); + // containerId is also required for processcontainer. + mxc_config["containerId"] = serde_json::json!("split-policy-dryrun"); + + let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); + assert_eq!( + code, + 0, + "split_policy output rejected by --dry-run; \ + this proves the mapper emits valid MXC JSON\n\ + config={}\nstdout={stdout}\nstderr={stderr}", + serde_json::to_string_pretty(&mxc_config).unwrap_or_default() + ); +} + +// ── (b) Enforcement tests — probe-gated ─────────────────────────────────────── +// +// These skip on this box (processcontainer velocity keys not enabled; +// isolation_session backend absent). They PASS where backends are live. + +/// Probe the processcontainer backend. +/// +/// Runs a trivial one-shot (`cmd /c exit 0`, owned temporary-directory grant). +/// Returns `Ok(())` when the backend is live, or `Err(reason)` when it is not (the +/// caller prints SKIP + reason and returns from the test). +fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { + // Abort early if the mock env var is set — a stale OPENSHELL_MXC_MOCK_WXC + // would silently turn this "real" run back into a mock run. + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let tmpdir = tempfile::tempdir().map_err(|error| format!("tempdir failed: {error}"))?; + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "probe-pc", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 10, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout = String::from_utf8_lossy(&out.stdout).to_lowercase(); + let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout} {stderr}"); + + if combined.contains("backend_error") + || combined.contains("e_notimpl") + || combined.contains("velocity") + || combined.contains("not enabled") + { + // Extract the message if possible for a more useful skip reason. + let reason = + serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) + .map_or_else( + |_| "backend_error (velocity keys not enabled)".to_string(), + |value| { + value["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + }, + ); + return Err(reason); + } + + if !out.status.success() { + return Err(format!( + "processcontainer probe returned exit {}: stdout={} stderr={}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + )); + } + + Ok(()) +} + +/// Probe the `isolation_session` backend. +/// +/// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or +/// `Err(reason)` when the backend is unavailable (caller prints SKIP). +fn probe_isolation_session(wxc: &PathBuf) -> Result { + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": "composable", + "provision": {} + } + } + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout_raw = String::from_utf8_lossy(&out.stdout).into_owned(); + let stdout_lower = stdout_raw.to_lowercase(); + let stderr_lower = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout_lower} {stderr_lower}"); + + if combined.contains("backend_unavailable") || combined.contains("0x80040154") { + return Err( + "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553".to_string(), + ); + } + + if !out.status.success() { + return Err(format!( + "isolation_session provision failed (exit {}): {}", + out.status.code().unwrap_or(-1), + stdout_raw + )); + } + + // Parse the sandboxId from {"result":{"sandboxId":"iso:..."}} + let env: serde_json::Value = serde_json::from_str(&stdout_raw) + .map_err(|e| format!("provision envelope parse failed: {e}: {stdout_raw}"))?; + + let sandbox_id = env["result"]["sandboxId"] + .as_str() + .ok_or_else(|| format!("sandboxId missing in provision result: {stdout_raw}"))? + .to_string(); + + Ok(sandbox_id) +} + +/// RAII guard that best-effort deprovisioning on drop — protects the +/// single-session backend against orphaned sessions. +struct DeprovisionGuard<'a> { + wxc: &'a PathBuf, + sandbox_id: Option, +} + +impl<'a> DeprovisionGuard<'a> { + fn new(wxc: &'a PathBuf, sandbox_id: String) -> Self { + Self { + wxc, + sandbox_id: Some(sandbox_id), + } + } + + fn disarm(&mut self) { + self.sandbox_id = None; + } + + fn deprovision_now(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } + + fn run_deprovision(wxc: &PathBuf, sandbox_id: &str) { + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "deprovision", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + // Unit variant: must be null, not {} (malformed_request otherwise). + "deprovision": null + } + } + }); + let json = serde_json::to_string(&config).unwrap_or_default(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + // Best-effort: ignore errors so the test does not panic in drop. + let _ = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output(); + } +} + +impl Drop for DeprovisionGuard<'_> { + fn drop(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } +} + +// ── Processcontainer enforcement tests ─────────────────────────────────────── + +/// Write a file inside the granted temp dir; assert the file appears and the +/// exit code is 0. Requires the processcontainer backend to be live. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_in_policy_write_succeeds() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let target = tmpdir.path().join("pc-in-policy.txt"); + let target_str = target.to_string_lossy().into_owned(); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-in-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo hello > \"{target_str}\""), + "cwd": tmpdir_str, + "timeout": 30, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + + assert_eq!( + code, 0, + "in-policy write should exit 0\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + target.exists(), + "in-policy write: file should exist at {target_str}\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. +/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write +/// without requiring any host ACL lockdown. The mock can only fake this. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_out_of_policy_write_denied() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let granted_dir = tempfile::tempdir().expect("granted tempdir"); + let denied_dir = tempfile::tempdir().expect("denied tempdir"); + let denied_file = denied_dir.path().join("pc-out-of-policy.txt"); + let denied_file_str = denied_file.to_string_lossy().into_owned(); + let granted_str = granted_dir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-out-of-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo denied > \"{denied_file_str}\""), + "cwd": granted_str, + "timeout": 30, + }, + "filesystem": { + // Only the granted_dir is in policy — denied_dir is NOT granted. + "readwritePaths": [granted_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + + assert_ne!( + code, 0, + "out-of-policy write should be denied (non-zero exit)\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + !denied_file.exists(), + "out-of-policy file must be absent at {denied_file_str} (OS default-deny proof)\n\ + stdout={stdout}\nstderr={stderr}" + ); +} + +// ── Isolation session enforcement tests ────────────────────────────────────── + +/// Full `isolation_session` round trip: provision → start → exec → stop → +/// deprovision. `deprovision` runs in a drop-guard even on panic so the +/// single-session backend is never left orphaned. +#[test] +#[ignore = "requires real wxc-exec"] +fn iso_lifecycle_round_trip() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let sandbox_id = match probe_isolation_session(&wxc) { + Ok(id) => id, + Err(reason) => { + eprintln!("SKIP: isolation_session not live: {reason}"); + return; + } + }; + + // Guard ensures deprovision even on panic. + let mut guard = DeprovisionGuard::new(&wxc, sandbox_id.clone()); + + // start + let start_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "start", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + let json = serde_json::to_string(&start_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("start"); + assert!( + out.status.success(), + "start failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // exec. timeout is MILLISECONDS; 0 = no timeout. Empirical (test box, + // build 26300.8553, wxc-exec 2026-06-10): a small positive value (30) is + // rejected by RunProcessWithOptionsAsync with "Invalid timeout value" + // (HRESULT 0x80070057). 0 is the documented no-timeout value and matches + // what the driver's exec path sends by default (MxcProcess.timeout = 0). + let exec_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "exec", + "sandboxId": sandbox_id, + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "C:\\Windows\\Temp", + "env": [], + "timeout": 0, + } + }); + let json = serde_json::to_string(&exec_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("exec"); + assert_eq!( + out.status.code().unwrap_or(-1), + 0, + "exec phase should exit 0: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // stop + let stop_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "stop", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + // Unit variant: must be null, not {} (malformed_request otherwise). + "stop": null + } + } + }); + let json = serde_json::to_string(&stop_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("stop"); + assert!( + out.status.success(), + "stop failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // deprovision (also disarms the guard so Drop does not double-deprovision) + guard.deprovision_now(); + guard.disarm(); +} diff --git a/crates/openshell-driver-podman/BUILD.bazel b/crates/openshell-driver-podman/BUILD.bazel deleted file mode 100644 index d52f9b3360..0000000000 --- a/crates/openshell-driver-podman/BUILD.bazel +++ /dev/null @@ -1,41 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_binary.bzl", "rust_binary") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-driver-podman", - srcs = glob( - ["src/**/*.rs"], - exclude = ["src/main.rs"], - ), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_binary( - name = "openshell-driver-podman_bin", - srcs = ["src/main.rs"], - aliases = aliases(), - binary_name = "openshell-driver-podman", - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True) + [":openshell-driver-podman"], -) - -rust_test( - name = "openshell-driver-podman_test", - crate = ":openshell-driver-podman", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-driver-podman", - ":openshell-driver-podman_bin", - ":openshell-driver-podman_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index e46d2eed85..8b3e014e8c 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -15,7 +15,8 @@ name = "openshell-driver-podman" path = "src/main.rs" [dependencies] -openshell-core = { path = "../openshell-core", default-features = false } +openshell-core = { path = "../openshell-core", default-features = false, features = ["driver-extraction"] } +openshell-otel = { path = "../openshell-otel" } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } @@ -32,11 +33,18 @@ nix = { workspace = true } rustix = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +tracing-opentelemetry = { workspace = true } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } +tower-http = { workspace = true } +http = { workspace = true } thiserror = { workspace = true } miette = { workspace = true } url = { workspace = true } [dev-dependencies] +openshell-otel-test-support = { path = "../openshell-otel-test-support" } +opentelemetry_sdk = { workspace = true, features = ["testing"] } prost-types = { workspace = true } temp-env = "0.3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/openshell-driver-podman/NETWORKING.md b/crates/openshell-driver-podman/NETWORKING.md index 4b2ae7ff29..567abcbfcd 100644 --- a/crates/openshell-driver-podman/NETWORKING.md +++ b/crates/openshell-driver-podman/NETWORKING.md @@ -275,13 +275,11 @@ and rootless pasta because the driver maps both local callback aliases to that literal. Other rootless helpers still fail closed. Podman Machine requests gateway loopback because its configured address is guest-visible and gvproxy terminates that route on host loopback. The gateway validates and binds every -accepted callback listener. A callback address cannot equal the exact primary -listener address because the gateway could not distinguish their authorization -scopes. In particular, a Podman Machine gateway using the IPv4 loopback -callback must place its primary listener on another address, such as IPv6 -loopback (`[::1]:17670`). Negotiated callback listeners expose only the -gateway's sandbox-callable gRPC methods. Operator, health, reflection, and HTTP -requests must use the primary listener. +accepted callback requirement. If the primary listener covers the requested +address, the gateway reuses it and relies on sandbox JWT authorization to limit +the supervisor's RPCs. Otherwise, it creates an additional listener that +exposes only the gateway's sandbox-callable gRPC methods. Operator, health, +reflection, and HTTP requests must use the primary listener. ### Layer 3 Inner Sandbox Network Namespace @@ -319,6 +317,19 @@ The supervisor uses `nsenter --net=` rather than `ip netns exec` to avoid sysfs remount issues that arise under rootless Podman where real host `CAP_SYS_ADMIN` is unavailable. +For a policy with explicit `protocol: tcp` endpoints, this same inner namespace +also hosts policy DNS and transparent TCP capture. The supervisor answers only +policy-eligible names with epoch-scoped synthetic addresses, redirects TCP to +those synthetic ranges into its transparent listener, and leaves direct real-IP +dials subject to the terminal bypass fence. The Podman driver advertises this +substrate through its driver-owned runtime capability; sandbox image and policy +environment values cannot opt into it independently. + +The container spec preserves Podman's resolver search domains and options. +Policy DNS captures both UDP and TCP in the inner namespace, so it does not +depend on libc honoring `use-vc` and does not change ordinary short-name +resolution for sandboxes that do not use native TCP. + A tmpfs is mounted at `/run/netns` in the container spec so the supervisor can create named network namespaces. In rootless Podman this directory does not exist on the host, so a private tmpfs gives the supervisor its own writable diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 965a295d19..e53ddc9f3f 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -7,6 +7,16 @@ driver runs in-process within the gateway server and delegates all sandbox isolation enforcement to the `openshell-sandbox` supervisor binary, which is sideloaded into each container via an OCI image volume mount. +When the gateway configures `[openshell.gateway.otlp]`, Podman compute-driver +spans export to the same OTLP/gRPC collector with the service name +`openshell-driver-podman`. The driver preserves the gateway trace context and +uses the same compute-driver RPC span names in its in-process and standalone +forms. + +`mise run gateway:podman` enables this export only when a local collector is +listening on `127.0.0.1:4317`. Otherwise, it omits the gateway OTLP configuration +so the development gateway does not repeatedly report export failures. + Before creating the container, the driver inspects the final sandbox image and captures its immutable image ID and raw OCI `Config.User`. Container creation uses that image ID with pulling disabled, preventing a mutable tag from changing @@ -19,6 +29,25 @@ independently. For a rootless networking deep dive, see [NETWORKING.md](NETWORKING.md). +## Stop and Start + +Stop stops the managed container without deleting it. The per-sandbox named +workspace volume, token and proxy-auth secrets, labels, and container metadata +remain intact. Start starts the same container and reuses the same named +volume. Stopped managed containers remain visible through list and watch +reconciliation. Delete remains responsible for removing the container, +driver-owned secrets, and workspace volume. + +The stop call waits until Podman reports the container as stopped or exited. +This keeps an immediate start from racing a rootless Podman stop that is still +finishing after its API request returns. + +Graceful gateway shutdown sends `StopSandbox` for each sandbox whose persisted +phase requires running compute without changing that persisted intent. On +startup, the gateway sends an idempotent `StartSandbox` request for the same +sandboxes, restarting their retained containers. Explicitly stopped sandboxes +remain excluded. + ## Architecture The Podman driver communicates with the Podman daemon over a Unix socket and @@ -52,7 +81,7 @@ The container spec in `container.rs` sets these security-critical fields: |---|---|---| | `user` | `0:0` | The supervisor needs root inside the container for namespace creation, proxy setup, Landlock, seccomp, and filesystem preparation. | | `cap_drop` | Selected unneeded defaults | Podman's default capability set is already restricted. The driver drops capabilities the supervisor does not need. | -| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG`, `DAC_READ_SEARCH`, `SETPCAP` | Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, and child bounding-set cleanup. | +| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG`, `DAC_READ_SEARCH`, `SETPCAP`, `KILL` | Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, child bounding-set cleanup, and forwarding shutdown signals to a workload that runs as the sandbox user. Policy DNS binds an unprivileged supervisor port and does not require `NET_BIND_SERVICE`. | | `no_new_privileges` | `true` | Prevents privilege escalation after exec. | | `seccomp_profile_path` | `unconfined` | The supervisor installs its own policy-aware BPF filter. A container-level profile can block Landlock/seccomp syscalls during setup. | | `mounts` | Private tmpfs at `/run/netns` | Lets the supervisor create named network namespaces in rootless Podman. | @@ -117,7 +146,7 @@ and `FOWNER` capabilities because the supervisor needs them to drop privileges and prepare writable sandbox directories. It also keeps `SETPCAP` until child setup so `drop_privileges()` can clear the child capability bounding set before exec. It drops unneeded defaults such as -`DAC_OVERRIDE`, `FSETID`, `KILL`, `NET_BIND_SERVICE`, `NET_RAW`, `SETFCAP`, +`DAC_OVERRIDE`, `FSETID`, `KILL`, `NET_RAW`, `SETFCAP`, and `SYS_CHROOT`. ## Supervisor Sideloading @@ -218,6 +247,14 @@ Key points: - Nested netns: the supervisor creates a private `NetworkNamespace` with a veth pair. Sandbox processes enter this netns via `setns(fd, CLONE_NEWNET)` in the `pre_exec` hook, forcing ordinary traffic through the CONNECT proxy. +- Policy DNS and transparent TCP: the driver advertises the complete + `policy-dns-transparent-tcp` substrate. For explicit `protocol: tcp` + endpoints, the supervisor installs namespace-local DNS listeners, synthetic + routes, and TCP redirect rules before starting the workload. The container + disables Podman's implicit DNS search suffix so policy DNS evaluates the + exact endpoint name requested by the workload, and asks libc to use the + policy DNS TCP listener to avoid rootless Podman's nested UDP NAT return + path. - Port publishing: the container spec still requests `host_port: 0` for the configured SSH port. The gateway SSH tunnel uses the supervisor relay rather than connecting directly to the published port. @@ -273,7 +310,7 @@ via sandbox templates: - `OPENSHELL_ENDPOINT` - `OPENSHELL_SSH_SOCKET_PATH` - `OPENSHELL_CONTAINER_IMAGE` -- `OPENSHELL_SANDBOX_COMMAND` +- `OPENSHELL_MAIN_PROCESS_SPEC` ## Sandbox Lifecycle @@ -341,7 +378,7 @@ Podman resources after out-of-band container removal or label drift. | Environment Variable | CLI Flag | Default | Description | |---|---|---|---| -| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | Probes known local Podman API sockets and uses the first responsive socket. Fails to start if none respond. | Podman API Unix socket path. | +| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | Probes known local Podman API sockets and uses the first responsive socket, then falls back to asking the `podman` CLI for the host-side socket. Fails to start if neither finds one. | Podman API Unix socket path. | | `OPENSHELL_SANDBOX_IMAGE` | `--sandbox-image` | From gateway config | Default OCI image for sandboxes. | | `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY` | `--sandbox-image-pull-policy` | `missing` | Pull policy: `always`, `missing`, `never`, or `newer`. | | `OPENSHELL_GRPC_ENDPOINT` | `--grpc-endpoint` | Auto-detected via `host.containers.internal` | Gateway gRPC endpoint for sandbox callbacks. | @@ -355,15 +392,17 @@ Podman resources after out-of-band container removal or label drift. | `OPENSHELL_PODMAN_TLS_CA` | `--podman-tls-ca` | unset | Host path to the CA certificate mounted for sandbox mTLS. | | `OPENSHELL_PODMAN_TLS_CERT` | `--podman-tls-cert` | unset | Host path to the client certificate mounted for sandbox mTLS. | | `OPENSHELL_PODMAN_TLS_KEY` | `--podman-tls-key` | unset | Host path to the client private key mounted for sandbox mTLS. | -| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://host:port` URLs are supported (scheme and port required). Plain-HTTP requests always dial directly. | +| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Credential-free `http://host:port` and `https://host:port` URLs are supported (scheme and port required). For an `https://` proxy the supervisor TLS-wraps the proxy connection, verifying the proxy certificate against the built-in and system roots plus `--sandbox-proxy-ca-bundle`. Plain-HTTP requests always dial directly. | | `OPENSHELL_SANDBOX_NO_PROXY` | `--sandbox-no-proxy` | unset | Comma-separated `NO_PROXY` list (hostnames, domain suffixes, IPs, CIDRs, each with an optional `:port` qualifier) dialed directly instead of through the corporate proxy. IP/CIDR entries also match hostnames through their validated DNS resolution. | | `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. | -| `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. | +| `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set with an `http://` proxy; not required for `https://` proxies (the credential travels inside the verified TLS session) but tolerated if set. Rejected when no auth file is configured. | | `OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME` | `--sandbox-proxy-connect-by-hostname` | unset | Send the destination hostname in CONNECT requests instead of a validated IP. Last resort for proxies whose ACLs filter on hostnames: the proxy then resolves the name itself, so sandbox SSRF/`allowed_ips` validation no longer binds the connection. | +| `OPENSHELL_PODMAN_USERNS` | `--userns` | unset | User namespace mode for sandbox containers (e.g. `auto`). When unset, containers use the default user namespace. | +| `OPENSHELL_SANDBOX_PROXY_CA_BUNDLE` | `--sandbox-proxy-ca-bundle` | unset | Path (on the gateway host) to a PEM CA bundle trusted for the corporate proxy. Bind-mounted read-only into the sandbox (a CA certificate is not secret). Trusted for the `https://` proxy TLS handshake and, because TLS-intercepting proxies re-sign tunneled certificates, folded into the sandbox trust bundle and upstream verification. Requires a proxy URL; the file must exist and hold at least one certificate. | Through the gateway, the same settings are the `https_proxy`, `no_proxy`, -`proxy_auth_file`, `proxy_auth_allow_insecure`, and -`proxy_connect_by_hostname` keys under `[openshell.drivers.podman]`; see +`proxy_auth_file`, `proxy_auth_allow_insecure`, `proxy_connect_by_hostname`, +and `proxy_ca_bundle` keys under `[openshell.drivers.podman]`; see `docs/reference/gateway-config.mdx`. This is an operator-owned egress boundary: the driver passes the settings on @@ -418,11 +457,11 @@ matter compared to cluster or rootful runtimes: ## Implementation References -- Gateway integration: `crates/openshell-server/src/compute/mod.rs` - (`new_podman` and `PodmanComputeDriver` wiring). -- Server configuration: `crates/openshell-server/src/lib.rs` - (`ComputeDriverKind::Podman` builds `PodmanComputeConfig` including - `sandbox_ssh_socket_path` from gateway `Config`). +- Gateway integration: `crates/openshell-gateway/src/lib.rs` registers the + driver factory and constructs `PodmanComputeConfig` from the generic server + build context. +- Server configuration: `crates/openshell-server/src/lib.rs` exposes the + backend-agnostic registry and factory context. - Gateway relay path: `openshell-core` `Config::sandbox_ssh_socket_path` in `crates/openshell-core/src/config.rs`. - SSRF mitigation: `crates/openshell-core/src/net.rs`, diff --git a/crates/openshell-driver-podman/src/client.rs b/crates/openshell-driver-podman/src/client.rs index 9fe39cf7e2..508b604ce7 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -513,6 +513,32 @@ impl PodmanClient { } } + /// Download a file from a container as a tar archive. + /// + /// Calls `GET /libpod/containers/{name}/archive?path={path}` and returns + /// the raw tar bytes. The container does not need to be running. + pub async fn copy_from_container( + &self, + name: &str, + path: &str, + ) -> Result { + validate_name(name)?; + let encoded_path = url_encode(path); + let (status, bytes) = self + .request( + hyper::Method::GET, + &format!("/libpod/containers/{name}/archive?path={encoded_path}"), + None, + API_TIMEOUT, + ) + .await?; + if status.is_success() { + Ok(bytes) + } else { + Err(error_from_response(status.as_u16(), &bytes)) + } + } + /// Inspect a container by name or ID. pub async fn inspect_container(&self, name: &str) -> Result { validate_name(name)?; diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 2d226397b4..42571f00f1 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -126,6 +126,9 @@ pub struct PodmanComputeConfig { /// `template.driver_config`. #[serde(default)] pub enable_bind_mounts: bool, + /// Host path to a SPIFFE Workload API Unix socket exposed to sandbox + /// supervisors for provider token exchange client assertions. + pub provider_spiffe_workload_api_socket: Option, /// Health check interval in seconds for sandbox containers. /// /// Podman runs the health check command at this interval to determine @@ -135,13 +138,16 @@ pub struct PodmanComputeConfig { /// Defaults to [`DEFAULT_HEALTH_CHECK_INTERVAL_SECS`] (10 seconds). pub health_check_interval_secs: u64, /// Corporate forward proxy URL passed to the in-container supervisor - /// (e.g. `http://proxy.corp.com:8080`). + /// (e.g. `http://proxy.corp.com:8080` or `https://proxy.corp.com:3130`). /// /// The supervisor chains policy-approved TLS tunnels through this proxy /// with HTTP CONNECT instead of dialing upstream destinations directly. - /// Only `http://` proxy URLs in explicit `http://host:port` form (scheme - /// and port required) are supported. This is an operator-owned egress - /// boundary delivered on the supervisor's command line, so + /// `http://` and `https://` proxy URLs in explicit `scheme://host:port` + /// form (scheme and port required) are supported; for an `https://` proxy + /// the supervisor wraps the proxy connection in TLS, verifying the proxy + /// certificate against the built-in and system roots plus the optional + /// [`proxy_ca_bundle`](Self::proxy_ca_bundle). This is an operator-owned + /// egress boundary delivered on the supervisor's command line, so /// sandbox/template environment cannot override it, and the conventional /// `HTTPS_PROXY` variables are not used. pub https_proxy: Option, @@ -185,10 +191,70 @@ pub struct PodmanComputeConfig { /// pointing the gateway host at the corporate resolver so validated-IP /// CONNECT works in split-horizon networks. pub proxy_connect_by_hostname: Option, + /// Path (on the gateway host) to a PEM CA bundle trusted for the corporate + /// proxy. + /// + /// A CA certificate is not secret, so the gateway bind-mounts this file + /// read-only into the sandbox (at + /// [`PROXY_CA_MOUNT_PATH`](openshell_core::driver_utils::PROXY_CA_MOUNT_PATH)) + /// and passes its path to the supervisor via `--upstream-proxy-ca-bundle`. + /// The supervisor trusts it for the TLS handshake with an `https://` + /// proxy and, because a TLS-intercepting proxy re-signs tunneled server + /// certificates with the same CA, folds it into the sandbox trust bundle + /// and upstream verification. Only meaningful with `https_proxy` set; the + /// bundle must exist and contain at least one certificate. + pub proxy_ca_bundle: Option, + /// User namespace mode for sandbox containers (e.g. `auto`, `private`). + /// When unset, containers use the default user namespace. + pub userns: Option, + /// Explicit UID mappings for `userns = "private"`. + /// Each entry is `"container_id:host_id:size"`. + #[serde(default)] + pub uidmap: Vec, + /// Explicit GID mappings for `userns = "private"`. + /// Each entry is `"container_id:host_id:size"`. + #[serde(default)] + pub gidmap: Vec, } pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10; +/// Parse a single `"container_id:host_id:size"` mapping entry. +/// +/// Returns `(container_id, host_id, size)` on success. +pub fn parse_id_map_entry( + field: &str, + entry: &str, +) -> Result<(u32, u32, u32), crate::client::PodmanApiError> { + let parts: Vec<&str> = entry.split(':').collect(); + if parts.len() != 3 { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "{field} entry '{entry}' must be 'container_id:host_id:size'", + ))); + } + let container_id: u32 = parts[0].parse().map_err(|_| { + crate::client::PodmanApiError::InvalidInput(format!( + "{field} entry '{entry}': container_id must be a non-negative integer", + )) + })?; + let host_id: u32 = parts[1].parse().map_err(|_| { + crate::client::PodmanApiError::InvalidInput(format!( + "{field} entry '{entry}': host_id must be a non-negative integer", + )) + })?; + let size: u32 = parts[2].parse().map_err(|_| { + crate::client::PodmanApiError::InvalidInput(format!( + "{field} entry '{entry}': size must be a non-negative integer", + )) + })?; + if size == 0 { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "{field} entry '{entry}': size must be greater than 0", + ))); + } + Ok((container_id, host_id, size)) +} + impl PodmanComputeConfig { /// Returns `true` when all three TLS paths are configured. #[must_use] @@ -245,16 +311,16 @@ impl PodmanComputeConfig { /// Shares validation semantics with the in-container supervisor through /// [`openshell_core::driver_utils::parse_upstream_proxy_url`], so a value /// accepted here can never be rejected by the supervisor at sandbox - /// startup (or vice versa). The supervisor only supports `http://` - /// forward proxies, so other schemes are rejected at config time instead - /// of failing inside every sandbox. Credentials must be supplied through - /// `proxy_auth_file`; an inline `user:pass@` in the URL is rejected - /// because it would otherwise be stored in `gateway.toml` and exposed in - /// container metadata. + /// startup (or vice versa). The supervisor supports `http://` and + /// `https://` forward proxies, so other schemes (SOCKS, etc.) are rejected + /// at config time instead of failing inside every sandbox. Credentials + /// must be supplied through `proxy_auth_file`; an inline `user:pass@` in + /// the URL is rejected because it would otherwise be stored in + /// `gateway.toml` and exposed in container metadata. pub fn validate_proxy_config(&self) -> Result<(), crate::client::PodmanApiError> { use openshell_core::driver_utils::{UpstreamProxyUrlError, parse_upstream_proxy_url}; - if let Some(url) = &self.https_proxy { - parse_upstream_proxy_url(url).map_err(|err| { + let proxy_secure = if let Some(url) = &self.https_proxy { + let addr = parse_upstream_proxy_url(url).map_err(|err| { crate::client::PodmanApiError::InvalidInput(match err { UpstreamProxyUrlError::Empty => { "https_proxy must not be empty when set".to_string() @@ -267,7 +333,10 @@ impl PodmanComputeConfig { err => format!("https_proxy {err}"), }) })?; - } + addr.secure + } else { + false + }; // The supervisor treats a present-but-empty driver-supplied argument // as a fatal misconfiguration, so never accept (and later pass) one. @@ -301,8 +370,10 @@ impl PodmanComputeConfig { // Basic auth over the plain-TCP proxy connection is readable by // anyone on the network path; sending it requires an explicit // operator acknowledgement rather than being an implicit side - // effect of configuring credentials. - if self.proxy_auth_allow_insecure != Some(true) { + // effect of configuring credentials. For an https:// proxy the + // credential is inside the verified TLS session, so the + // acknowledgement is unnecessary (but tolerated). + if self.proxy_auth_allow_insecure != Some(true) && !proxy_secure { return Err(crate::client::PodmanApiError::InvalidInput( "proxy_auth_file sends the credential as cleartext Basic auth over the \ plain-TCP connection to the http:// proxy; set proxy_auth_allow_insecure \ @@ -325,6 +396,107 @@ impl PodmanComputeConfig { "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), )); } + + // A CA bundle only makes sense relative to a proxy boundary (an + // https:// proxy handshake, or a TLS-intercepting proxy's re-sign CA). + // Mirror the proxy_auth_file pairing so a stray setting cannot hide a + // fail-open state. The file's readability and certificate content are + // checked at sandbox-create time (see the driver) and fail closed in + // the supervisor. + if let Some(path) = self.proxy_ca_bundle.as_deref() { + if path.trim().is_empty() { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_ca_bundle must not be empty when set".to_string(), + )); + } + if self.https_proxy.is_none() { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_ca_bundle is set but no https_proxy is configured".to_string(), + )); + } + } + Ok(()) + } + + /// Validate and canonicalize the optional `userns` mode. + /// + /// Supported modes: `auto` (with optional params, e.g. `auto:size=65536`), + /// `host`, `keep-id` (with optional params), `no-map` (alias `nomap`), + /// and `private` (requires explicit `uidmap`/`gidmap`). + /// Modes that don't accept parameters (`host`, `no-map`, `private`) are + /// rejected when a colon-separated suffix is present. + /// + /// On success, `self.userns` is rewritten with the canonical lowercase + /// mode string so downstream code can rely on exact matches. + pub fn canonicalize_userns(&mut self) -> Result<(), crate::client::PodmanApiError> { + let Some(mode) = self.userns.as_deref() else { + return Ok(()); + }; + let (base, has_params) = mode + .split_once(':') + .map_or((mode, false), |(b, _)| (b, true)); + let canonical = match base.to_ascii_lowercase().as_str() { + "auto" => "auto", + "host" => "host", + "keep-id" => "keep-id", + "nomap" | "no-map" => "no-map", + "private" => "private", + _ => { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "unsupported userns mode '{mode}'; \ + supported modes: auto, host, keep-id, no-map, private", + ))); + } + }; + if has_params { + match canonical { + "auto" | "keep-id" => {} + _ => { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "userns mode '{canonical}' does not accept parameters", + ))); + } + } + } + self.userns = Some(if has_params { + let params = mode.split_once(':').unwrap().1; + format!("{canonical}:{params}") + } else { + canonical.to_string() + }); + Ok(()) + } + + /// Validate `uidmap`/`gidmap` consistency with the userns mode. + /// + /// `private` requires at least one entry in both `uidmap` and `gidmap`; + /// other modes (or no userns) reject non-empty mappings. Each entry must + /// be `"container_id:host_id:size"` with `size > 0`. + pub fn validate_userns_mappings(&self) -> Result<(), crate::client::PodmanApiError> { + let is_private = self + .userns + .as_deref() + .is_some_and(|m| m.eq_ignore_ascii_case("private")); + + if is_private { + if self.uidmap.is_empty() || self.gidmap.is_empty() { + return Err(crate::client::PodmanApiError::InvalidInput( + "userns mode 'private' requires at least one entry in both \ + uidmap and gidmap" + .to_string(), + )); + } + } else if !self.uidmap.is_empty() || !self.gidmap.is_empty() { + return Err(crate::client::PodmanApiError::InvalidInput( + "uidmap/gidmap are only valid with userns = \"private\"".to_string(), + )); + } + + for (field, entries) in [("uidmap", &self.uidmap), ("gidmap", &self.gidmap)] { + for entry in entries { + parse_id_map_entry(field, entry)?; + } + } Ok(()) } @@ -364,7 +536,7 @@ impl Default for PodmanComputeConfig { image_pull_policy: ImagePullPolicy::default(), grpc_endpoint: String::new(), gateway_port: openshell_core::config::DEFAULT_SERVER_PORT, - sandbox_ssh_socket_path: "/run/openshell/ssh.sock".to_string(), + sandbox_ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), network_name: DEFAULT_NETWORK_NAME.to_string(), host_gateway_ip: Self::default_host_gateway_ip(), stop_timeout_secs: DEFAULT_PODMAN_STOP_TIMEOUT_SECS, @@ -374,12 +546,17 @@ impl Default for PodmanComputeConfig { guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, enable_bind_mounts: false, + provider_spiffe_workload_api_socket: None, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, https_proxy: None, no_proxy: None, proxy_auth_file: None, proxy_auth_allow_insecure: None, proxy_connect_by_hostname: None, + proxy_ca_bundle: None, + userns: None, + uidmap: Vec::new(), + gidmap: Vec::new(), } } } @@ -402,6 +579,10 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("guest_tls_key", &self.guest_tls_key) .field("sandbox_pids_limit", &self.sandbox_pids_limit) .field("enable_bind_mounts", &self.enable_bind_mounts) + .field( + "provider_spiffe_workload_api_socket", + &self.provider_spiffe_workload_api_socket, + ) .field( "health_check_interval_secs", &self.health_check_interval_secs, @@ -412,6 +593,10 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("proxy_auth_file", &self.proxy_auth_file.is_some()) .field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure) .field("proxy_connect_by_hostname", &self.proxy_connect_by_hostname) + .field("proxy_ca_bundle", &self.proxy_ca_bundle) + .field("userns", &self.userns) + .field("uidmap", &self.uidmap) + .field("gidmap", &self.gidmap) .finish() } } @@ -482,7 +667,7 @@ mod tests { // ── Proxy config validation ─────────────────────────────────────── #[test] - fn validate_proxy_config_accepts_unset_and_http() { + fn validate_proxy_config_accepts_unset_http_and_https() { assert!( PodmanComputeConfig::default() .validate_proxy_config() @@ -494,11 +679,18 @@ mod tests { ..PodmanComputeConfig::default() }; assert!(cfg.validate_proxy_config().is_ok()); + // An https:// proxy URL is now supported (scheme and port required). + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); } #[test] - fn validate_proxy_config_rejects_non_http_schemes() { - for url in ["https://proxy:443", "socks5://proxy:1080"] { + fn validate_proxy_config_rejects_socks_schemes() { + // http:// and https:// are supported; only other schemes are rejected. + for url in ["socks5://proxy:1080", "ftp://proxy:21"] { let cfg = PodmanComputeConfig { https_proxy: Some(url.to_string()), ..PodmanComputeConfig::default() @@ -511,6 +703,46 @@ mod tests { } } + #[test] + fn validate_proxy_config_accepts_ca_bundle_with_proxy() { + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + proxy_ca_bundle: Some("/etc/openshell/proxy-ca.pem".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + // A CA bundle is also valid with an http:// proxy: a TLS-intercepting + // proxy reached over plain HTTP still re-signs upstream certificates. + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + proxy_ca_bundle: Some("/etc/openshell/proxy-ca.pem".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + } + + #[test] + fn validate_proxy_config_rejects_ca_bundle_without_proxy() { + let cfg = PodmanComputeConfig { + proxy_ca_bundle: Some("/etc/openshell/proxy-ca.pem".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("proxy_ca_bundle"), "{err}"); + assert!(err.to_string().contains("no https_proxy"), "{err}"); + } + + #[test] + fn validate_proxy_config_rejects_empty_ca_bundle() { + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + proxy_ca_bundle: Some(" ".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("proxy_ca_bundle"), "{err}"); + } + #[test] fn validate_proxy_config_rejects_url_components() { for url in [ @@ -635,6 +867,16 @@ mod tests { } } + #[test] + fn validate_proxy_config_accepts_auth_file_without_acknowledgement_for_https_proxy() { + let cfg = PodmanComputeConfig { + https_proxy: Some("https://proxy.corp.com:3130".to_string()), + proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + } + #[test] fn validate_proxy_config_accepts_connect_by_hostname_with_proxy() { for by_hostname in [Some(true), Some(false)] { @@ -794,4 +1036,174 @@ mod tests { assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}"); assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); } + + #[test] + fn canonicalize_userns_accepts_supported_modes() { + for mode in [ + "auto", + "host", + "keep-id", + "no-map", + "private", + "auto:size=65536", + "keep-id:uid=1000,gid=1000", + ] { + let mut cfg = PodmanComputeConfig { + userns: Some(mode.to_string()), + ..PodmanComputeConfig::default() + }; + cfg.canonicalize_userns() + .unwrap_or_else(|_| panic!("mode '{mode}' should be accepted")); + } + } + + #[test] + fn canonicalize_userns_normalizes_case_and_aliases() { + let cases = [ + ("Auto", "auto"), + ("HOST", "host"), + ("KEEP-ID:uid=1000", "keep-id:uid=1000"), + ("nomap", "no-map"), + ("no-map", "no-map"), + ("Private", "private"), + ("auto:SIZE=65536", "auto:SIZE=65536"), + ]; + for (input, expected) in cases { + let mut cfg = PodmanComputeConfig { + userns: Some(input.to_string()), + ..PodmanComputeConfig::default() + }; + cfg.canonicalize_userns() + .unwrap_or_else(|_| panic!("mode '{input}' should be accepted")); + assert_eq!( + cfg.userns.as_deref(), + Some(expected), + "input '{input}' should canonicalize to '{expected}'" + ); + } + } + + #[test] + fn canonicalize_userns_rejects_unsupported_modes() { + for mode in ["container:foo", "ns:/proc/1/ns/user", "4000:5000"] { + let mut cfg = PodmanComputeConfig { + userns: Some(mode.to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg + .canonicalize_userns() + .expect_err(&format!("mode '{mode}' should be rejected")); + let msg = err.to_string(); + assert!(msg.contains("unsupported userns mode"), "{msg}"); + } + } + + #[test] + fn canonicalize_userns_rejects_params_on_non_parameterizable_modes() { + for mode in ["host:foo", "no-map:x=1", "private:x=1"] { + let mut cfg = PodmanComputeConfig { + userns: Some(mode.to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg + .canonicalize_userns() + .expect_err(&format!("mode '{mode}' should be rejected")); + let msg = err.to_string(); + assert!(msg.contains("does not accept parameters"), "{msg}"); + } + } + + #[test] + fn canonicalize_userns_accepts_none() { + let mut cfg = PodmanComputeConfig::default(); + cfg.canonicalize_userns().expect("None should be accepted"); + } + + // ── Userns mapping validation ──────────────────────────────────── + + #[test] + fn validate_userns_mappings_accepts_private_with_maps() { + let cfg = PodmanComputeConfig { + userns: Some("private".to_string()), + uidmap: vec!["0:1000:1".to_string(), "1:100000:65536".to_string()], + gidmap: vec!["0:1000:1".to_string(), "1:100000:65536".to_string()], + ..PodmanComputeConfig::default() + }; + cfg.validate_userns_mappings() + .expect("private with mappings should be accepted"); + } + + #[test] + fn validate_userns_mappings_rejects_private_without_uidmap() { + let cfg = PodmanComputeConfig { + userns: Some("private".to_string()), + gidmap: vec!["0:1000:1".to_string()], + ..PodmanComputeConfig::default() + }; + let err = cfg + .validate_userns_mappings() + .expect_err("private without uidmap should be rejected"); + assert!(err.to_string().contains("uidmap"), "{err}"); + } + + #[test] + fn validate_userns_mappings_rejects_private_without_gidmap() { + let cfg = PodmanComputeConfig { + userns: Some("private".to_string()), + uidmap: vec!["0:1000:1".to_string()], + ..PodmanComputeConfig::default() + }; + let err = cfg + .validate_userns_mappings() + .expect_err("private without gidmap should be rejected"); + assert!(err.to_string().contains("gidmap"), "{err}"); + } + + #[test] + fn validate_userns_mappings_rejects_maps_without_private() { + for mode in [Some("auto"), Some("host"), Some("keep-id"), None] { + let cfg = PodmanComputeConfig { + userns: mode.map(ToString::to_string), + uidmap: vec!["0:1000:1".to_string()], + gidmap: vec!["0:1000:1".to_string()], + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_userns_mappings().expect_err(&format!( + "mappings without private should be rejected (mode={mode:?})" + )); + assert!( + err.to_string().contains("only valid with"), + "{mode:?}: {err}" + ); + } + } + + #[test] + fn validate_userns_mappings_rejects_malformed_entries() { + let cases = [ + ("0:1000", "too few fields"), + ("0:1000:1:extra", "too many fields"), + ("abc:1000:1", "non-numeric container_id"), + ("0:abc:1", "non-numeric host_id"), + ("0:1000:abc", "non-numeric size"), + ("0:1000:0", "zero size"), + ]; + for (entry, desc) in cases { + let cfg = PodmanComputeConfig { + userns: Some("private".to_string()), + uidmap: vec![entry.to_string()], + gidmap: vec!["0:1000:1".to_string()], + ..PodmanComputeConfig::default() + }; + cfg.validate_userns_mappings() + .expect_err(&format!("{desc}: '{entry}' should be rejected")); + } + } + + #[test] + fn validate_userns_mappings_accepts_no_userns_no_maps() { + let cfg = PodmanComputeConfig::default(); + cfg.validate_userns_mappings() + .expect("no userns and no maps should be accepted"); + } } diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 90ef0fec21..a81ee13e1d 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -14,7 +14,6 @@ use openshell_core::{driver_mounts, proto_struct}; use serde::Serialize; use serde_json::Value; use std::collections::{BTreeMap, HashSet}; -#[cfg(target_os = "linux")] use std::path::Path; /// Returns `true` when `SELinux` is enabled (enforcing or permissive). @@ -54,6 +53,9 @@ const VOLUME_PREFIX: &str = "openshell-sandbox-"; /// Secret name prefix for per-sandbox gateway JWTs. const TOKEN_SECRET_PREFIX: &str = "openshell-token-"; const PROXY_AUTH_SECRET_PREFIX: &str = "openshell-proxy-auth-"; +const TLS_CA_SECRET_PREFIX: &str = "openshell-tls-ca-"; +const TLS_CERT_SECRET_PREFIX: &str = "openshell-tls-cert-"; +const TLS_KEY_SECRET_PREFIX: &str = "openshell-tls-key-"; /// Container-side mount paths for client TLS materials and the sandbox token. const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH; @@ -62,6 +64,9 @@ const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PAT const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = openshell_core::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH; +const PROXY_CA_MOUNT_PATH: &str = openshell_core::driver_utils::PROXY_CA_MOUNT_PATH; +const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = + openshell_core::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; /// Directory inside sandbox containers where the supervisor binary is mounted. const SUPERVISOR_MOUNT_DIR: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_DIR; @@ -171,6 +176,16 @@ pub fn proxy_auth_secret_name(sandbox_id: &str) -> String { format!("{PROXY_AUTH_SECRET_PREFIX}{sandbox_id}") } +/// Build per-sandbox Podman secret names for TLS CA, cert, and key. +#[must_use] +pub fn tls_secret_names(sandbox_id: &str) -> [String; 3] { + [ + format!("{TLS_CA_SECRET_PREFIX}{sandbox_id}"), + format!("{TLS_CERT_SECRET_PREFIX}{sandbox_id}"), + format!("{TLS_KEY_SECRET_PREFIX}{sandbox_id}"), + ] +} + /// Truncate a container ID to 12 characters (standard short form). #[must_use] pub fn short_id(id: &str) -> String { @@ -216,6 +231,10 @@ struct ContainerSpec { /// via Podman's `host-gateway` magic so sandbox containers can reach /// the gateway server running on the host in rootless mode. hostadd: Vec, + /// Search domains written to `/etc/resolv.conf` by Podman. + dns_search: Vec, + /// Resolver options written to `/etc/resolv.conf` by Podman. + dns_option: Vec, netns: NetNS, // Matches libpod's network spec format, which is `{name: {opts}}` where // empty opts is a unit struct rather than `()`. Keep as a map so JSON @@ -229,6 +248,13 @@ struct ContainerSpec { /// Port mappings from host to container. Using `host_port=0` requests an /// ephemeral port, readable back from the inspect response. portmappings: Vec, + /// User namespace mode override (e.g. `auto`). + #[serde(skip_serializing_if = "Option::is_none")] + userns: Option, + /// UID/GID mapping options. Required for `userns = "auto"` — the Podman + /// API needs `AutoUserNs: true` alongside the namespace mode. + #[serde(skip_serializing_if = "Option::is_none")] + idmappings: Option, } /// A port mapping entry for the libpod `SpecGenerator`. @@ -328,6 +354,49 @@ struct NetNS { nsmode: String, } +#[derive(Serialize)] +struct UserNS { + nsmode: String, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, +} + +#[derive(Serialize)] +struct IDMap { + container_id: u32, + host_id: u32, + size: u32, +} + +#[derive(Serialize)] +struct IDMappings { + #[serde(rename = "HostUIDMapping")] + host_uid_mapping: bool, + #[serde(rename = "HostGIDMapping")] + host_gid_mapping: bool, + #[serde(rename = "AutoUserNs")] + auto_user_ns: bool, + #[serde(rename = "UIDMap", skip_serializing_if = "Vec::is_empty")] + uid_map: Vec, + #[serde(rename = "GIDMap", skip_serializing_if = "Vec::is_empty")] + gid_map: Vec, +} + +fn parse_id_maps(entries: &[String]) -> Result, ComputeDriverError> { + entries + .iter() + .map(|entry| { + let (cid, hid, size) = crate::config::parse_id_map_entry("idmap", entry) + .map_err(|err| ComputeDriverError::Precondition(err.to_string()))?; + Ok(IDMap { + container_id: cid, + host_id: hid, + size, + }) + }) + .collect() +} + #[derive(Serialize)] struct NetworkAttachment {} @@ -390,6 +459,12 @@ fn upstream_proxy_cli_args(config: &PodmanComputeConfig) -> Vec { if config.proxy_connect_by_hostname == Some(true) { args.push("--upstream-proxy-connect-by-hostname".to_string()); } + // A CA certificate is not secret, so the host PEM is bind-mounted + // read-only and the container-side mount path is passed on argv. + if config.proxy_ca_bundle.is_some() { + args.push("--upstream-proxy-ca-bundle".to_string()); + args.push(PROXY_CA_MOUNT_PATH.to_string()); + } args } @@ -454,14 +529,22 @@ fn build_env( config.sandbox_ssh_socket_path.clone(), ); env.insert("OPENSHELL_CONTAINER_IMAGE".into(), image.to_string()); + let main_process = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(spec) + .expect("main process config serialization cannot fail"); env.insert( - openshell_core::sandbox_env::SANDBOX_COMMAND.into(), - "sleep infinity".into(), + openshell_core::sandbox_env::MAIN_PROCESS_SPEC.into(), + main_process, ); env.insert( openshell_core::sandbox_env::TELEMETRY_ENABLED.into(), openshell_core::telemetry::enabled_env_value().into(), ); + // Runtime capabilities are driver-owned. Override image/user input with + // only the substrate that this driver configures for the supervisor. + env.insert( + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.into(), + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.into(), + ); // 3. TLS client cert paths (when mTLS is enabled). These point to // the container-side mount paths where the cert files are @@ -481,8 +564,20 @@ fn build_env( ); } + if let Some(socket_path) = provider_spiffe_workload_api_socket_env_value(config) { + env.insert( + openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET.into(), + socket_path, + ); + } + env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN); env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE); + // Prevent user-supplied environment from overriding the TLS server name + // the supervisor verifies — a sandbox user who can redirect the gateway + // hostname could otherwise present a certificate for a name they control + // and intercept the sandbox JWT. + env.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME); env.insert( openshell_core::sandbox_env::OCI_IMAGE_USER.into(), oci_user.to_string(), @@ -905,9 +1000,12 @@ pub fn build_container_spec_with_token_and_gpu_devices( image, image, "", + None, + None, ) } +#[allow(clippy::too_many_arguments)] pub fn build_container_spec_for_image( sandbox: &DriverSandbox, config: &PodmanComputeConfig, @@ -916,6 +1014,8 @@ pub fn build_container_spec_for_image( requested_image: &str, image_id: &str, oci_user: &str, + supervisor_bin_path: Option<&Path>, + tls_secret_names: Option<&[String; 3]>, ) -> Result { let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id); let vol = volume_name(&sandbox.id); @@ -957,12 +1057,21 @@ pub fn build_container_spec_for_image( }]; volumes.extend(user_mounts.volumes); - let mut image_volumes = vec![ImageVolume { - source: config.supervisor_image.clone(), - destination: SUPERVISOR_MOUNT_DIR.into(), - rw: false, - }]; + let mut image_volumes = if supervisor_bin_path.is_some() { + Vec::new() + } else { + vec![ImageVolume { + source: config.supervisor_image.clone(), + destination: SUPERVISOR_MOUNT_DIR.into(), + rw: false, + }] + }; image_volumes.extend(user_mounts.image_volumes); + let mut command = vec![ + "--workdir".to_string(), + driver_mounts::DEFAULT_WORKSPACE_ROOT.to_string(), + ]; + command.extend(upstream_proxy_cli_args(config)); let container_spec = ContainerSpec { name, @@ -984,10 +1093,11 @@ pub fn build_container_spec_for_image( // Without this, the container would run the entrypoint binary with // the supervisor path as an argument instead of executing it directly. entrypoint: vec![SUPERVISOR_BINARY_PATH.into()], - // Operator-owned corporate proxy flags. The workload command is not - // part of argv (the supervisor takes it from the reserved command - // env var), so these flags are the whole command list. - command: upstream_proxy_cli_args(config), + // Keep Podman's existing /sandbox workspace contract explicit while + // the supervisor supports driver-selected workdirs. Operator-owned + // corporate proxy flags follow it; the workload command comes from + // the reserved environment variable. + command, // Force the supervisor to run as root (UID 0). Sandbox images may // set a non-root USER directive (e.g. `USER sandbox`), but the // supervisor needs root to create network namespaces, set up the @@ -1004,8 +1114,6 @@ pub fn build_container_spec_for_image( "DAC_OVERRIDE".into(), // Not needed: the supervisor does not create setuid/setgid executables. "FSETID".into(), - // Not needed: the supervisor does not send signals to arbitrary processes. - "KILL".into(), // Not needed: the supervisor does not bind privileged ports (<1024). "NET_BIND_SERVICE".into(), // Not in Podman's default set but explicitly denied in case the image @@ -1036,6 +1144,9 @@ pub fn build_container_spec_for_image( // Child setup clears the capability bounding set before exec, which // requires CAP_SETPCAP in the supervisor until drop_privileges(). "SETPCAP".into(), + // Forwarding shutdown signals to the canonical workload process + // group after it drops to the sandbox UID requires CAP_KILL. + "KILL".into(), ], // SETUID, SETGID, SETPCAP, CHOWN, and FOWNER are intentionally kept from // Podman's default set and not dropped: @@ -1105,6 +1216,29 @@ pub fn build_container_spec_for_image( mode: 0o400, }); } + if let Some([ca, cert, key]) = tls_secret_names { + secrets.push(SecretMount { + source: ca.clone(), + target: TLS_CA_MOUNT_PATH.into(), + uid: 0, + gid: 0, + mode: 0o400, + }); + secrets.push(SecretMount { + source: cert.clone(), + target: TLS_CERT_MOUNT_PATH.into(), + uid: 0, + gid: 0, + mode: 0o400, + }); + secrets.push(SecretMount { + source: key.clone(), + target: TLS_KEY_MOUNT_PATH.into(), + uid: 0, + gid: 0, + mode: 0o400, + }); + } secrets }, stop_timeout: config.stop_timeout_secs, @@ -1112,6 +1246,11 @@ pub fn build_container_spec_for_image( // reach services on the host. `host.openshell.internal` is the driver- // neutral alias used by policies and e2e tests. hostadd: hostadd_entries(config), + // Preserve Podman's resolver defaults for both policy-DNS and ordinary + // sandboxes. Namespace-local capture supports UDP and TCP, so it must + // not depend on a libc-specific option or alter short-name searches. + dns_search: Vec::new(), + dns_option: Vec::new(), netns: NetNS { nsmode: "bridge".to_string(), }, @@ -1127,23 +1266,23 @@ pub fn build_container_spec_for_image( let mut m = vec![Mount { kind: "tmpfs".into(), source: "tmpfs".into(), - destination: "/run/netns".into(), + destination: openshell_core::container_paths::NETNS_MOUNT_ROOT.into(), options: vec!["rw".into(), "nosuid".into(), "nodev".into()], }]; - // Bind-mount client TLS materials into the container when mTLS - // is enabled. The supervisor reads these via OPENSHELL_TLS_CA, - // OPENSHELL_TLS_CERT, and OPENSHELL_TLS_KEY env vars (set in - // build_env above) to establish an mTLS connection back to the - // gateway. - if let (Some(ca), Some(cert), Some(key)) = ( - &config.guest_tls_ca, - &config.guest_tls_cert, - &config.guest_tls_key, - ) { + // Deliver client TLS materials into the container when mTLS is + // enabled. When userns remaps UIDs (auto, no-map), bind-mounted + // host files are unreadable because the container root maps to a + // different host UID. In that case TLS materials are delivered as + // Podman secrets (handled in the `secrets` block above); otherwise + // use bind mounts. + if tls_secret_names.is_none() + && let (Some(ca), Some(cert), Some(key)) = ( + &config.guest_tls_ca, + &config.guest_tls_cert, + &config.guest_tls_key, + ) + { let mut ro = vec!["ro".into(), "rbind".into()]; - // On SELinux-enabled systems (Fedora, RHEL), bind-mounted - // files need the shared relabel option so the container - // process can read them through the SELinux MAC policy. if is_selinux_enabled() { ro.push("z".into()); } @@ -1166,6 +1305,47 @@ pub fn build_container_spec_for_image( options: ro, }); } + // Bind-mount the corporate proxy CA bundle read-only when + // configured. A CA certificate is not secret, so unlike the proxy + // credential (a driver secret) a plain read-only bind mount is + // used. The supervisor reads it via the --upstream-proxy-ca-bundle + // argv path (see upstream_proxy_cli_args) to verify an https:// + // proxy and trust re-signed upstream certificates. + if let Some(ca_bundle) = &config.proxy_ca_bundle { + let mut ro = vec!["ro".into(), "rbind".into()]; + if is_selinux_enabled() { + ro.push("z".into()); + } + m.push(Mount { + kind: "bind".into(), + source: ca_bundle.clone(), + destination: PROXY_CA_MOUNT_PATH.into(), + options: ro, + }); + } + if let Some(bin_path) = supervisor_bin_path { + let mut opts = vec!["ro".into(), "rbind".into()]; + if is_selinux_enabled() { + opts.push("z".into()); + } + m.push(Mount { + kind: "bind".into(), + source: bin_path.display().to_string(), + destination: SUPERVISOR_BINARY_PATH.into(), + options: opts, + }); + } + if let Some(path) = provider_spiffe_workload_api_socket_mount_source(config) { + // No SELinux relabel - the SPIRE agent socket is shared host + // infrastructure and must keep its existing SELinux context. + let ro = vec!["ro".into(), "rbind".into()]; + m.push(Mount { + kind: "bind".into(), + source: path.display().to_string(), + destination: PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR.into(), + options: ro, + }); + } m.extend(user_mounts.mounts); m }, @@ -1177,11 +1357,68 @@ pub fn build_container_spec_for_image( container_port: openshell_core::config::DEFAULT_SSH_PORT, protocol: "tcp".into(), }], + userns: config.userns.as_deref().map(|raw| { + let (base, params) = raw + .split_once(':') + .map_or((raw, None), |(b, p)| (b, Some(p))); + UserNS { + nsmode: base.to_string(), + value: params.map(ToString::to_string), + } + }), + idmappings: match config + .userns + .as_deref() + .map(|m| m.split(':').next().unwrap_or(m)) + { + Some("auto") => Some(IDMappings { + host_uid_mapping: false, + host_gid_mapping: false, + auto_user_ns: true, + uid_map: Vec::new(), + gid_map: Vec::new(), + }), + Some("private") => Some(IDMappings { + host_uid_mapping: false, + host_gid_mapping: false, + auto_user_ns: false, + uid_map: parse_id_maps(&config.uidmap)?, + gid_map: parse_id_maps(&config.gidmap)?, + }), + _ => None, + }, }; Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } +fn provider_spiffe_workload_api_socket_env_value(config: &PodmanComputeConfig) -> Option { + let host_path = config.provider_spiffe_workload_api_socket.as_ref()?; + let raw = host_path.to_str()?; + if raw.starts_with("tcp:") { + return Some(raw.to_string()); + } + let host_path = raw + .strip_prefix("unix:") + .map_or(host_path.as_path(), Path::new); + let file_name = host_path.file_name()?.to_str()?; + Some(format!( + "{PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR}/{file_name}" + )) +} + +fn provider_spiffe_workload_api_socket_mount_source(config: &PodmanComputeConfig) -> Option<&Path> { + let host_path = config.provider_spiffe_workload_api_socket.as_ref()?; + let raw = host_path.to_str()?; + if raw.starts_with("tcp:") { + return None; + } + let host_path = raw + .strip_prefix("unix:") + .map_or(host_path.as_path(), Path::new); + host_path.parent() +} + fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { let host_gateway_ip = config.host_gateway_ip.trim(); if host_gateway_ip.is_empty() { @@ -1379,6 +1616,8 @@ mod tests { "registry.example/app:latest", "sha256:immutable", "app:staff", + None, + None, ) .unwrap(); @@ -1389,6 +1628,8 @@ mod tests { ); assert_eq!(container["user"].as_str(), Some("0:0")); assert_eq!(container["image_pull_policy"].as_str(), Some("never")); + assert_eq!(container["dns_search"], serde_json::json!([])); + assert_eq!(container["dns_option"], serde_json::json!([])); assert_eq!( container["env"][openshell_core::sandbox_env::OCI_IMAGE_USER].as_str(), Some("app:staff") @@ -1401,6 +1642,28 @@ mod tests { container["env"][openshell_core::sandbox_env::SANDBOX_GID].as_str(), Some("") ); + assert_eq!( + container["command"], + serde_json::json!(["--workdir", "/sandbox"]) + ); + } + + #[test] + fn build_env_strips_gateway_tls_server_name() { + let mut sandbox = test_sandbox("test-id", "test-name"); + let spec = sandbox.spec.get_or_insert_default(); + spec.environment.insert( + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME.to_string(), + "evil.attacker.example.com".to_string(), + ); + + let container = build_container_spec(&sandbox, &test_config()); + + assert_eq!( + container["env"].get(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME), + None, + "GATEWAY_TLS_SERVER_NAME must be stripped from the supervisor environment" + ); } #[test] @@ -1623,6 +1886,7 @@ mod tests { "missing DAC_READ_SEARCH" ); assert!(added.contains(&"SETPCAP"), "missing SETPCAP"); + assert!(added.contains(&"KILL"), "missing KILL"); // SETUID and SETGID are NOT in cap_add — they remain available from the // default bounding set because we no longer use cap_drop:ALL. Verify they @@ -1638,6 +1902,10 @@ mod tests { .collect(); assert!(!dropped.contains(&"SETUID"), "SETUID must not be dropped"); assert!(!dropped.contains(&"SETGID"), "SETGID must not be dropped"); + assert!( + dropped.contains(&"NET_BIND_SERVICE"), + "NET_BIND_SERVICE must stay dropped; policy DNS binds an unprivileged port" + ); assert!( !dropped.contains(&"CHOWN"), "CHOWN must not be dropped (needed for prepare_filesystem chown)" @@ -1650,6 +1918,10 @@ mod tests { !dropped.contains(&"SETPCAP"), "SETPCAP must not be dropped (needed for child bounding-set clear)" ); + assert!( + !dropped.contains(&"KILL"), + "KILL must not be dropped (needed to signal the sandbox workload on shutdown)" + ); assert!( !dropped.contains(&"ALL"), "must not use cap_drop:ALL in rootless Podman" @@ -1798,6 +2070,26 @@ mod tests { ); } + #[test] + fn container_spec_keeps_network_capabilities_driver_controlled() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "legit-name"); + sandbox.spec = Some(DriverSandboxSpec { + environment: std::collections::HashMap::from([( + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), + "spoofed".to_string(), + )]), + template: Some(DriverSandboxTemplate::default()), + ..Default::default() + }); + let spec = build_container_spec(&sandbox, &test_config()); + assert_eq!( + spec["env"][openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES], + serde_json::json!(openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY) + ); + } + /// Extract the container spec's supervisor argv (`command`) as strings. fn spec_command(spec: &Value) -> Vec { spec["command"] @@ -1865,6 +2157,61 @@ mod tests { ); } + #[test] + fn container_spec_binds_proxy_ca_bundle_and_passes_argv() { + let sandbox = test_sandbox("ca-id", "ca-name"); + let mut config = test_config(); + config.https_proxy = Some("https://proxy.corp.com:3130".to_string()); + config.proxy_ca_bundle = Some("/host/proxy-ca.pem".to_string()); + + let spec = build_container_spec(&sandbox, &config); + let command = spec_command(&spec); + + // The container-side mount path travels on argv as a flag/value pair. + let idx = command + .iter() + .position(|a| a == "--upstream-proxy-ca-bundle") + .expect("CA bundle flag present"); + assert_eq!( + command.get(idx + 1).map(String::as_str), + Some("/etc/openshell/tls/proxy/ca-bundle.pem") + ); + + // The host PEM is bind-mounted read-only at that container path. + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + let ca_mount = mounts + .iter() + .find(|m| { + m["type"].as_str() == Some("bind") + && m["destination"].as_str() == Some("/etc/openshell/tls/proxy/ca-bundle.pem") + }) + .expect("CA bundle bind mount present"); + assert_eq!(ca_mount["source"].as_str(), Some("/host/proxy-ca.pem")); + assert!( + ca_mount["options"] + .as_array() + .expect("options array") + .iter() + .any(|o| o.as_str() == Some("ro")), + "CA bundle mount must be read-only" + ); + } + + #[test] + fn container_spec_omits_proxy_ca_bundle_when_unconfigured() { + let sandbox = test_sandbox("test-id", "test-name"); + let spec = build_container_spec(&sandbox, &test_config()); + let mounts = spec["mounts"].as_array().expect("mounts array"); + assert!( + !mounts.iter().any( + |m| m["destination"].as_str() == Some("/etc/openshell/tls/proxy/ca-bundle.pem") + ), + "no CA bundle mount without operator config" + ); + } + #[test] fn container_spec_sandbox_env_cannot_influence_proxy_argv() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; @@ -2506,7 +2853,7 @@ mod tests { "mounts": [{ "type": "volume", "source": "work-nfs", - "target": "/etc/openshell/tls/custom" + "target": "/etc/openshell/tls/client" }] }))), ..Default::default() @@ -2755,6 +3102,33 @@ mod tests { ); } + #[test] + fn container_spec_includes_provider_spiffe_socket_when_configured() { + let sandbox = test_sandbox("spiffe-id", "spiffe-name"); + let mut config = test_config(); + config.provider_spiffe_workload_api_socket = + Some(std::path::PathBuf::from("/host/spire-agent.sock")); + + let spec = build_container_spec(&sandbox, &config); + + let env_map = spec["env"].as_object().expect("env should be an object"); + assert_eq!( + env_map + .get(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) + .and_then(|v| v.as_str()), + Some("/spiffe-workload-api/spire-agent.sock"), + ); + + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + assert!(mounts.iter().any(|m| { + m["type"].as_str() == Some("bind") + && m["source"].as_str() == Some("/host") + && m["destination"].as_str() == Some(PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR) + })); + } + #[test] fn container_spec_omits_tls_without_config() { let sandbox = test_sandbox("notls-id", "notls-name"); @@ -2777,4 +3151,205 @@ mod tests { .count(); assert_eq!(bind_count, 0, "no bind mounts without TLS config"); } + + #[test] + fn container_spec_includes_userns_when_configured() { + let sandbox = test_sandbox("userns-id", "userns-name"); + let mut config = test_config(); + config.userns = Some("auto".to_string()); + let spec = build_container_spec(&sandbox, &config); + + let userns = &spec["userns"]; + assert_eq!(userns["nsmode"].as_str(), Some("auto")); + assert!(userns.get("value").is_none(), "bare auto should omit value"); + + let idmappings = &spec["idmappings"]; + assert_eq!( + idmappings["AutoUserNs"].as_bool(), + Some(true), + "idmappings.AutoUserNs must be true for userns=auto" + ); + } + + #[test] + fn container_spec_auto_with_params() { + let sandbox = test_sandbox("userns-auto-params-id", "userns-auto-params-name"); + let mut config = test_config(); + config.userns = Some("auto:size=65536".to_string()); + let spec = build_container_spec(&sandbox, &config); + + let userns = &spec["userns"]; + assert_eq!(userns["nsmode"].as_str(), Some("auto")); + assert_eq!(userns["value"].as_str(), Some("size=65536")); + + assert_eq!( + spec["idmappings"]["AutoUserNs"].as_bool(), + Some(true), + "idmappings.AutoUserNs must be true for auto:size=65536" + ); + } + + #[test] + fn container_spec_keep_id_with_params() { + let sandbox = test_sandbox("userns-keepid-id", "userns-keepid-name"); + let mut config = test_config(); + config.userns = Some("keep-id:uid=1000,gid=1000".to_string()); + let spec = build_container_spec(&sandbox, &config); + + let userns = &spec["userns"]; + assert_eq!(userns["nsmode"].as_str(), Some("keep-id")); + assert_eq!(userns["value"].as_str(), Some("uid=1000,gid=1000")); + + assert!( + spec.get("idmappings").is_none(), + "idmappings should not be set for keep-id" + ); + } + + #[test] + fn container_spec_nomap_mode() { + let sandbox = test_sandbox("userns-nomap-id", "userns-nomap-name"); + let mut config = test_config(); + config.userns = Some("no-map".to_string()); + let spec = build_container_spec(&sandbox, &config); + + let userns = &spec["userns"]; + assert_eq!(userns["nsmode"].as_str(), Some("no-map")); + assert!(userns.get("value").is_none(), "no-map should omit value"); + + assert!( + spec.get("idmappings").is_none(), + "idmappings should not be set for no-map" + ); + } + + #[test] + fn container_spec_private_with_mappings() { + let sandbox = test_sandbox("userns-private-id", "userns-private-name"); + let mut config = test_config(); + config.userns = Some("private".to_string()); + config.uidmap = vec!["0:1000:1".to_string(), "1:100000:65536".to_string()]; + config.gidmap = vec!["0:1000:1".to_string(), "1:100000:65536".to_string()]; + let spec = build_container_spec(&sandbox, &config); + + let userns = &spec["userns"]; + assert_eq!(userns["nsmode"].as_str(), Some("private")); + assert!(userns.get("value").is_none(), "private should omit value"); + + let idmappings = &spec["idmappings"]; + assert_eq!( + idmappings["AutoUserNs"].as_bool(), + Some(false), + "AutoUserNs must be false for private mode" + ); + + let uid_map = idmappings["UIDMap"] + .as_array() + .expect("UIDMap should be an array"); + assert_eq!(uid_map.len(), 2); + assert_eq!(uid_map[0]["container_id"].as_u64(), Some(0)); + assert_eq!(uid_map[0]["host_id"].as_u64(), Some(1000)); + assert_eq!(uid_map[0]["size"].as_u64(), Some(1)); + assert_eq!(uid_map[1]["container_id"].as_u64(), Some(1)); + assert_eq!(uid_map[1]["host_id"].as_u64(), Some(100_000)); + assert_eq!(uid_map[1]["size"].as_u64(), Some(65536)); + + let gid_map = idmappings["GIDMap"] + .as_array() + .expect("GIDMap should be an array"); + assert_eq!(gid_map.len(), 2); + assert_eq!(gid_map[0]["container_id"].as_u64(), Some(0)); + assert_eq!(gid_map[0]["host_id"].as_u64(), Some(1000)); + assert_eq!(gid_map[0]["size"].as_u64(), Some(1)); + } + + #[test] + fn container_spec_omits_userns_when_unset() { + let sandbox = test_sandbox("no-userns-id", "no-userns-name"); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + assert!( + spec.get("userns").is_none(), + "userns should not be set when unconfigured" + ); + assert!( + spec.get("idmappings").is_none(), + "idmappings should not be set when userns is unconfigured" + ); + } + + #[test] + fn container_spec_uses_bind_mount_for_supervisor_when_path_provided() { + let sandbox = test_sandbox("bind-sv-id", "bind-sv-name"); + let config = test_config(); + let image = resolve_image(&sandbox, &config); + let spec = build_container_spec_for_image( + &sandbox, + &config, + None, + None, + image, + image, + "", + Some(Path::new("/host/cache/openshell-sandbox")), + None, + ) + .unwrap(); + + let image_volumes = spec["image_volumes"] + .as_array() + .expect("image_volumes should be an array"); + assert!( + !image_volumes + .iter() + .any(|v| v["destination"].as_str() == Some(SUPERVISOR_MOUNT_DIR)), + "supervisor image volume should not be present when bind path is provided" + ); + + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + let sv_bind = mounts + .iter() + .find(|m| m["destination"].as_str() == Some(SUPERVISOR_BINARY_PATH)); + assert!( + sv_bind.is_some(), + "supervisor bind mount should be present at {SUPERVISOR_BINARY_PATH}" + ); + let sv_bind = sv_bind.unwrap(); + assert_eq!( + sv_bind["source"].as_str(), + Some("/host/cache/openshell-sandbox") + ); + assert_eq!(sv_bind["type"].as_str(), Some("bind")); + } + + #[test] + fn container_spec_uses_image_volume_when_no_bind_path() { + let sandbox = test_sandbox("imgvol-id", "imgvol-name"); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let image_volumes = spec["image_volumes"] + .as_array() + .expect("image_volumes should be an array"); + assert!( + image_volumes + .iter() + .any(|v| v["destination"].as_str() == Some(SUPERVISOR_MOUNT_DIR)), + "supervisor image volume should be present by default" + ); + + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + assert!( + !mounts.iter().any( + |m| m["destination"].as_str() == Some(SUPERVISOR_BINARY_PATH) + && m["type"].as_str() == Some("bind") + ), + "supervisor bind mount should not be present by default" + ); + } } diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 51c689fb29..16c5780780 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -3,15 +3,19 @@ //! Podman compute driver. -use crate::client::{PodmanApiError, PodmanClient, VolumeInspect}; +use crate::client::{ContainerListEntry, PodmanApiError, PodmanClient, VolumeInspect}; use crate::config::PodmanComputeConfig; use crate::container::{self, LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, PodmanSandboxDriverConfig}; use crate::watcher::{ - self, WatchStream, driver_sandbox_from_inspect, driver_sandbox_from_list_entry, + self, LifecycleEventFences, WatchStream, driver_sandbox_from_inspect, + driver_sandbox_from_list_entry, }; use openshell_core::ComputeDriverError; use openshell_core::config::CDI_GPU_DEVICE_ALL; -use openshell_core::driver_utils::supervisor_image_should_refresh; +use openshell_core::driver_utils::{ + SUPERVISOR_IMAGE_BINARY_PATH, extract_first_tar_entry, supervisor_image_should_refresh, + temp_extract_container_name, validate_linux_elf_binary, write_cache_binary_atomic, +}; use openshell_core::gpu::{ CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, effective_driver_gpu_count, validate_specific_gpu_device_request, @@ -29,14 +33,17 @@ use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use tracing::{debug, info, warn}; +use tracing::{Instrument as _, debug, info, warn}; use url::Url; +const STOP_COMPLETION_POLL_INTERVAL: Duration = Duration::from_millis(50); +const STOP_COMPLETION_TIMEOUT_HEADROOM: Duration = Duration::from_secs(5); + impl From for ComputeDriverError { fn from(value: PodmanApiError) -> Self { match value { PodmanApiError::Conflict(_) => Self::AlreadyExists, - PodmanApiError::NotFound(msg) => Self::Message(format!("not found: {msg}")), + PodmanApiError::NotFound(_) => Self::NotFound, other => Self::Message(other.to_string()), } } @@ -56,6 +63,7 @@ pub struct PodmanComputeDriver { rootless_network_cmd: String, gpu_selector: Arc, gpu_inventory_refresh: Arc (CdiGpuInventory, bool) + Send + Sync>, + lifecycle_event_fences: LifecycleEventFences, } impl std::fmt::Debug for PodmanComputeDriver { @@ -169,6 +177,41 @@ async fn create_sandbox_proxy_auth_secret( Ok(Some(secret_name)) } +/// Fail-closed readability check for the corporate proxy CA bundle. +/// +/// When `proxy_ca_bundle` is configured the host PEM is bind-mounted read-only +/// into the sandbox; verifying up front that it exists and is a non-empty +/// regular file turns a missing path into a clear `proxy_ca_bundle` error at +/// sandbox-create time instead of an opaque bind-mount failure. The supervisor +/// independently validates the certificate content (fail-closed) at startup. +async fn validate_sandbox_proxy_ca_bundle( + config: &PodmanComputeConfig, +) -> Result<(), ComputeDriverError> { + let Some(path) = config.proxy_ca_bundle.as_deref() else { + return Ok(()); + }; + let path_owned = path.to_string(); + let metadata = tokio::task::spawn_blocking(move || std::fs::metadata(&path_owned)) + .await + .map_err(|e| ComputeDriverError::Message(format!("proxy_ca_bundle stat task failed: {e}")))? + .map_err(|err| { + ComputeDriverError::InvalidArgument(format!( + "proxy_ca_bundle '{path}' could not be read: {err}" + )) + })?; + if !metadata.is_file() { + return Err(ComputeDriverError::InvalidArgument(format!( + "proxy_ca_bundle '{path}' is not a regular file" + ))); + } + if metadata.len() == 0 { + return Err(ComputeDriverError::InvalidArgument(format!( + "proxy_ca_bundle '{path}' is empty" + ))); + } + Ok(()) +} + async fn cleanup_sandbox_proxy_auth_secret(client: &PodmanClient, secret_name: &str) { if let Err(err) = client.remove_secret(secret_name).await { warn!( @@ -179,6 +222,52 @@ async fn cleanup_sandbox_proxy_auth_secret(client: &PodmanClient, secret_name: & } } +async fn create_tls_secrets( + client: &PodmanClient, + config: &PodmanComputeConfig, + names: &[String; 3], +) -> Result<(), ComputeDriverError> { + let paths = [ + config.guest_tls_ca.as_deref(), + config.guest_tls_cert.as_deref(), + config.guest_tls_key.as_deref(), + ]; + let mut created = 0usize; + for (name, path) in names.iter().zip(paths.iter()) { + let Some(p) = path else { continue }; + let result = async { + let data = std::fs::read(p).map_err(|e| { + ComputeDriverError::Message(format!("read TLS file '{}': {e}", p.display())) + })?; + client + .create_secret(name, &data) + .await + .map_err(ComputeDriverError::from) + } + .await; + if let Err(e) = result { + for prev in &names[..created] { + let _ = client.remove_secret(prev).await; + } + return Err(e); + } + created += 1; + } + Ok(()) +} + +async fn cleanup_tls_secrets(client: &PodmanClient, names: &[String; 3]) { + for name in names { + if let Err(err) = client.remove_secret(name).await { + warn!( + secret = %name, + error = %err, + "Failed to remove TLS secret" + ); + } + } +} + fn local_podman_cdi_gpu_inventory_from(dev_root: &Path) -> CdiGpuInventory { let mut device_ids = std::fs::read_dir(dev_root) .ok() @@ -222,11 +311,21 @@ fn podman_gpu_selection_error(err: CdiGpuSelectionError) -> ComputeDriverError { ComputeDriverError::Precondition(err.to_string()) } +/// Return the first responsive local Podman API socket. +#[must_use] +pub fn detect_socket() -> Option { + crate::socket_discovery::detect_socket() +} + +#[must_use] +pub fn is_available() -> bool { + detect_socket().is_some() +} + /// Resolve the socket to connect to: explicit configuration wins, otherwise /// fall back to `detect`. Returns an error if neither resolves. /// -/// Takes `detect` as a parameter (rather than calling -/// [`openshell_core::config::detect_podman_socket`] directly) so tests can +/// Takes `detect` as a parameter so tests can /// exercise the precedence deterministically, without touching real /// environment variables or the filesystem. fn resolve_socket_path( @@ -248,10 +347,7 @@ impl PodmanComputeDriver { const MAX_PING_RETRIES: u32 = 5; const PING_RETRY_DELAY: Duration = Duration::from_secs(2); - let socket_path = resolve_socket_path( - config.socket_path.clone(), - openshell_core::config::detect_podman_socket, - )?; + let socket_path = resolve_socket_path(config.socket_path.clone(), detect_socket)?; config.socket_path = Some(socket_path.clone()); if !socket_path.exists() { @@ -277,6 +373,8 @@ impl PodmanComputeDriver { config.validate_runtime_limits()?; config.validate_host_gateway_ip()?; config.validate_proxy_config()?; + config.canonicalize_userns()?; + config.validate_userns_mappings()?; let client = PodmanClient::new(socket_path); @@ -396,6 +494,7 @@ impl PodmanComputeDriver { allow_all_default_gpu, )), gpu_inventory_refresh: Arc::new(local_podman_gpu_selector_state), + lifecycle_event_fences: LifecycleEventFences::default(), }) } @@ -410,11 +509,14 @@ impl PodmanComputeDriver { /// Report driver capabilities. pub fn capabilities(&self) -> Result { - Ok(openshell_core::driver_utils::build_capabilities_response( - "podman", - openshell_core::VERSION, - &self.config.default_image, - )) + Ok(GetCapabilitiesResponse { + driver_name: "podman".to_string(), + driver_version: openshell_core::VERSION.to_string(), + default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: true, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, + }) } /// Report the gateway exposure needed by Podman's standard local callback aliases. @@ -625,7 +727,18 @@ impl PodmanComputeDriver { } /// Create a sandbox container. + #[tracing::instrument( + name = "podman.provision", + skip(self, sandbox), + fields( + otel.name = "podman.provision", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox.id, + sandbox.name = %sandbox.name, + ) + )] pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), ComputeDriverError> { + let span_status = openshell_otel::ErrorStatusGuard::current(); if sandbox.name.is_empty() { return Err(ComputeDriverError::Precondition( "sandbox name is required".into(), @@ -652,84 +765,126 @@ impl PodmanComputeDriver { "Creating sandbox container" ); - // 1a. Pull the supervisor image if needed. The supervisor binary - // is shipped in a standalone OCI image and mounted into sandbox - // containers via Podman's type=image mount. Refresh mutable tags - // like latest/dev, but avoid registry checks for pinned images. - let supervisor_pull_policy = supervisor_image_pull_policy(&self.config.supervisor_image); - info!( - image = %self.config.supervisor_image, - policy = supervisor_pull_policy, - "Ensuring supervisor image" - ); - self.client - .pull_image(&self.config.supervisor_image, supervisor_pull_policy) - .await - .map_err(ComputeDriverError::from)?; - - // 1b. Pull the sandbox image if needed (Podman does not pull on create). - let image = container::resolve_image(sandbox, &self.config); - if image.is_empty() { - return Err(ComputeDriverError::Precondition( - "no sandbox image configured: set default_image in [openshell.drivers.podman] \ - or provide an image in the sandbox template" - .to_string(), - )); - } - let pull_policy = self.config.image_pull_policy.as_str(); - info!(image = %image, policy = %pull_policy, "Ensuring sandbox image"); - self.client - .pull_image(image, pull_policy) - .await - .map_err(ComputeDriverError::from)?; - let inspected_image = self - .client - .inspect_image(image) - .await - .map_err(ComputeDriverError::from)?; - if inspected_image.id.is_empty() { - return Err(ComputeDriverError::Precondition(format!( - "podman image '{image}' inspection did not return an immutable image ID" - ))); - } - let image_user = inspected_image - .config - .as_ref() - .map_or("", |config| config.user.as_str()); - - for image in - container::podman_driver_image_mount_sources(sandbox, self.config.enable_bind_mounts) + let (image, immutable_image_id, image_user) = async { + let phase_status = openshell_otel::ErrorStatusGuard::current(); + let result = async { + // The supervisor binary is shipped in a standalone OCI image and + // mounted into sandbox containers via Podman's type=image mount. + let supervisor_pull_policy = + supervisor_image_pull_policy(&self.config.supervisor_image); + info!( + image = %self.config.supervisor_image, + policy = supervisor_pull_policy, + "Ensuring supervisor image" + ); + self.client + .pull_image(&self.config.supervisor_image, supervisor_pull_policy) + .await + .map_err(ComputeDriverError::from)?; + + // Podman does not pull the sandbox image on container creation. + let image = container::resolve_image(sandbox, &self.config); + if image.is_empty() { + return Err(ComputeDriverError::Precondition( + "no sandbox image configured: set default_image in \ + [openshell.drivers.podman] or provide an image in the sandbox template" + .to_string(), + )); + } + let pull_policy = self.config.image_pull_policy.as_str(); + info!(image = %image, policy = %pull_policy, "Ensuring sandbox image"); + self.client + .pull_image(image, pull_policy) + .await + .map_err(ComputeDriverError::from)?; + let inspected_image = self + .client + .inspect_image(image) + .await + .map_err(ComputeDriverError::from)?; + if inspected_image.id.is_empty() { + return Err(ComputeDriverError::Precondition(format!( + "podman image '{image}' inspection did not return an immutable image ID" + ))); + } + let image_user = inspected_image + .config + .as_ref() + .map_or_else(String::new, |config| config.user.clone()); + + for mount_image in container::podman_driver_image_mount_sources( + sandbox, + self.config.enable_bind_mounts, + ) .map_err(ComputeDriverError::Precondition)? - { - info!(image = %image, policy = %pull_policy, "Ensuring image mount source"); - self.client - .pull_image(&image, pull_policy) - .await - .map_err(ComputeDriverError::from)?; - } + { + info!(image = %mount_image, policy = %pull_policy, "Ensuring image mount source"); + self.client + .pull_image(&mount_image, pull_policy) + .await + .map_err(ComputeDriverError::from)?; + } - // 2. Create workspace volume and per-sandbox token secret. - if let Err(e) = self.client.create_volume(&vol_name).await { - return Err(ComputeDriverError::from(e)); + Ok((image.to_string(), inspected_image.id, image_user)) + } + .await; + phase_status.finish(result) } - let token_secret_name = match create_sandbox_token_secret(&self.client, sandbox).await { - Ok(name) => name, - Err(e) => { - let _ = self.client.remove_volume(&vol_name).await; - return Err(e); + .instrument(tracing::info_span!( + "podman.prepare_images", + otel.name = "podman.prepare_images", + otel.status_code = tracing::field::Empty, + )) + .await?; + + // Fail closed on a missing/unreadable corporate proxy CA bundle before + // creating any resources, so the operator gets a clear error + // attributable to `proxy_ca_bundle` rather than an opaque bind-mount + // failure. The supervisor independently validates the certificate + // content at startup. + validate_sandbox_proxy_ca_bundle(&self.config).await?; + + // Create workspace volume and per-sandbox token secret. + let (token_secret_name, proxy_auth_secret_name) = async { + let phase_status = openshell_otel::ErrorStatusGuard::current(); + let result = async { + self.client + .create_volume(&vol_name) + .await + .map_err(ComputeDriverError::from)?; + let token_secret_name = + match create_sandbox_token_secret(&self.client, sandbox).await { + Ok(name) => name, + Err(e) => { + let _ = self.client.remove_volume(&vol_name).await; + return Err(e); + } + }; + let proxy_auth_secret_name = + match create_sandbox_proxy_auth_secret(&self.client, &self.config, sandbox) + .await + { + Ok(name) => name, + Err(e) => { + let _ = self.client.remove_volume(&vol_name).await; + if let Some(secret) = token_secret_name.as_deref() { + cleanup_sandbox_token_secret(&self.client, secret).await; + } + return Err(e); + } + }; + Ok((token_secret_name, proxy_auth_secret_name)) } - }; - let proxy_auth_secret_name = - match create_sandbox_proxy_auth_secret(&self.client, &self.config, sandbox).await { - Ok(name) => name, - Err(e) => { - let _ = self.client.remove_volume(&vol_name).await; - if let Some(secret) = token_secret_name.as_deref() { - cleanup_sandbox_token_secret(&self.client, secret).await; - } - return Err(e); - } - }; + .await; + phase_status.finish(result) + } + .instrument(tracing::info_span!( + "podman.prepare_storage", + otel.name = "podman.prepare_storage", + otel.status_code = tracing::field::Empty, + volume.name = %vol_name, + )) + .await?; // Clean up the volume and both per-sandbox secrets on any failure past // this point. @@ -743,51 +898,119 @@ impl PodmanComputeDriver { } }; - // 3. Create container. - let gpu_devices = match self.resolve_gpu_cdi_devices( - validated.gpu_requirements, - &validated.driver_config, - CdiGpuDefaultSelector::next_device_ids, - ) { - Ok(devices) => devices, - Err(e) => { - cleanup_created().await; - return Err(e); + // Prepare and create the container. + let tls_secret_names = async { + let phase_status = openshell_otel::ErrorStatusGuard::current(); + let result = async { + let gpu_devices = match self.resolve_gpu_cdi_devices( + validated.gpu_requirements, + &validated.driver_config, + CdiGpuDefaultSelector::next_device_ids, + ) { + Ok(devices) => devices, + Err(e) => { + cleanup_created().await; + return Err(e); + } + }; + let supervisor_bin_path = if userns_needs_extraction(self.config.userns.as_deref()) + { + match extract_supervisor_bin(&self.client, &self.config).await { + Ok(path) => Some(path), + Err(e) => { + cleanup_created().await; + return Err(e); + } + } + } else { + None + }; + + let tls_secret_names = if userns_remaps_uids(self.config.userns.as_deref()) + && self.config.tls_enabled() + { + let names = container::tls_secret_names(&sandbox.id); + if let Err(e) = create_tls_secrets(&self.client, &self.config, &names).await { + cleanup_created().await; + return Err(e); + } + Some(names) + } else { + None + }; + + let cleanup_all = || async { + cleanup_created().await; + if let Some(names) = &tls_secret_names { + cleanup_tls_secrets(&self.client, names).await; + } + }; + + let spec = match container::build_container_spec_for_image( + sandbox, + &self.config, + token_secret_name.as_deref(), + gpu_devices.as_deref(), + &image, + &immutable_image_id, + &image_user, + supervisor_bin_path.as_deref(), + tls_secret_names.as_ref(), + ) { + Ok(spec) => spec, + Err(e) => { + cleanup_all().await; + return Err(e); + } + }; + match self.client.create_container(&spec).await { + Ok(_) => Ok(tls_secret_names), + Err(PodmanApiError::Conflict(_)) => { + cleanup_all().await; + Err(ComputeDriverError::AlreadyExists) + } + Err(e) => { + cleanup_all().await; + Err(ComputeDriverError::from(e)) + } + } } - }; - let spec = match container::build_container_spec_for_image( - sandbox, - &self.config, - token_secret_name.as_deref(), - gpu_devices.as_deref(), - image, - &inspected_image.id, - image_user, - ) { - Ok(spec) => spec, - Err(e) => { - cleanup_created().await; - return Err(e); + .await; + phase_status.finish(result) + } + .instrument(tracing::info_span!( + "podman.prepare_container", + otel.name = "podman.prepare_container", + otel.status_code = tracing::field::Empty, + container.name = %name, + )) + .await?; + + let cleanup_all = || async { + cleanup_created().await; + if let Some(names) = &tls_secret_names { + cleanup_tls_secrets(&self.client, names).await; } }; - match self.client.create_container(&spec).await { - Ok(_) => {} - Err(PodmanApiError::Conflict(_)) => { - // Clean up the volume we just created. It is keyed by *this* - // sandbox's ID, not the conflicting container's ID (which - // has the same name but a different ID), so it would be - // orphaned otherwise. - cleanup_created().await; - return Err(ComputeDriverError::AlreadyExists); - } - Err(e) => { - cleanup_created().await; - return Err(ComputeDriverError::from(e)); - } - } - // 5. Start container. - if let Err(e) = self.client.start_container(&name).await { + // Start container. + let start_result = async { + let phase_status = openshell_otel::ErrorStatusGuard::current(); + let result = self + .client + .start_container(&name) + .await + .map_err(ComputeDriverError::from); + phase_status.finish(result) + } + .instrument(tracing::info_span!( + "podman.start_container", + otel.name = "podman.start_container", + otel.status_code = tracing::field::Empty, + container.name = %name, + )) + .await; + if let Err(e) = start_result { warn!( sandbox_name = %sandbox.name, error = %e, @@ -797,8 +1020,8 @@ impl PodmanComputeDriver { .client .remove_container(&name, self.config.stop_timeout_secs) .await; - cleanup_created().await; - return Err(ComputeDriverError::from(e)); + cleanup_all().await; + return Err(e); } info!( @@ -807,7 +1030,7 @@ impl PodmanComputeDriver { "Sandbox container started" ); - Ok(()) + span_status.finish(Ok(())) } /// Find the Podman container ID for a sandbox by its sandbox ID using label lookup. @@ -815,30 +1038,169 @@ impl PodmanComputeDriver { &self, sandbox_id: &str, ) -> Result, ComputeDriverError> { + Ok(self.find_container(sandbox_id).await?.map(|entry| entry.id)) + } + + async fn find_container( + &self, + sandbox_id: &str, + ) -> Result, ComputeDriverError> { let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); let entries = self .client .list_containers(&[LABEL_MANAGED_FILTER, &id_filter]) .await .map_err(ComputeDriverError::from)?; - Ok(entries.first().map(|e| e.id.clone())) + Ok(entries.into_iter().next()) + } + + async fn wait_for_container_stopped( + &self, + sandbox_id: &str, + container_id: &str, + ) -> Result, ComputeDriverError> { + let timeout = Duration::from_secs(u64::from(self.config.stop_timeout_secs)) + + STOP_COMPLETION_TIMEOUT_HEADROOM; + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let inspect = self + .client + .inspect_container(container_id) + .await + .map_err(ComputeDriverError::from)?; + if matches!(inspect.state.status.as_str(), "exited" | "stopped") { + return Ok(inspect.state.finished_at); + } + + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(ComputeDriverError::Message(format!( + "container {container_id} for sandbox {sandbox_id} did not finish stopping within {timeout:?} (last state: {})", + inspect.state.status, + ))); + } + tokio::time::sleep(STOP_COMPLETION_POLL_INTERVAL.min(deadline - now)).await; + } } /// Stop a sandbox container without deleting it. + #[tracing::instrument( + name = "podman.stop_sandbox", + skip(self), + fields( + otel.name = "podman.stop_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), ComputeDriverError> { - let container_id = self.find_container_id(sandbox_id).await?.ok_or_else(|| { - ComputeDriverError::Precondition("sandbox container not found".into()) - })?; + let span_status = openshell_otel::ErrorStatusGuard::current(); + let container = self + .find_container(sandbox_id) + .await? + .ok_or(ComputeDriverError::NotFound)?; + let container_id = container.id; + if container.state == "stopping" { + let result = async { + let finished_at = self + .wait_for_container_stopped(sandbox_id, &container_id) + .await?; + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, finished_at.as_deref()); + Ok(()) + } + .await; + return span_status.finish(result); + } + if container.state != "running" { + return span_status.finish(Ok(())); + } info!(sandbox_id = %sandbox_id, container = %container_id, "Stopping sandbox container"); - self.client - .stop_container(&container_id, self.config.stop_timeout_secs) + let result = async { + self.client + .stop_container(&container_id, self.config.stop_timeout_secs) + .await + .map_err(ComputeDriverError::from)?; + + // Podman can return from the stop request before inspect reports the + // container as exited. If start runs during that interval, the exit + // event from the previous run can arrive after the gateway has moved + // the same sandbox to Starting, causing it to regress to Error. Wait + // for the terminal container state before allowing a restart. + let finished_at = self + .wait_for_container_stopped(sandbox_id, &container_id) + .await?; + + // Record the completed run before returning the stop RPC. The server + // may begin a restart as soon as this method returns, while Podman's + // stop/die event can still be queued. Recording the fence here keeps + // that delayed event from regressing the new run from Starting to + // Error. Keep the start-side recording as a fallback for restarts + // after a driver or gateway process restart. + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, finished_at.as_deref()); + Ok(()) + } + .await; + span_status.finish(result) + } + + /// Start a previously stopped sandbox container. + #[tracing::instrument( + name = "podman.start_sandbox", + skip(self), + fields( + otel.name = "podman.start_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] + pub async fn start_sandbox(&self, sandbox_id: &str) -> Result<(), ComputeDriverError> { + let span_status = openshell_otel::ErrorStatusGuard::current(); + let container = self + .find_container(sandbox_id) + .await? + .ok_or(ComputeDriverError::NotFound)?; + if container.state == "running" { + return span_status.finish(Ok(())); + } + let container_id = container.id; + info!(sandbox_id = %sandbox_id, container = %container_id, "Starting sandbox container"); + + // Fence delayed stop/die events from the previous container run before + // issuing the start. Podman's event stream can deliver those events + // after this API call has begun. Use the container's own transition + // timestamp so this remains correct for remote Podman services whose + // wall clock may differ from the gateway host. + let previous = self + .client + .inspect_container(&container_id) .await - .map_err(ComputeDriverError::from) + .map_err(ComputeDriverError::from)?; + self.lifecycle_event_fences + .record_previous_exit(sandbox_id, previous.state.finished_at.as_deref()); + let result = self + .client + .start_container(&container_id) + .await + .map_err(ComputeDriverError::from); + span_status.finish(result) } /// Delete a sandbox container and its workspace volume. + #[tracing::instrument( + name = "podman.delete_sandbox", + skip(self), + fields( + otel.name = "podman.delete_sandbox", + otel.status_code = tracing::field::Empty, + sandbox.id = %sandbox_id, + ) + )] pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { + let span_status = openshell_otel::ErrorStatusGuard::current(); if sandbox_id.is_empty() { return Err(ComputeDriverError::Precondition( "sandbox id is required".into(), @@ -858,7 +1220,9 @@ impl PodmanComputeDriver { &container::proxy_auth_secret_name(sandbox_id), ) .await; - return Ok(false); + cleanup_tls_secrets(&self.client, &container::tls_secret_names(sandbox_id)).await; + self.lifecycle_event_fences.remove(sandbox_id); + return span_status.finish(Ok(false)); }; info!(sandbox_id = %sandbox_id, container = %container_id, "Deleting sandbox container"); @@ -891,8 +1255,10 @@ impl PodmanComputeDriver { &container::proxy_auth_secret_name(sandbox_id), ) .await; + cleanup_tls_secrets(&self.client, &container::tls_secret_names(sandbox_id)).await; + self.lifecycle_event_fences.remove(sandbox_id); - Ok(container_existed) + span_status.finish(Ok(container_existed)) } /// Check whether a sandbox container exists. @@ -977,7 +1343,7 @@ impl PodmanComputeDriver { /// Start watching all managed sandbox containers. pub async fn watch_sandboxes(&self) -> Result { - watcher::start_watch(self.client.clone()) + watcher::start_watch(self.client.clone(), self.lifecycle_event_fences.clone()) .await .map_err(ComputeDriverError::from) } @@ -1016,6 +1382,7 @@ impl PodmanComputeDriver { gpu_inventory_refresh: Arc::new(move || { (refresh_inventory.clone(), allow_all_default_gpu) }), + lifecycle_event_fences: LifecycleEventFences::default(), } } } @@ -1092,6 +1459,131 @@ fn validate_rootless_local_callback_helper( ))) } +// ── Supervisor binary extraction (userns fallback) ───────────────────── + +async fn extract_supervisor_bin( + client: &PodmanClient, + config: &PodmanComputeConfig, +) -> Result { + let mut inspect = client + .inspect_image(&config.supervisor_image) + .await + .map_err(ComputeDriverError::from)?; + + if supervisor_image_should_refresh(&config.supervisor_image) { + info!( + image = %config.supervisor_image, + "Refreshing mutable podman supervisor image" + ); + match client.pull_image(&config.supervisor_image, "always").await { + Ok(()) => { + inspect = client + .inspect_image(&config.supervisor_image) + .await + .map_err(ComputeDriverError::from)?; + } + Err(err) => { + warn!( + image = %config.supervisor_image, + error = %err, + "Failed to refresh mutable podman supervisor image; \ + falling back to local image if present", + ); + } + } + } + + let digest = if inspect.id.is_empty() { + return Err(ComputeDriverError::Precondition(format!( + "supervisor image '{}' has no ID", + config.supervisor_image, + ))); + } else { + &inspect.id + }; + + let cache_path = + openshell_core::driver_utils::supervisor_cache_path("podman-supervisor", digest) + .map_err(ComputeDriverError::Precondition)?; + if cache_path.is_file() { + validate_linux_elf_binary(&cache_path).map_err(ComputeDriverError::Precondition)?; + info!( + cache_path = %cache_path.display(), + "Using cached supervisor binary" + ); + return Ok(cache_path); + } + + info!( + image = %config.supervisor_image, + cache_path = %cache_path.display(), + "Extracting supervisor binary from image" + ); + + let container_name = temp_extract_container_name(); + let spec = serde_json::json!({ + "image": config.supervisor_image, + "name": container_name, + "entrypoint": [SUPERVISOR_IMAGE_BINARY_PATH], + "command": [], + }); + client + .create_container(&spec) + .await + .map_err(ComputeDriverError::from)?; + + let result = extract_binary_from_container(client, &container_name, &cache_path).await; + + if let Err(err) = client.remove_container(&container_name, 0).await { + warn!( + container = container_name, + error = %err, + "Failed to remove supervisor extractor container" + ); + } + + result +} + +async fn extract_binary_from_container( + client: &PodmanClient, + container_name: &str, + cache_path: &Path, +) -> Result { + let tar_bytes = client + .copy_from_container(container_name, SUPERVISOR_IMAGE_BINARY_PATH) + .await + .map_err(ComputeDriverError::from)?; + + let binary_bytes = extract_first_tar_entry(&tar_bytes).map_err(|err| { + ComputeDriverError::Precondition(format!( + "failed to extract supervisor binary from tar: {err}" + )) + })?; + + write_cache_binary_atomic(cache_path, &binary_bytes) + .map_err(ComputeDriverError::Precondition)?; + validate_linux_elf_binary(cache_path).map_err(ComputeDriverError::Precondition)?; + Ok(cache_path.to_path_buf()) +} + +fn userns_needs_extraction(userns: Option<&str>) -> bool { + userns.is_some_and(|mode| { + let base = mode.split(':').next().unwrap_or(mode); + !base.eq_ignore_ascii_case("host") + }) +} + +/// Returns `true` when userns remaps all UIDs, making host-owned bind mounts +/// unreadable from inside the container. `auto` and `no-map` remap every UID; +/// `keep-id` preserves the host user's UID; `host` uses the host namespace. +fn userns_remaps_uids(userns: Option<&str>) -> bool { + userns.is_some_and(|mode| { + let base = mode.split(':').next().unwrap_or(mode); + !matches!(base.to_ascii_lowercase().as_str(), "host" | "keep-id") + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1172,7 +1664,356 @@ mod tests { #[test] fn podman_driver_error_from_not_found() { let err = ComputeDriverError::from(PodmanApiError::NotFound("gone".into())); - assert!(matches!(err, ComputeDriverError::Message(_))); + assert!(matches!(err, ComputeDriverError::NotFound)); + } + + #[tokio::test] + async fn stop_and_start_target_the_existing_container() { + let (stop_socket, stop_requests, stop_handle) = spawn_podman_stub( + "lifecycle-stop", + vec![ + StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"running"}]"#), + StubResponse::new(StatusCode::NO_CONTENT, ""), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, + ), + ], + ); + test_driver(stop_socket.clone()) + .stop_sandbox("sandbox-1") + .await + .expect("stop should succeed"); + stop_handle.await.expect("stop stub should finish"); + assert_eq!( + stop_requests + .lock() + .expect("request log lock should not be poisoned")[1], + format!( + "POST {}", + api_path("/libpod/containers/ctr-1/stop?timeout=10") + ) + ); + assert_eq!( + stop_requests + .lock() + .expect("request log lock should not be poisoned")[2], + format!("GET {}", api_path("/libpod/containers/ctr-1/json")) + ); + + let (start_socket, start_requests, start_handle) = spawn_podman_stub( + "lifecycle-start", + vec![ + StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"stopped"}]"#), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, + ), + StubResponse::new(StatusCode::NO_CONTENT, ""), + ], + ); + test_driver(start_socket.clone()) + .start_sandbox("sandbox-1") + .await + .expect("start should succeed"); + start_handle.await.expect("start stub should finish"); + assert_eq!( + start_requests + .lock() + .expect("request log lock should not be poisoned")[1], + format!("GET {}", api_path("/libpod/containers/ctr-1/json")) + ); + assert_eq!( + start_requests + .lock() + .expect("request log lock should not be poisoned")[2], + format!("POST {}", api_path("/libpod/containers/ctr-1/start")) + ); + + let _ = fs::remove_file(stop_socket); + let _ = fs::remove_file(start_socket); + } + + #[tokio::test] + async fn stop_waits_for_the_container_to_leave_stopping_state() { + let (socket, requests, handle) = spawn_podman_stub( + "lifecycle-stop-wait", + vec![ + StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"running"}]"#), + StubResponse::new(StatusCode::NO_CONTENT, ""), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"stopping","Running":true},"Config":{}}"#, + ), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, + ), + ], + ); + + test_driver(socket.clone()) + .stop_sandbox("sandbox-1") + .await + .expect("stop should wait for the terminal container state"); + handle.await.expect("stop stub should finish"); + + let requests = requests + .lock() + .expect("request log lock should not be poisoned"); + assert_eq!(requests.len(), 4); + assert_eq!( + requests[2], + format!("GET {}", api_path("/libpod/containers/ctr-1/json")) + ); + assert_eq!(requests[3], requests[2]); + + let _ = fs::remove_file(socket); + } + + #[tokio::test] + async fn stop_retry_waits_for_an_existing_stopping_container() { + let (socket, requests, handle) = spawn_podman_stub( + "lifecycle-stop-retry", + vec![ + StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"stopping"}]"#), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, + ), + ], + ); + + test_driver(socket.clone()) + .stop_sandbox("sandbox-1") + .await + .expect("stop retry should wait for the terminal container state"); + handle.await.expect("stop retry stub should finish"); + + let requests = requests + .lock() + .expect("request log lock should not be poisoned"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1], + format!("GET {}", api_path("/libpod/containers/ctr-1/json")) + ); + + let _ = fs::remove_file(socket); + } + + #[tokio::test] + async fn stop_sandbox_exports_a_podman_operation_span() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let (socket_path, _requests, handle) = spawn_podman_stub( + "trace-stop", + vec![ + StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"running"}]"#), + StubResponse::new(StatusCode::NO_CONTENT, ""), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, + ), + ], + ); + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + + test_driver(socket_path.clone()) + .stop_sandbox("sandbox-1") + .with_subscriber(subscriber) + .await + .expect("stop should succeed"); + handle.await.expect("stub should finish"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "podman.stop_sandbox") + .expect("stop operation should be exported"); + assert_eq!( + span.attributes + .iter() + .find(|attribute| attribute.key.as_str() == "sandbox.id") + .map(|attribute| attribute.value.to_string()) + .as_deref(), + Some("sandbox-1") + ); + provider.shutdown().unwrap(); + let _ = fs::remove_file(socket_path); + } + + #[tokio::test] + async fn create_sandbox_exports_nested_preparation_spans() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let (socket_path, _requests, handle) = spawn_podman_stub( + "trace-create", + vec![ + StubResponse::new(StatusCode::OK, "{}"), + StubResponse::new(StatusCode::OK, "{}"), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"sha256:sandbox","Config":{"User":"1234:1235"}}"#, + ), + StubResponse::new(StatusCode::CREATED, "{}"), + StubResponse::new(StatusCode::CREATED, "{}"), + StubResponse::new(StatusCode::NO_CONTENT, ""), + ], + ); + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + + test_driver(socket_path.clone()) + .create_sandbox(&plain_sandbox("sandbox-trace", "demo")) + .with_subscriber(subscriber) + .await + .expect("create should succeed"); + handle.await.expect("stub should finish"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let create = spans + .iter() + .find(|span| span.name == "podman.provision") + .expect("create operation should be exported"); + for name in [ + "podman.prepare_images", + "podman.prepare_storage", + "podman.prepare_container", + "podman.start_container", + ] { + let child = spans + .iter() + .find(|span| span.name == name) + .unwrap_or_else(|| panic!("{name} should be exported")); + assert_eq!( + child.parent_span_id, + create.span_context.span_id(), + "{name}" + ); + } + provider.shutdown().unwrap(); + let _ = fs::remove_file(socket_path); + } + + #[tokio::test] + async fn prepare_images_span_covers_and_marks_sandbox_image_pull_failure() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let (socket_path, _requests, handle) = spawn_podman_stub( + "trace-image-failure", + vec![ + StubResponse::new(StatusCode::OK, "{}"), + StubResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "pull failed"), + ], + ); + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + + test_driver(socket_path.clone()) + .create_sandbox(&plain_sandbox("sandbox-trace", "demo")) + .with_subscriber(subscriber) + .await + .expect_err("sandbox image pull should fail"); + handle.await.expect("stub should finish"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let phase = spans + .iter() + .find(|span| span.name == "podman.prepare_images") + .expect("image preparation should be exported"); + assert!(matches!( + phase.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); + let _ = fs::remove_file(socket_path); + } + + #[tokio::test] + async fn start_and_delete_export_podman_operation_spans() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + + let (start_socket, _requests, start_handle) = spawn_podman_stub( + "trace-start", + vec![ + StubResponse::new(StatusCode::OK, r#"[{"Id":"ctr-1","State":"stopped"}]"#), + StubResponse::new( + StatusCode::OK, + r#"{"Id":"ctr-1","Name":"sandbox","State":{"Status":"exited","Running":false,"FinishedAt":"2026-08-12T16:39:13Z"},"Config":{}}"#, + ), + StubResponse::new(StatusCode::NO_CONTENT, ""), + ], + ); + test_driver(start_socket.clone()) + .start_sandbox("sandbox-1") + .with_subscriber(subscriber) + .await + .expect("start should succeed"); + start_handle.await.expect("start stub should finish"); + + let (delete_socket, _requests, delete_handle) = spawn_podman_stub( + "trace-delete", + vec![ + StubResponse::new(StatusCode::OK, "[]"), + StubResponse::new(StatusCode::NO_CONTENT, ""), + ], + ); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + test_driver(delete_socket.clone()) + .delete_sandbox("sandbox-1") + .with_subscriber(subscriber) + .await + .expect("delete should succeed"); + delete_handle.await.expect("delete stub should finish"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert!(spans.iter().any(|span| span.name == "podman.start_sandbox")); + assert!( + spans + .iter() + .any(|span| span.name == "podman.delete_sandbox") + ); + provider.shutdown().unwrap(); + let _ = fs::remove_file(start_socket); + let _ = fs::remove_file(delete_socket); } #[test] @@ -2245,4 +3086,28 @@ mod tests { ); let _ = fs::remove_file(socket_path); } + + #[test] + fn userns_needs_extraction_cases() { + assert!(!userns_needs_extraction(None)); + assert!(!userns_needs_extraction(Some("host"))); + assert!(!userns_needs_extraction(Some("Host"))); + assert!(userns_needs_extraction(Some("auto"))); + assert!(userns_needs_extraction(Some("auto:size=65536"))); + assert!(userns_needs_extraction(Some("keep-id"))); + assert!(userns_needs_extraction(Some("keep-id:uid=1000"))); + assert!(userns_needs_extraction(Some("no-map"))); + assert!(userns_needs_extraction(Some("private"))); + } + + #[test] + fn userns_remaps_uids_cases() { + assert!(!userns_remaps_uids(None)); + assert!(!userns_remaps_uids(Some("host"))); + assert!(!userns_remaps_uids(Some("keep-id"))); + assert!(userns_remaps_uids(Some("auto"))); + assert!(userns_remaps_uids(Some("auto:size=65536"))); + assert!(userns_remaps_uids(Some("no-map"))); + assert!(userns_remaps_uids(Some("private"))); + } } diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 2d0792d447..fedeea3068 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -6,9 +6,11 @@ use futures::{Stream, StreamExt}; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - GetCapabilitiesRequest, GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, - GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, - ListSandboxesRequest, ListSandboxesResponse, StopSandboxRequest, StopSandboxResponse, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, EnsureWorkspaceRequest, + EnsureWorkspaceResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, + GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, + GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, + StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, compute_driver_server::ComputeDriver, }; @@ -17,142 +19,253 @@ use tonic::{Request, Response, Status}; use crate::PodmanComputeDriver; +type ComputeDriverWatchStream = + Pin> + Send + 'static>>; + +#[cfg(test)] +type TracedWatchStream = openshell_otel::TracedGrpcStream; + #[derive(Debug, Clone)] pub struct ComputeDriverService { driver: PodmanComputeDriver, + rpc_tracer: openshell_otel::InProcessRpcTracer, } impl ComputeDriverService { #[must_use] pub fn new(driver: PodmanComputeDriver) -> Self { - Self { driver } + Self { + driver, + rpc_tracer: openshell_otel::InProcessRpcTracer::disabled(), + } + } + + #[must_use] + pub fn new_in_process(driver: PodmanComputeDriver) -> Self { + Self { + driver, + rpc_tracer: openshell_otel::InProcessRpcTracer::enabled(), + } } } #[tonic::async_trait] impl ComputeDriver for ComputeDriverService { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + self.rpc_tracer + .trace(openshell_otel::rpc::AUTHENTICATE_SANDBOX, async { + Err(Status::unimplemented( + "podman does not authenticate sandbox credentials", + )) + }) + .await + } + async fn get_capabilities( &self, _request: Request, ) -> Result, Status> { - self.driver - .capabilities() - .map(Response::new) - .map_err(Status::from) + self.rpc_tracer + .trace(openshell_otel::rpc::GET_CAPABILITIES, async { + self.driver + .capabilities() + .map(Response::new) + .map_err(Status::from) + }) + .await } async fn get_gateway_listener_requirements( &self, _request: Request, ) -> Result, Status> { - Ok(Response::new(GetGatewayListenerRequirementsResponse { - requirements: self - .driver - .gateway_listener_requirements() - .map_err(Status::from)?, - })) + self.rpc_tracer + .trace( + openshell_otel::rpc::GET_GATEWAY_LISTENER_REQUIREMENTS, + async { + Ok(Response::new(GetGatewayListenerRequirementsResponse { + requirements: self + .driver + .gateway_listener_requirements() + .map_err(Status::from)?, + })) + }, + ) + .await } async fn validate_sandbox_create( &self, request: Request, ) -> Result, Status> { - let sandbox = request - .into_inner() - .sandbox - .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; - self.driver - .validate_sandbox_create(&sandbox) + self.rpc_tracer + .trace(openshell_otel::rpc::VALIDATE_SANDBOX_CREATE, async { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.driver + .validate_sandbox_create(&sandbox) + .await + .map_err(Status::from)?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + }) .await - .map_err(Status::from)?; - Ok(Response::new(ValidateSandboxCreateResponse {})) } async fn get_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - - let sandbox = self - .driver - .get_sandbox(&request.sandbox_id) + self.rpc_tracer + .trace(openshell_otel::rpc::GET_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + let sandbox = self + .driver + .get_sandbox(&request.sandbox_id) + .await + .map_err(Status::from)? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + Ok(Response::new(GetSandboxResponse { + sandbox: Some(sandbox), + })) + }) .await - .map_err(Status::from)? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - - Ok(Response::new(GetSandboxResponse { - sandbox: Some(sandbox), - })) } async fn list_sandboxes( &self, _request: Request, ) -> Result, Status> { - let sandboxes = self.driver.list_sandboxes().await.map_err(Status::from)?; - Ok(Response::new(ListSandboxesResponse { sandboxes })) + self.rpc_tracer + .trace(openshell_otel::rpc::LIST_SANDBOXES, async { + let sandboxes = self.driver.list_sandboxes().await.map_err(Status::from)?; + Ok(Response::new(ListSandboxesResponse { sandboxes })) + }) + .await } async fn create_sandbox( &self, request: Request, ) -> Result, Status> { - let sandbox = request - .into_inner() - .sandbox - .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; - self.driver - .create_sandbox(&sandbox) + self.rpc_tracer + .trace(openshell_otel::rpc::CREATE_SANDBOX, async { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.driver + .create_sandbox(&sandbox) + .await + .map_err(Status::from)?; + Ok(Response::new(CreateSandboxResponse {})) + }) .await - .map_err(Status::from)?; - Ok(Response::new(CreateSandboxResponse {})) } async fn stop_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - self.driver - .stop_sandbox(&request.sandbox_id) + self.rpc_tracer + .trace(openshell_otel::rpc::STOP_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + self.driver + .stop_sandbox(&request.sandbox_id) + .await + .map_err(Status::from)?; + Ok(Response::new(StopSandboxResponse {})) + }) + .await + } + + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace(openshell_otel::rpc::START_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + self.driver + .start_sandbox(&request.sandbox_id) + .await + .map_err(Status::from)?; + Ok(Response::new(StartSandboxResponse {})) + }) .await - .map_err(Status::from)?; - Ok(Response::new(StopSandboxResponse {})) } async fn delete_sandbox( &self, request: Request, ) -> Result, Status> { - let request = request.into_inner(); - if request.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - let deleted = self - .driver - .delete_sandbox(&request.sandbox_id) + self.rpc_tracer + .trace(openshell_otel::rpc::DELETE_SANDBOX, async { + let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + let deleted = self + .driver + .delete_sandbox(&request.sandbox_id) + .await + .map_err(Status::from)?; + Ok(Response::new(DeleteSandboxResponse { deleted })) + }) .await - .map_err(Status::from)?; - Ok(Response::new(DeleteSandboxResponse { deleted })) } - type WatchSandboxesStream = - Pin> + Send + 'static>>; + type WatchSandboxesStream = ComputeDriverWatchStream; async fn watch_sandboxes( &self, _request: Request, ) -> Result, Status> { - let stream = self.driver.watch_sandboxes().await.map_err(Status::from)?; - let stream = stream.map(|item| item.map_err(|err| Status::internal(err.to_string()))); - Ok(Response::new(Box::pin(stream))) + let create_stream = async { + let stream = self.driver.watch_sandboxes().await.map_err(Status::from)?; + let stream = stream.map(|item| item.map_err(|err| Status::internal(err.to_string()))); + Ok::(Box::pin(stream)) + }; + self.rpc_tracer + .trace_stream(openshell_otel::rpc::WATCH_SANDBOXES, create_stream) + .await + .map(Response::new) + } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace(openshell_otel::rpc::ENSURE_WORKSPACE, async { + Ok(Response::new(EnsureWorkspaceResponse {})) + }) + .await + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + self.rpc_tracer + .trace(openshell_otel::rpc::DELETE_WORKSPACE, async { + Ok(Response::new(DeleteWorkspaceResponse {})) + }) + .await } } @@ -166,6 +279,53 @@ mod tests { use openshell_core::ComputeDriverError; use std::path::PathBuf; + type TestDriverClient = + openshell_core::proto::compute::v1::compute_driver_client::ComputeDriverClient< + tonic::transport::Channel, + >; + + fn request_with_traceparent(message: T) -> Request { + let mut request = Request::new(message); + request.metadata_mut().insert( + "traceparent", + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + .parse() + .unwrap(), + ); + request + } + + async fn standalone_traced_client() -> ( + TestDriverClient, + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle>, + ) { + use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let service = ComputeDriverService::new(PodmanComputeDriver::for_tests( + PodmanComputeConfig::default(), + )); + let server = tokio::spawn(async move { + tonic::transport::Server::builder() + .layer(openshell_otel::compute_driver_rpc_layer()) + .add_service(ComputeDriverServer::new(service)) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::TcpListenerStream::new(listener), + async { + let _ = shutdown_rx.await; + }, + ) + .await + }); + let client = TestDriverClient::connect(format!("http://{address}")) + .await + .unwrap(); + (client, shutdown, server) + } + #[test] fn precondition_driver_errors_map_to_failed_precondition_status() { let status: Status = @@ -181,6 +341,307 @@ mod tests { assert_eq!(status.code(), tonic::Code::AlreadyExists); } + #[test] + fn not_found_driver_errors_map_to_not_found_status() { + let status: Status = ComputeDriverError::NotFound.into(); + assert_eq!(status.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn in_process_service_preserves_the_driver_rpc_server_boundary() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::{Instrument as _, instrument::WithSubscriber as _}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let gateway_exporter = InMemorySpanExporterBuilder::new().build(); + let gateway_provider = SdkTracerProvider::builder() + .with_simple_exporter(gateway_exporter.clone()) + .build(); + let driver_exporter = InMemorySpanExporterBuilder::new().build(); + let driver_provider = SdkTracerProvider::builder() + .with_simple_exporter(driver_exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(openshell_otel::layer_excluding_target_prefixes( + &gateway_provider, + "gateway-test", + crate::otel_tracing::TRACING.in_process_targets(), + )) + .with(crate::otel_tracing::TRACING.in_process_layer(&driver_provider)); + let service = ComputeDriverService::new_in_process(PodmanComputeDriver::for_tests( + PodmanComputeConfig::default(), + )); + + async { + let gateway_span = tracing::info_span!(target: "openshell_server::compute", "driver", otel.name = "openshell.compute.v1.ComputeDriver/GetCapabilities", otel.kind = "client"); + ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) + .instrument(gateway_span) + .await + } + .with_subscriber(subscriber) + .await + .expect("capabilities should succeed"); + gateway_provider.force_flush().unwrap(); + driver_provider.force_flush().unwrap(); + + let gateway_spans = gateway_exporter.get_finished_spans().unwrap(); + let driver_spans = driver_exporter.get_finished_spans().unwrap(); + let client = gateway_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .unwrap(); + let server = driver_spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .expect("in-process server span"); + assert_eq!( + server.span_context.trace_id(), + client.span_context.trace_id() + ); + assert_eq!(server.parent_span_id, client.span_context.span_id()); + assert_eq!(server.span_kind, opentelemetry::trace::SpanKind::Server); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.method" + && attribute.value.to_string() + == "openshell.compute.v1.ComputeDriver/GetCapabilities" + })); + assert!( + server + .attributes + .iter() + .all(|attribute| attribute.key.as_str() != "rpc.service"), + "the current RPC semantic conventions integrate the service into rpc.method" + ); + assert!(server.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "OK" + })); + gateway_provider.shutdown().unwrap(); + driver_provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn standalone_rpc_layer_propagates_context_and_records_errors() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let dispatch = tracing::Dispatch::new( + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)), + ); + let _dispatch = tracing::dispatcher::set_default(&dispatch); + let (mut client, shutdown, server) = standalone_traced_client().await; + + client + .get_capabilities(request_with_traceparent(GetCapabilitiesRequest {})) + .await + .expect("capabilities should succeed"); + client + .validate_sandbox_create(request_with_traceparent(ValidateSandboxCreateRequest { + sandbox: None, + })) + .await + .expect_err("missing sandbox should fail"); + drop(client); + shutdown.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .expect("standalone test server should stop") + .expect("standalone test server should not panic") + .expect("standalone test server should stop cleanly"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let capabilities = spans + .iter() + .filter(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") + .collect::>(); + assert_eq!(capabilities.len(), 1, "exactly one RPC span is expected"); + assert_eq!( + capabilities[0].span_context.trace_id().to_string(), + "4bf92f3577b34da6a3ce929d0e0e4736" + ); + assert_eq!( + capabilities[0].parent_span_id.to_string(), + "00f067aa0ba902b7" + ); + assert_eq!( + capabilities[0].span_kind, + opentelemetry::trace::SpanKind::Server + ); + assert!(capabilities[0].attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "OK" + })); + + let failed = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate") + .expect("failed RPC span should be exported"); + assert!(matches!( + failed.status, + opentelemetry::trace::Status::Error { .. } + )); + assert!(failed.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "INVALID_ARGUMENT" + })); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn in_process_stream_span_lives_until_stream_failure() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(crate::otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: crate::otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::iter([Err( + Status::internal("watch failed"), + )])); + let mut stream = TracedWatchStream::new(inner, span); + + provider.force_flush().unwrap(); + assert!( + exporter.get_finished_spans().unwrap().is_empty(), + "server span must remain open while the response stream is alive" + ); + stream + .next() + .await + .expect("stream item") + .expect_err("stream should fail"); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream ends"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn in_process_stream_records_ok_when_stream_completes() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(crate::otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: crate::otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::empty()); + let mut stream = TracedWatchStream::new(inner, span); + + assert!(stream.next().await.is_none()); + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream completes"); + assert!(span.attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "OK" + })); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn in_process_stream_leaves_status_unset_when_dropped() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(crate::otel_tracing::TRACING.in_process_layer(&provider)); + + async { + let span = tracing::info_span!( + target: crate::otel_tracing::TRACING.in_process_target(), + "driver_rpc", + otel.name = "openshell.compute.v1.ComputeDriver/WatchSandboxes", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, + ); + let inner: ComputeDriverWatchStream = Box::pin(futures::stream::pending()); + let stream = TracedWatchStream::new(inner, span); + + drop(stream); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch server span should be exported when the stream is dropped"); + assert!(matches!(span.status, opentelemetry::trace::Status::Unset)); + assert!( + span.attributes + .iter() + .all(|attribute| attribute.key.as_str() != "rpc.response.status_code") + ); + provider.shutdown().unwrap(); + } + fn test_service(socket_path: PathBuf) -> ComputeDriverService { let config = PodmanComputeConfig { socket_path: Some(socket_path), diff --git a/crates/openshell-driver-podman/src/lib.rs b/crates/openshell-driver-podman/src/lib.rs index 5847a10ea6..115e64eb2f 100644 --- a/crates/openshell-driver-podman/src/lib.rs +++ b/crates/openshell-driver-podman/src/lib.rs @@ -6,6 +6,8 @@ pub mod config; pub(crate) mod container; pub mod driver; pub mod grpc; +pub mod otel_tracing; +mod socket_discovery; #[cfg(test)] pub(crate) mod test_utils; pub(crate) mod watcher; diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 4a38643f38..4c42ba9699 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -3,10 +3,10 @@ use clap::Parser; use miette::{IntoDiagnostic, Result}; +use std::future::Future; use std::net::SocketAddr; use std::path::PathBuf; use tracing::info; -use tracing_subscriber::EnvFilter; use openshell_core::VERSION; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; @@ -20,6 +20,10 @@ use openshell_driver_podman::{ComputeDriverService, PodmanComputeConfig, PodmanC #[command(name = "openshell-driver-podman")] #[command(version = VERSION)] struct Args { + /// Public compute-driver Unix socket used by an external gateway. + #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] + bind_socket: Option, + #[arg( long, env = "OPENSHELL_COMPUTE_DRIVER_BIND", @@ -30,6 +34,12 @@ struct Args { #[arg(long, env = "OPENSHELL_LOG_LEVEL", default_value = "info")] log_level: String, + #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] + otlp_endpoint: Option, + + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + /// Path to the Podman API Unix socket. #[arg(long, env = "OPENSHELL_PODMAN_SOCKET")] podman_socket: Option, @@ -67,7 +77,7 @@ struct Args { #[arg( long, env = "OPENSHELL_SANDBOX_SSH_SOCKET_PATH", - default_value = "/run/openshell/ssh.sock" + default_value = openshell_core::container_paths::SSH_SOCKET_PATH )] sandbox_ssh_socket_path: String, @@ -133,16 +143,46 @@ struct Args { /// SSRF/`allowed_ips` validation no longer binds the connection. #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME")] sandbox_proxy_connect_by_hostname: Option, + + /// Path (on the gateway host) to a PEM CA bundle trusted for the corporate + /// proxy: the TLS handshake with an `https://` proxy and, for + /// TLS-intercepting proxies, re-signed upstream certificates. Bind-mounted + /// read-only into the sandbox. Only meaningful with `--sandbox-https-proxy`. + #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_CA_BUNDLE")] + sandbox_proxy_ca_bundle: Option, + + /// User namespace mode for sandbox containers (e.g. `auto`). + /// When unset, containers use the default user namespace. + #[arg(long, env = "OPENSHELL_PODMAN_USERNS")] + userns: Option, + + /// Explicit UID mappings for `userns = "private"`. + /// Each entry is `"container_id:host_id:size"`. + #[arg(long = "uidmap")] + uidmap: Vec, + + /// Explicit GID mappings for `userns = "private"`. + /// Each entry is `"container_id:host_id:size"`. + #[arg(long = "gidmap")] + gidmap: Vec, + + /// Allow sandbox requests to attach host bind mounts. + #[arg(long, env = "OPENSHELL_ENABLE_BIND_MOUNTS", default_value_t = false)] + enable_bind_mounts: bool, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), - ) - .init(); + let _tracing = openshell_otel::install_driver_tracing( + openshell_driver_podman::otel_tracing::TRACING, + openshell_otel::DriverTracingConfig { + endpoint: args.otlp_endpoint.as_deref(), + gateway_name: args.gateway_name.as_deref(), + service_version: VERSION, + log_level: &args.log_level, + }, + ); let driver = PodmanComputeDriver::new(PodmanComputeConfig { socket_path: args.podman_socket, @@ -168,18 +208,105 @@ async fn main() -> Result<()> { proxy_auth_file: args.sandbox_proxy_auth_file, proxy_auth_allow_insecure: args.sandbox_proxy_auth_allow_insecure, proxy_connect_by_hostname: args.sandbox_proxy_connect_by_hostname, + proxy_ca_bundle: args.sandbox_proxy_ca_bundle, + userns: args.userns, + uidmap: args.uidmap, + gidmap: args.gidmap, + enable_bind_mounts: args.enable_bind_mounts, ..PodmanComputeConfig::default() }) .await .into_diagnostic()?; - info!(address = %args.bind_address, "Starting Podman compute driver"); - tonic::transport::Server::builder() - .add_service(ComputeDriverServer::new(ComputeDriverService::new(driver))) - .serve_with_shutdown(args.bind_address, async { - tokio::signal::ctrl_c().await.ok(); - info!("Received shutdown signal, draining in-flight requests"); - }) - .await - .into_diagnostic() + let service = ComputeDriverServer::new(ComputeDriverService::new(driver)); + if let Some(socket_path) = args.bind_socket { + let listener = openshell_core::external_driver_socket::bind_private(&socket_path) + .map_err(|err| miette::miette!("{err}"))?; + let _cleanup = + openshell_core::external_driver_socket::SocketCleanup::new(socket_path.clone()); + info!(socket = %socket_path.display(), "Starting Podman compute driver"); + tonic::transport::Server::builder() + .layer(openshell_otel::compute_driver_rpc_layer()) + .add_service(service) + .serve_with_incoming_shutdown( + openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), + shutdown_signal(), + ) + .await + .into_diagnostic() + } else { + info!(address = %args.bind_address, "Starting Podman compute driver"); + tonic::transport::Server::builder() + .layer(openshell_otel::compute_driver_rpc_layer()) + .add_service(service) + .serve_with_shutdown(args.bind_address, shutdown_signal()) + .await + .into_diagnostic() + } +} + +async fn select_shutdown_signal( + ctrl_c: impl Future, + terminate: impl Future, +) { + tokio::select! { + () = ctrl_c => {} + () = terminate => {} + } +} + +async fn ctrl_c_signal() { + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::warn!(%error, "Failed to install Ctrl-C signal handler"); + std::future::pending::<()>().await; + } +} + +#[cfg(unix)] +async fn terminate_signal() { + let Ok(mut signal) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + else { + tracing::warn!("Failed to install SIGTERM signal handler"); + std::future::pending::<()>().await; + return; + }; + let _ = signal.recv().await; +} + +async fn shutdown_signal() { + #[cfg(unix)] + select_shutdown_signal(ctrl_c_signal(), terminate_signal()).await; + + #[cfg(not(unix))] + ctrl_c_signal().await; + + info!("Received shutdown signal, draining in-flight requests"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn shutdown_completes_when_termination_signal_arrives() { + select_shutdown_signal(std::future::pending(), std::future::ready(())).await; + } + + #[test] + fn accepts_gateway_otlp_configuration() { + let args = Args::try_parse_from([ + "openshell-driver-podman", + "--otlp-endpoint", + "http://collector.internal:4317", + "--gateway-name", + "production-us-west", + ]) + .expect("OTLP configuration should be accepted"); + + assert_eq!( + args.otlp_endpoint.as_deref(), + Some("http://collector.internal:4317") + ); + assert_eq!(args.gateway_name.as_deref(), Some("production-us-west")); + } } diff --git a/crates/openshell-driver-podman/src/otel_tracing.rs b/crates/openshell-driver-podman/src/otel_tracing.rs new file mode 100644 index 0000000000..8d3314f821 --- /dev/null +++ b/crates/openshell-driver-podman/src/otel_tracing.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenTelemetry tracing identity for the Podman compute driver. + +pub const TRACING: openshell_otel::ComputeDriverTracing = openshell_otel::compute_driver_tracing!(); + +#[cfg(test)] +mod tests { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn driver_spans_reach_otlp_collector_with_resource_identity() { + openshell_otel_test_support::assert_compute_driver_tracing( + super::TRACING, + openshell_core::VERSION, + "podman.create", + ) + .await; + } +} diff --git a/crates/openshell-driver-podman/src/socket_discovery.rs b/crates/openshell-driver-podman/src/socket_discovery.rs new file mode 100644 index 0000000000..e7a6c79e74 --- /dev/null +++ b/crates/openshell-driver-podman/src/socket_discovery.rs @@ -0,0 +1,402 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Podman socket discovery for local and machine-backed installations. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +const DISCOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Return the first responsive well-known socket, then ask the Podman CLI for +/// the active native or machine-backed connection. +pub fn detect_socket() -> Option { + openshell_core::local_api_socket::first_responsive_socket(&socket_candidates(), |response| { + openshell_core::local_api_socket::http_response_is_success(response) + && openshell_core::local_api_socket::contains_ascii(response, b"Libpod-Api-Version:") + }) + .or_else(discover_socket) +} + +fn socket_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(path) = env_var_nonempty("OPENSHELL_PODMAN_SOCKET") { + candidates.push(PathBuf::from(path)); + } + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + candidates.push(PathBuf::from(runtime_dir).join("podman/podman.sock")); + } + #[cfg(target_os = "linux")] + candidates.push(PathBuf::from(format!( + "/run/user/{}/podman/podman.sock", + rustix::process::geteuid().as_raw() + ))); + if let Some(home) = std::env::var_os("HOME") { + candidates + .push(PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock")); + } + candidates +} + +/// Query the same active connection selected by the Podman CLI. This covers +/// named/rootful machines and provider-specific forwarded socket locations that +/// do not have a stable well-known path. +fn discover_socket() -> Option { + let stdout = run_podman_capture(&["info", "--format", "json"])?; + + // A successful `podman info` proves this explicit socket is usable. A named + // connection has higher precedence, so only honor CONTAINER_HOST directly + // when no CONTAINER_CONNECTION is set. + if env_var_nonempty("CONTAINER_CONNECTION").is_none() + && let Some(path) = env_var_nonempty("CONTAINER_HOST") + .as_deref() + .and_then(unix_url_socket_path) + { + return Some(path); + } + + let info: serde_json::Value = serde_json::from_slice(&stdout).ok()?; + if !info["host"]["serviceIsRemote"].as_bool().unwrap_or(false) { + return parse_info_socket(&info); + } + + discover_machine_socket() +} + +fn discover_machine_socket() -> Option { + let connections = connection_list(); + let active = active_machine( + env_var_nonempty("CONTAINER_CONNECTION").as_deref(), + env_var_nonempty("CONTAINER_HOST").as_deref(), + connections.as_ref(), + )?; + machine_inspect_targets(&active) + .into_iter() + .find_map(|name| { + let stdout = run_podman_capture(&["machine", "inspect", &name])?; + let machines: serde_json::Value = serde_json::from_slice(&stdout).ok()?; + parse_machine_socket(&machines) + }) +} + +fn active_machine( + container_connection: Option<&str>, + container_host: Option<&str>, + connections: Option<&serde_json::Value>, +) -> Option { + if let Some(name) = container_connection.filter(|name| !name.trim().is_empty()) { + return Some(name.to_string()); + } + if let Some(host) = container_host.filter(|host| !host.trim().is_empty()) { + // An explicit non-machine endpoint must not fall back to an unrelated + // local machine. + return connection_name_for_uri(connections?, host); + } + default_machine_connection(connections).or_else(|| Some("podman-machine-default".to_string())) +} + +fn machine_inspect_targets(connection: &str) -> Vec { + let mut names = vec![connection.to_string()]; + if let Some(machine) = connection.strip_suffix("-root") + && !machine.is_empty() + { + names.push(machine.to_string()); + } + names +} + +fn connection_list() -> Option { + let stdout = run_podman_capture(&["system", "connection", "list", "--format", "json"])?; + serde_json::from_slice(&stdout).ok() +} + +fn default_machine_connection(connections: Option<&serde_json::Value>) -> Option { + connections? + .as_array()? + .iter() + .find(|connection| { + connection["Default"].as_bool().unwrap_or(false) + && connection["IsMachine"].as_bool().unwrap_or(false) + }) + .and_then(|connection| connection["Name"].as_str()) + .map(str::to_string) +} + +fn connection_name_for_uri(connections: &serde_json::Value, uri: &str) -> Option { + connections + .as_array()? + .iter() + .find(|connection| { + connection["IsMachine"].as_bool().unwrap_or(false) + && connection["URI"].as_str() == Some(uri) + }) + .and_then(|connection| connection["Name"].as_str()) + .map(str::to_string) +} + +fn parse_info_socket(info: &serde_json::Value) -> Option { + let path = info["host"]["remoteSocket"]["path"].as_str()?; + unix_url_socket_path(path).or_else(|| (!path.is_empty()).then(|| PathBuf::from(path))) +} + +fn parse_machine_socket(machines: &serde_json::Value) -> Option { + let path = machines.as_array()?.first()?["ConnectionInfo"]["PodmanSocket"]["Path"].as_str()?; + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +fn unix_url_socket_path(url: &str) -> Option { + let path = url.trim().strip_prefix("unix://")?; + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +fn env_var_nonempty(key: &str) -> Option { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn run_podman_capture(args: &[&str]) -> Option> { + run_bounded_command("podman", args, DISCOVERY_TIMEOUT) +} + +/// Capture stdout without allowing a stalled Podman machine, SSH transport, or +/// descendant holding the stdout pipe open to block gateway startup forever. +fn run_bounded_command(program: &str, args: &[&str], timeout: Duration) -> Option> { + use std::io::Read as _; + use std::sync::mpsc; + + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + set_new_process_group(&mut command); + let mut child = command.spawn().ok()?; + + let mut stdout = child.stdout.take()?; + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut output = Vec::new(); + let _ = stdout.read_to_end(&mut output); + let _ = sender.send(output); + }); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(DISCOVERY_POLL_INTERVAL); + } + Ok(None) | Err(_) => break None, + } + }; + + let Some(status) = status else { + terminate_process_group(&mut child); + let _ = child.wait(); + return None; + }; + + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(output) if status.success() => Some(output), + Ok(_) => None, + Err(_) => { + terminate_process_group(&mut child); + None + } + } +} + +#[cfg(unix)] +fn set_new_process_group(command: &mut Command) { + use std::os::unix::process::CommandExt as _; + command.process_group(0); +} + +#[cfg(not(unix))] +fn set_new_process_group(_command: &mut Command) {} + +#[cfg(unix)] +fn terminate_process_group(child: &mut std::process::Child) { + let pid = i32::try_from(child.id()).unwrap_or(i32::MAX); + let group = nix::unistd::Pid::from_raw(-pid); + let _ = nix::sys::signal::kill(group, nix::sys::signal::Signal::SIGKILL); + let _ = child.kill(); +} + +#[cfg(not(unix))] +fn terminate_process_group(child: &mut std::process::Child) { + let _ = child.kill(); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_info_socket_rejects_missing_or_empty_paths() { + assert_eq!( + parse_info_socket(&json!({"host": {"remoteSocket": {}}})), + None + ); + assert_eq!( + parse_info_socket(&json!({"host": {"remoteSocket": {"path": ""}}})), + None + ); + } + + #[test] + fn parse_machine_socket_rejects_missing_socket_or_machine() { + assert_eq!(parse_machine_socket(&json!([])), None); + assert_eq!(parse_machine_socket(&json!([{"ConnectionInfo": {}}])), None); + } + + #[test] + fn parses_native_and_machine_socket_paths() { + let info = json!({"host": {"remoteSocket": {"path": "unix:///run/user/1000/podman.sock"}}}); + assert_eq!( + parse_info_socket(&info), + Some(PathBuf::from("/run/user/1000/podman.sock")) + ); + + let machine = json!([{"ConnectionInfo": {"PodmanSocket": {"Path": "/tmp/machine.sock"}}}]); + assert_eq!( + parse_machine_socket(&machine), + Some(PathBuf::from("/tmp/machine.sock")) + ); + } + + #[test] + fn resolves_named_rootful_and_default_machine_connections() { + let connections = json!([ + {"Name": "team-machine-root", "URI": "ssh://team", "Default": true, "IsMachine": true}, + {"Name": "remote", "URI": "ssh://remote", "Default": false, "IsMachine": false} + ]); + assert_eq!( + active_machine(Some("team-machine-root"), None, Some(&connections)), + Some("team-machine-root".to_string()) + ); + assert_eq!( + machine_inspect_targets("team-machine-root"), + ["team-machine-root", "team-machine"] + ); + assert_eq!( + active_machine(None, None, Some(&connections)), + Some("team-machine-root".to_string()) + ); + } + + #[test] + fn explicit_non_machine_endpoint_does_not_guess_a_machine() { + let connections = json!([ + {"Name": "local", "URI": "ssh://local", "Default": true, "IsMachine": true}, + {"Name": "remote", "URI": "ssh://remote", "Default": false, "IsMachine": false} + ]); + assert_eq!( + active_machine(None, Some("ssh://remote"), Some(&connections)), + None + ); + } + + #[test] + fn container_host_maps_to_the_matching_machine_connection() { + let connections = json!([ + {"Name": "work", "URI": "ssh://core@127.0.0.1:5555/run/podman.sock", "Default": false, "IsMachine": true}, + {"Name": "podman-machine-default", "URI": "ssh://core@127.0.0.1:4444/run/podman.sock", "Default": true, "IsMachine": true} + ]); + assert_eq!( + active_machine( + None, + Some("ssh://core@127.0.0.1:5555/run/podman.sock"), + Some(&connections), + ), + Some("work".to_string()) + ); + } + + #[test] + fn unix_url_socket_path_only_accepts_nonempty_unix_urls() { + assert_eq!( + unix_url_socket_path("unix:///run/user/1000/podman.sock"), + Some(PathBuf::from("/run/user/1000/podman.sock")) + ); + assert_eq!(unix_url_socket_path("ssh://core@127.0.0.1/x"), None); + assert_eq!(unix_url_socket_path("tcp://127.0.0.1:2375"), None); + assert_eq!(unix_url_socket_path("unix://"), None); + } + + #[cfg(unix)] + #[test] + fn bounded_command_captures_stdout_on_success() { + assert_eq!( + run_bounded_command("printf", &["hello"], Duration::from_secs(5)), + Some(b"hello".to_vec()) + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_returns_none_on_nonzero_exit_or_missing_program() { + assert_eq!( + run_bounded_command("false", &[], Duration::from_secs(5)), + None + ); + assert_eq!( + run_bounded_command( + "openshell-nonexistent-binary-xyz", + &[], + Duration::from_secs(5), + ), + None + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_kills_child_that_exceeds_deadline() { + let start = Instant::now(); + let result = run_bounded_command("sleep", &["30"], Duration::from_millis(200)); + assert_eq!(result, None); + assert!( + start.elapsed() < Duration::from_secs(5), + "bounded command did not return promptly" + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_bounds_drain_when_in_group_descendant_holds_stdout() { + let start = Instant::now(); + let result = run_bounded_command( + "sh", + &["-c", "sleep 30 & echo done"], + Duration::from_millis(300), + ); + assert_eq!(result, None); + assert!( + start.elapsed() < Duration::from_secs(2), + "drain blocked on a descendant holding stdout" + ); + } + + #[cfg(unix)] + #[test] + fn bounded_command_bounds_drain_when_descendant_escapes_process_group() { + let start = Instant::now(); + let result = run_bounded_command( + "bash", + &["-c", "set -m; sleep 5 & echo done"], + Duration::from_millis(300), + ); + assert_eq!(result, None); + assert!( + start.elapsed() < Duration::from_secs(2), + "drain blocked on a descendant that escaped the process group" + ); + } +} diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 4d397eb972..c3903a0067 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -16,7 +16,9 @@ use openshell_core::proto::compute::v1::{ DriverCondition, DriverSandbox, DriverSandboxStatus, WatchSandboxesDeletedEvent, WatchSandboxesEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; +use std::collections::HashMap; use std::pin::Pin; +use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use tracing::{debug, info, warn}; @@ -24,12 +26,70 @@ use tracing::{debug, info, warn}; // Condition reason constants shared across event-building paths. const CONDITION_RUNNING: &str = "ContainerRunning"; const CONDITION_STARTING: &str = "ContainerStarting"; -const CONDITION_EXITED: &str = "ContainerExited"; -const CONDITION_STOPPED: &str = "ContainerStopped"; +use openshell_core::driver_utils::{ + CONDITION_EXITED, CONDITION_RUNTIME_RESTART, CONDITION_STOPPED, +}; pub type WatchStream = Pin> + Send>>; +/// Per-sandbox container exit timestamps that fence state changes from an earlier run. +/// +/// Podman can deliver a container's `die` or `stop` event after the stop API +/// has returned. If a restart is already in progress, inspecting the container +/// for that delayed event can report the previous exit and incorrectly regress +/// the sandbox from `Starting` to `Error`. +#[derive(Clone, Debug, Default)] +pub struct LifecycleEventFences { + previous_finished_at: Arc>>, +} + +impl LifecycleEventFences { + pub fn record_previous_exit(&self, sandbox_id: &str, finished_at: Option<&str>) { + let mut fences = self + .previous_finished_at + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match finished_at.filter(|finished_at| !finished_at.is_empty()) { + Some(finished_at) => { + fences.insert(sandbox_id.to_string(), finished_at.to_string()); + } + None => { + fences.remove(sandbox_id); + } + } + } + + pub fn remove(&self, sandbox_id: &str) { + self.previous_finished_at + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(sandbox_id); + } + + fn matches_previous_exit( + &self, + event: &PodmanEvent, + sandbox_id: &str, + state: &ContainerState, + ) -> bool { + if !matches!(event.action.as_str(), "die" | "stop") + || !matches!(state.status.as_str(), "exited" | "stopped") + { + return false; + } + + let Some(finished_at) = state.finished_at.as_deref() else { + return false; + }; + self.previous_finished_at + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(sandbox_id) + .is_some_and(|previous| previous == finished_at) + } +} + /// Build a `WatchSandboxesEvent` carrying a sandbox snapshot. fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { WatchSandboxesEvent { @@ -62,16 +122,16 @@ fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { /// drops (daemon restart, socket error, or clean shutdown), the stream /// terminates with a final error item and stops producing events. /// -/// Callers are responsible for reconnecting by calling [`start_watch`] again. -/// The server's `ComputeRuntime::watch_loop` in `openshell-server` provides -/// this behaviour with a 2-second backoff: when the stream terminates with an -/// error, `watch_loop` sleeps and then calls `watch_sandboxes()` again, which -/// ultimately calls `start_watch()` again and re-syncs state. +/// Callers are responsible for reconnecting by calling [`start_watch`] again +/// and re-synchronizing state. /// /// **Do not add reconnection logic inside this function.** A local reconnect -/// would race with `watch_loop`'s retry and produce duplicate initial-sync -/// events that corrupt the server's sandbox index. -pub async fn start_watch(client: PodmanClient) -> Result { +/// would race with the consumer's retry and produce duplicate initial-sync +/// events. +pub async fn start_watch( + client: PodmanClient, + lifecycle_event_fences: LifecycleEventFences, +) -> Result { let (tx, rx) = mpsc::channel::>(256); // 1. Subscribe to events first so we don't miss any during the list. @@ -120,7 +180,8 @@ pub async fn start_watch(client: PodmanClient) -> Result { - if let Some(we) = map_podman_event(&event, &client).await + if let Some(we) = + map_podman_event(&event, &client, &lifecycle_event_fences).await && tx.send(Ok(we)).await.is_err() { return; @@ -165,6 +226,7 @@ pub async fn start_watch(client: PodmanClient) -> Result Option { let container_id = &event.actor.id; let sandbox_id = event @@ -188,7 +250,24 @@ async fn map_podman_event( "create" | "start" | "stop" | "die" | "health_status" => { // Inspect the container to get current state. match client.inspect_container(container_id).await { - Ok(inspect) => driver_sandbox_from_inspect(&inspect).map(sandbox_event), + Ok(inspect) => { + if lifecycle_event_fences.matches_previous_exit( + event, + &sandbox_id, + &inspect.state, + ) { + debug!( + sandbox_id, + container_id = %container_id, + action = %event.action, + finished_at = inspect.state.finished_at.as_deref().unwrap_or_default(), + "Ignoring container stop event from before the latest sandbox start" + ); + None + } else { + driver_sandbox_from_inspect(&inspect).map(sandbox_event) + } + } Err(PodmanApiError::NotFound(_)) => { // The container is already gone by the time we inspected // it. This is a normal race between the `die`/`stop` event @@ -364,15 +443,29 @@ fn condition_from_state(state: &ContainerState) -> DriverCondition { }, "created" => ("False", "ContainerCreated", String::new()), "exited" | "stopped" => { - let msg = if state.oom_killed { - "Container was killed by the OOM killer".to_string() + // Exit codes 137 (128+SIGKILL) and 143 (128+SIGTERM) mean the + // container was terminated by an external signal rather than + // exiting on its own — the signature of a machine/daemon restart + // killing running containers. Those are recoverable at gateway + // startup; ordinary application exits (0, non-zero, faults) are not. + let (reason, msg) = if state.oom_killed { + ( + "OOMKilled", + "Container was killed by the OOM killer".to_string(), + ) + } else if matches!(state.exit_code, 137 | 143) { + ( + CONDITION_RUNTIME_RESTART, + format!( + "Container terminated by signal (exit code {})", + state.exit_code + ), + ) } else { - format!("Container exited with code {}", state.exit_code) - }; - let reason = if state.oom_killed { - "OOMKilled" - } else { - CONDITION_EXITED + ( + CONDITION_EXITED, + format!("Container exited with code {}", state.exit_code), + ) }; ("False", reason, msg) } @@ -405,6 +498,66 @@ fn condition_from_state(state: &ContainerState) -> DriverCondition { mod tests { use super::*; + fn podman_event(action: &str, sandbox_id: &str, time_nano: i64) -> PodmanEvent { + PodmanEvent { + event_type: "container".to_string(), + action: action.to_string(), + actor: crate::client::EventActor { + id: "container-1".to_string(), + attributes: HashMap::from([(LABEL_SANDBOX_ID.to_string(), sandbox_id.to_string())]), + }, + time_nano, + } + } + + #[test] + fn lifecycle_fence_rejects_delayed_stop_events_from_before_restart() { + let fences = LifecycleEventFences::default(); + fences.record_previous_exit("sandbox-1", Some("2026-08-12T16:39:13Z")); + let previous_exit = ContainerState { + status: "exited".to_string(), + running: false, + exit_code: 137, + oom_killed: false, + health: None, + started_at: Some("2026-08-12T16:38:58Z".to_string()), + finished_at: Some("2026-08-12T16:39:13Z".to_string()), + }; + + assert!(fences.matches_previous_exit( + &podman_event("die", "sandbox-1", 199), + "sandbox-1", + &previous_exit, + )); + assert!(fences.matches_previous_exit( + &podman_event("stop", "sandbox-1", 200), + "sandbox-1", + &previous_exit, + )); + + let mut new_exit = previous_exit.clone(); + new_exit.finished_at = Some("2026-08-12T16:40:00Z".to_string()); + assert!(!fences.matches_previous_exit( + &podman_event("die", "sandbox-1", 201), + "sandbox-1", + &new_exit, + )); + + let mut running = previous_exit.clone(); + running.status = "running".to_string(); + running.finished_at = None; + assert!(!fences.matches_previous_exit( + &podman_event("die", "sandbox-1", 201), + "sandbox-1", + &running, + )); + assert!(!fences.matches_previous_exit( + &podman_event("start", "sandbox-1", 199), + "sandbox-1", + &previous_exit, + )); + } + #[test] fn condition_healthy_container() { let state = ContainerState { @@ -459,6 +612,32 @@ mod tests { assert!(cond.message.contains("code 1")); } + #[test] + fn condition_signal_kill_is_runtime_restart() { + // 137 (128+SIGKILL) and 143 (128+SIGTERM) are external terminations — + // the signature of a machine/daemon restart. They classify as + // recoverable `ContainerRuntimeRestart`, distinct from an ordinary + // application exit. + for exit_code in [137, 143] { + let state = ContainerState { + status: "exited".to_string(), + running: false, + exit_code, + oom_killed: false, + health: None, + started_at: None, + finished_at: Some("2026-04-14T12:30:00Z".to_string()), + }; + let cond = condition_from_state(&state); + assert_eq!(cond.status, "False"); + assert_eq!( + cond.reason, "ContainerRuntimeRestart", + "exit code {exit_code} should classify as runtime restart" + ); + assert!(cond.message.contains(&format!("code {exit_code}"))); + } + } + #[test] fn short_id_truncates() { assert_eq!(short_id("abc123def456789"), "abc123def456"); @@ -467,7 +646,7 @@ mod tests { #[test] fn sandbox_event_from_list_entry_running() { - let mut labels = std::collections::HashMap::new(); + let mut labels = HashMap::new(); labels.insert(LABEL_SANDBOX_ID.to_string(), "test-id".to_string()); labels.insert(LABEL_SANDBOX_NAME.to_string(), "test-name".to_string()); labels.insert(LABEL_SANDBOX_WORKSPACE.to_string(), "default".to_string()); diff --git a/crates/openshell-driver-vault/Cargo.toml b/crates/openshell-driver-vault/Cargo.toml new file mode 100644 index 0000000000..2d3878ed67 --- /dev/null +++ b/crates/openshell-driver-vault/Cargo.toml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-vault" +description = "Vault credential driver for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-driver-vault" +path = "src/main.rs" + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +clap = { workspace = true } +futures = { workspace = true } +miette = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } +tonic = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +tempfile = "3" +wiremock = "0.6" + +[lints] +workspace = true diff --git a/crates/openshell-driver-vault/src/lib.rs b/crates/openshell-driver-vault/src/lib.rs new file mode 100644 index 0000000000..d932005708 --- /dev/null +++ b/crates/openshell-driver-vault/src/lib.rs @@ -0,0 +1,1420 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Credential driver backed by a Vault-compatible HTTP API. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use openshell_core::VERSION; +use openshell_core::proto::CredentialHandle; +use openshell_core::proto::credentials::v1::{ + DeleteCredentialRequest, DeleteCredentialResponse, GetCredentialDriverCapabilitiesRequest, + GetCredentialDriverCapabilitiesResponse, ListCredentialsRequest, ListCredentialsResponse, + ResolveCredentialRequest, ResolveCredentialsRequest, ResolveCredentialsResponse, + ResolvedCredential, StoreCredentialRequest, StoreCredentialResponse, + credential_driver_server::CredentialDriver, +}; +use openshell_core::{Error, Result as CoreResult}; +use reqwest::{StatusCode, Url}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tonic::{Request, Response, Status}; + +const DEFAULT_MOUNT: &str = "secret"; +const DEFAULT_AUTH_METHOD: &str = "kubernetes"; +const DEFAULT_KUBERNETES_AUTH_MOUNT: &str = "kubernetes"; +const DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH: &str = + "/var/run/secrets/kubernetes.io/serviceaccount/token"; +const DEFAULT_TIMEOUT_SECS: u64 = 10; +const HANDLE_VERSION: &str = "v1"; +const STORED_VALUE_KEY: &str = "value"; +const OBJECT_ID_METADATA_KEY: &str = "openshell.storage_object_id"; + +pub struct VaultCredentialDriver { + client: reqwest::Client, + settings: VaultDriverSettings, + cached_token: Arc>>, +} + +struct CachedVaultToken { + token: String, + valid_until: Instant, +} + +#[derive(Debug, Clone)] +pub struct CredentialDriverService { + driver: VaultCredentialDriver, +} + +impl CredentialDriverService { + #[must_use] + pub fn new(driver: VaultCredentialDriver) -> Self { + Self { driver } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct VaultDriverSettings { + address: Url, + mount: String, + kv_version: KvVersion, + auth: VaultAuthSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum VaultAuthSettings { + Kubernetes { + role: String, + auth_mount: String, + service_account_token_path: PathBuf, + }, + TokenFile { + token_path: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KvVersion { + V1, + V2, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct VaultDriverConfig { + address: Option, + mount: Option, + kv_version: Option, + auth_method: Option, + role: Option, + kubernetes_auth_mount: Option, + service_account_token_path: Option, + token_path: Option, + timeout_secs: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct VaultSecretReference { + api_path: String, + key: String, + kv_version: KvVersion, +} + +#[derive(Debug, Serialize)] +struct KubernetesLoginRequest<'a> { + role: &'a str, + jwt: &'a str, +} + +#[derive(Debug, Deserialize)] +struct KubernetesLoginResponse { + auth: Option, +} + +#[derive(Debug, Deserialize)] +struct KubernetesLoginAuth { + client_token: String, + #[serde(default)] + lease_duration: u64, +} + +impl VaultCredentialDriver { + pub const NAME: &'static str = "vault"; + + pub fn from_config(config: &toml::Table) -> CoreResult { + let settings = VaultDriverSettings::from_table(config)?; + let timeout_secs = timeout_secs(config)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|err| { + Error::config(format!( + "failed to configure vault credential driver: {err}" + )) + })?; + Ok(Self { + client, + settings, + cached_token: Arc::new(tokio::sync::Mutex::new(None)), + }) + } + + pub async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + let object_id = if let Some(existing_handle) = request.existing_handle.as_ref() { + object_id_from_handle(existing_handle, &request.provider_id)? + } else { + requested_object_id(&request.object_id, &request.provider_id)?.to_string() + }; + let logical_path = if let Some(existing_handle) = request.existing_handle.as_ref() { + Self::logical_path_from_handle(existing_handle)? + } else { + managed_secret_path( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + ) + }; + validate_secret_path(&logical_path).map_err(Status::invalid_argument)?; + validate_managed_secret_path( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + &logical_path, + )?; + let token = self.auth_token().await?; + let reference = VaultSecretReference { + api_path: api_path_for_reference( + &self.settings.mount, + self.settings.kv_version, + &logical_path, + ), + key: STORED_VALUE_KEY.to_string(), + kv_version: self.settings.kv_version, + }; + self.store_secret_value(&reference, &request.value, &token) + .await?; + Ok(CredentialHandle { + driver: Self::NAME.to_string(), + handle: format!("{HANDLE_VERSION}:{logical_path}"), + metadata: std::collections::HashMap::from([( + OBJECT_ID_METADATA_KEY.to_string(), + object_id, + )]), + }) + } + + pub async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + let handle = Self::handle_from_request("delete", request.handle)?; + let logical_path = Self::logical_path_from_handle(&handle)?; + let object_id = object_id_from_handle(&handle, &request.provider_id)?; + validate_managed_secret_path( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + &logical_path, + )?; + let token = self.auth_token().await?; + let api_path = delete_api_path_for_reference( + &self.settings.mount, + self.settings.kv_version, + &logical_path, + ); + self.delete_secret_value(&api_path, &token).await + } + + pub async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + let mut resolved_requests = Vec::with_capacity(requests.len()); + for request in requests { + let handle = Self::handle_from_request(&request.request_id, request.handle)?; + let logical_path = Self::logical_path_from_handle(&handle)?; + let object_id = object_id_from_handle(&handle, &request.provider_id)?; + validate_managed_secret_path( + &request.workspace, + &request.provider_id, + &request.provider_name, + &request.credential_key, + &object_id, + &logical_path, + )?; + let reference = VaultSecretReference { + api_path: api_path_for_reference( + &self.settings.mount, + self.settings.kv_version, + &logical_path, + ), + key: STORED_VALUE_KEY.to_string(), + kv_version: self.settings.kv_version, + }; + resolved_requests.push((request.request_id, reference)); + } + + let token = self.auth_token().await?; + let futures = resolved_requests + .into_iter() + .map(|(request_id, reference)| { + let token = token.clone(); + async move { + let value = self.resolve_secret_value(&reference, &token).await?; + Ok::<_, Status>(ResolvedCredential { + request_id, + value, + expires_at_ms: 0, + }) + } + }); + futures::future::try_join_all(futures).await + } + + fn handle_from_request( + request_id: &str, + handle: Option, + ) -> Result { + handle.ok_or_else(|| { + Status::invalid_argument(format!( + "vault credential request '{request_id}' is missing handle" + )) + }) + } + + fn logical_path_from_handle(handle: &CredentialHandle) -> Result { + let logical_path = handle + .handle + .strip_prefix(&format!("{HANDLE_VERSION}:")) + .ok_or_else(|| Status::invalid_argument("vault credential handle is malformed"))?; + validate_secret_path(logical_path).map_err(Status::invalid_argument)?; + Ok(logical_path.to_string()) + } + + async fn auth_token(&self) -> Result { + match &self.settings.auth { + VaultAuthSettings::TokenFile { token_path } => { + read_secret_file(token_path, "Vault token file").await + } + VaultAuthSettings::Kubernetes { + role, + auth_mount, + service_account_token_path, + } => { + let mut cache = self.cached_token.lock().await; + if let Some(cached) = cache.as_ref() + && Instant::now() < cached.valid_until + { + return Ok(cached.token.clone()); + } + let jwt = read_secret_file( + service_account_token_path, + "Kubernetes service account token", + ) + .await?; + let (token, lease_duration) = self.login_kubernetes(role, auth_mount, &jwt).await?; + if lease_duration > Duration::ZERO { + let ttl = lease_duration.mul_f64(0.8); + *cache = Some(CachedVaultToken { + token: token.clone(), + valid_until: Instant::now() + ttl, + }); + } + Ok(token) + } + } + } + + async fn login_kubernetes( + &self, + role: &str, + auth_mount: &str, + jwt: &str, + ) -> Result<(String, Duration), Status> { + let path = format!("auth/{auth_mount}/login"); + let url = self.url_for_path(&path)?; + let response = self + .client + .post(url) + .json(&KubernetesLoginRequest { role, jwt }) + .send() + .await + .map_err(|err| { + Status::unavailable(format!("Vault Kubernetes auth request failed: {err}")) + })?; + let status = response.status(); + if !status.is_success() { + return Err(vault_auth_status(status)); + } + + let body = response + .json::() + .await + .map_err(|_| { + Status::failed_precondition("Vault Kubernetes auth returned invalid JSON") + })?; + let (token, lease_duration) = body + .auth + .map(|auth| (auth.client_token, auth.lease_duration)) + .unwrap_or_default(); + let token = token.trim().to_string(); + if token.is_empty() { + return Err(Status::failed_precondition( + "Vault Kubernetes auth returned an empty client token", + )); + } + Ok((token, Duration::from_secs(lease_duration))) + } + + async fn resolve_secret_value( + &self, + reference: &VaultSecretReference, + token: &str, + ) -> Result { + let url = self.url_for_path(&reference.api_path)?; + let response = self + .client + .get(url) + .header("X-Vault-Token", token) + .send() + .await + .map_err(|err| { + Status::unavailable(format!( + "Vault secret read failed for path '{}': {err}", + reference.api_path + )) + })?; + let status = response.status(); + if !status.is_success() { + return Err(vault_secret_status(status, &reference.api_path)); + } + + let body = response.json::().await.map_err(|_| { + Status::failed_precondition(format!( + "Vault secret path '{}' returned invalid JSON", + reference.api_path + )) + })?; + extract_secret_value(&body, reference) + } + + async fn store_secret_value( + &self, + reference: &VaultSecretReference, + value: &str, + token: &str, + ) -> Result<(), Status> { + let url = self.url_for_path(&reference.api_path)?; + let body = match reference.kv_version { + KvVersion::V1 => serde_json::json!({ &reference.key: value }), + KvVersion::V2 => serde_json::json!({ "data": { &reference.key: value } }), + }; + let response = self + .client + .post(url) + .header("X-Vault-Token", token) + .json(&body) + .send() + .await + .map_err(|err| { + Status::unavailable(format!( + "Vault secret write failed for path '{}': {err}", + reference.api_path + )) + })?; + let status = response.status(); + if status.is_success() { + Ok(()) + } else { + Err(vault_secret_status(status, &reference.api_path)) + } + } + + async fn delete_secret_value(&self, api_path: &str, token: &str) -> Result<(), Status> { + let url = self.url_for_path(api_path)?; + let response = self + .client + .delete(url) + .header("X-Vault-Token", token) + .send() + .await + .map_err(|err| { + Status::unavailable(format!( + "Vault secret delete failed for path '{api_path}': {err}" + )) + })?; + let status = response.status(); + if status.is_success() || status == StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(vault_secret_status(status, api_path)) + } + } + + fn url_for_path(&self, path: &str) -> Result { + self.settings + .address + .join(&format!("v1/{path}")) + .map_err(|err| Status::internal(format!("failed to build Vault URL: {err}"))) + } +} + +impl std::fmt::Debug for VaultCredentialDriver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VaultCredentialDriver") + .field("settings", &self.settings) + .finish_non_exhaustive() + } +} + +impl Clone for VaultCredentialDriver { + fn clone(&self) -> Self { + Self { + client: self.client.clone(), + settings: self.settings.clone(), + cached_token: self.cached_token.clone(), + } + } +} + +#[tonic::async_trait] +impl CredentialDriver for CredentialDriverService { + async fn get_capabilities( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(GetCredentialDriverCapabilitiesResponse { + driver_name: VaultCredentialDriver::NAME.to_string(), + driver_version: VERSION.to_string(), + backend_kind: VaultCredentialDriver::NAME.to_string(), + supports_list: false, + supports_expires_at: false, + })) + } + + async fn store_credential( + &self, + request: Request, + ) -> Result, Status> { + let handle = self.driver.store_credential(request.into_inner()).await?; + Ok(Response::new(StoreCredentialResponse { + handle: Some(handle), + })) + } + + async fn delete_credential( + &self, + request: Request, + ) -> Result, Status> { + self.driver.delete_credential(request.into_inner()).await?; + Ok(Response::new(DeleteCredentialResponse {})) + } + + async fn resolve_credentials( + &self, + request: Request, + ) -> Result, Status> { + let credentials = self + .driver + .resolve_credentials(request.into_inner().credentials) + .await?; + Ok(Response::new(ResolveCredentialsResponse { credentials })) + } + + async fn list_credentials( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "vault credential driver does not support listing credentials", + )) + } +} + +impl VaultDriverSettings { + fn from_table(config: &toml::Table) -> CoreResult { + let config: VaultDriverConfig = + toml::Value::Table(config.clone()) + .try_into() + .map_err(|err| { + Error::config(format!( + "invalid [openshell.credential_drivers.vault]: {err}" + )) + })?; + let address = config + .address + .as_deref() + .ok_or_else(|| { + Error::config("[openshell.credential_drivers.vault] address is required") + }) + .and_then(vault_address)?; + let mount = config + .mount + .as_deref() + .map_or_else(|| Ok(DEFAULT_MOUNT.to_string()), mount_config)?; + let kv_version = config + .kv_version + .as_deref() + .map_or_else(|| Ok(KvVersion::V2), KvVersion::parse_config)?; + let auth_method = config + .auth_method + .as_deref() + .unwrap_or(DEFAULT_AUTH_METHOD) + .trim(); + let auth = match auth_method { + "kubernetes" => { + if config.token_path.is_some() { + return Err(Error::config( + "[openshell.credential_drivers.vault] token_path requires auth_method = 'token_file'", + )); + } + let role = config.role.as_deref().ok_or_else(|| { + Error::config( + "[openshell.credential_drivers.vault] role is required for auth_method = 'kubernetes'", + ) + })?; + let role = trimmed_config_string("role", role)?.to_string(); + let auth_mount = config.kubernetes_auth_mount.as_deref().map_or_else( + || Ok(DEFAULT_KUBERNETES_AUTH_MOUNT.to_string()), + |mount| path_config("kubernetes_auth_mount", mount), + )?; + let service_account_token_path = config + .service_account_token_path + .unwrap_or_else(|| PathBuf::from(DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH)); + VaultAuthSettings::Kubernetes { + role, + auth_mount, + service_account_token_path, + } + } + "token_file" => { + if config.role.is_some() + || config.kubernetes_auth_mount.is_some() + || config.service_account_token_path.is_some() + { + return Err(Error::config( + "[openshell.credential_drivers.vault] Kubernetes auth fields require auth_method = 'kubernetes'", + )); + } + let token_path = config.token_path.ok_or_else(|| { + Error::config( + "[openshell.credential_drivers.vault] token_path is required for auth_method = 'token_file'", + ) + })?; + VaultAuthSettings::TokenFile { token_path } + } + other => { + return Err(Error::config(format!( + "[openshell.credential_drivers.vault] auth_method must be 'kubernetes' or 'token_file', got '{other}'" + ))); + } + }; + + Ok(Self { + address, + mount, + kv_version, + auth, + }) + } +} + +impl KvVersion { + fn parse_config(value: &str) -> CoreResult { + match trimmed_config_string("kv_version", value)? { + "1" => Ok(Self::V1), + "2" => Ok(Self::V2), + other => Err(Error::config(format!( + "[openshell.credential_drivers.vault] kv_version must be '1' or '2', got '{other}'" + ))), + } + } +} + +fn vault_address(value: &str) -> CoreResult { + let value = trimmed_config_string("address", value)?; + let mut url = Url::parse(value).map_err(|_| { + Error::config("[openshell.credential_drivers.vault] address must be an absolute URL") + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::config( + "[openshell.credential_drivers.vault] address must use http or https", + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(Error::config( + "[openshell.credential_drivers.vault] address must not include credentials", + )); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(Error::config( + "[openshell.credential_drivers.vault] address must not include query or fragment", + )); + } + if !url.path().ends_with('/') { + let path = format!("{}/", url.path().trim_end_matches('/')); + url.set_path(&path); + } + Ok(url) +} + +fn timeout_secs(table: &toml::Table) -> CoreResult { + let Some(value) = table.get("timeout_secs") else { + return Ok(DEFAULT_TIMEOUT_SECS); + }; + let timeout = value.as_integer().ok_or_else(|| { + Error::config( + "[openshell.credential_drivers.vault] timeout_secs must be a positive integer", + ) + })?; + if timeout <= 0 { + return Err(Error::config( + "[openshell.credential_drivers.vault] timeout_secs must be a positive integer", + )); + } + u64::try_from(timeout).map_err(|_| { + Error::config("[openshell.credential_drivers.vault] timeout_secs is too large") + }) +} + +fn mount_config(value: &str) -> CoreResult { + path_config("mount", value) +} + +fn path_config(field_name: &str, value: &str) -> CoreResult { + let value = trimmed_config_string(field_name, value)?; + validate_secret_path(value).map_err(|message| { + Error::config(format!( + "[openshell.credential_drivers.vault] {field_name} {message}" + )) + })?; + Ok(value.to_string()) +} + +fn trimmed_config_string<'a>(field_name: &str, value: &'a str) -> CoreResult<&'a str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(Error::config(format!( + "[openshell.credential_drivers.vault] {field_name} must not be empty" + ))); + } + if trimmed.len() != value.len() { + return Err(Error::config(format!( + "[openshell.credential_drivers.vault] {field_name} must not contain leading or trailing whitespace" + ))); + } + Ok(trimmed) +} + +fn validate_secret_path(value: &str) -> Result<(), &'static str> { + if value.is_empty() { + return Err("must not be empty"); + } + if value.len() > 1024 { + return Err("must be 1024 bytes or fewer"); + } + if value.starts_with('/') || value.ends_with('/') { + return Err("must be a relative path without leading or trailing slash"); + } + if value.contains("//") { + return Err("must not contain empty path segments"); + } + for segment in value.split('/') { + if matches!(segment, "." | "..") { + return Err("must not contain '.' or '..' path segments"); + } + if !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("may only contain ASCII letters, digits, '-', '_', '.', and '/'"); + } + } + Ok(()) +} + +fn api_path_for_reference(mount: &str, kv_version: KvVersion, target: &str) -> String { + match kv_version { + KvVersion::V1 => { + if target == mount || target.starts_with(&format!("{mount}/")) { + target.to_string() + } else { + format!("{mount}/{target}") + } + } + KvVersion::V2 => { + let data_prefix = format!("{mount}/data/"); + if target.starts_with(&data_prefix) { + target.to_string() + } else { + let logical_path = target.strip_prefix(&format!("{mount}/")).unwrap_or(target); + format!("{mount}/data/{logical_path}") + } + } + } +} + +fn delete_api_path_for_reference(mount: &str, kv_version: KvVersion, target: &str) -> String { + match kv_version { + KvVersion::V1 => api_path_for_reference(mount, kv_version, target), + KvVersion::V2 => { + let metadata_prefix = format!("{mount}/metadata/"); + if target.starts_with(&metadata_prefix) { + target.to_string() + } else { + let logical_path = target.strip_prefix(&format!("{mount}/")).unwrap_or(target); + format!("{mount}/metadata/{logical_path}") + } + } + } +} + +fn managed_secret_path( + workspace: &str, + provider_id: &str, + provider_name: &str, + credential_key: &str, + object_id: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(workspace.as_bytes()); + hasher.update([0]); + hasher.update(provider_id.as_bytes()); + hasher.update([0]); + hasher.update(provider_name.as_bytes()); + hasher.update([0]); + hasher.update(credential_key.as_bytes()); + if object_id != provider_id { + hasher.update([0]); + hasher.update(object_id.as_bytes()); + } + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + format!("openshell/provider-credentials/{}", &hex[..40]) +} + +fn validate_managed_secret_path( + workspace: &str, + provider_id: &str, + provider_name: &str, + credential_key: &str, + object_id: &str, + logical_path: &str, +) -> Result<(), Status> { + let expected = managed_secret_path( + workspace, + provider_id, + provider_name, + credential_key, + object_id, + ); + if logical_path == expected { + return Ok(()); + } + Err(Status::invalid_argument(format!( + "vault credential handle path does not match the managed path for provider credential '{credential_key}'" + ))) +} + +fn requested_object_id<'a>(object_id: &'a str, provider_id: &'a str) -> Result<&'a str, Status> { + let object_id = if object_id.is_empty() { + provider_id + } else { + object_id + }; + if object_id.trim() != object_id || object_id.is_empty() { + return Err(Status::invalid_argument( + "vault credential object_id must not be empty or contain surrounding whitespace", + )); + } + Ok(object_id) +} + +fn object_id_from_handle(handle: &CredentialHandle, provider_id: &str) -> Result { + requested_object_id( + handle + .metadata + .get(OBJECT_ID_METADATA_KEY) + .map_or("", String::as_str), + provider_id, + ) + .map(str::to_string) +} + +async fn read_secret_file(path: &Path, description: &str) -> Result { + let contents = tokio::fs::read_to_string(path).await.map_err(|err| { + Status::unauthenticated(format!( + "failed to read {description} '{}': {err}", + path.display() + )) + })?; + let value = contents.trim().to_string(); + if value.is_empty() { + return Err(Status::unauthenticated(format!( + "{description} '{}' is empty", + path.display() + ))); + } + Ok(value) +} + +fn vault_auth_status(status: StatusCode) -> Status { + match status { + StatusCode::UNAUTHORIZED => { + Status::unauthenticated("Vault Kubernetes auth rejected the service account token") + } + StatusCode::FORBIDDEN => { + Status::permission_denied("Vault Kubernetes auth denied the configured role") + } + other => Status::unavailable(format!("Vault Kubernetes auth returned HTTP {other}")), + } +} + +fn vault_secret_status(status: StatusCode, path: &str) -> Status { + match status { + StatusCode::UNAUTHORIZED => { + Status::unauthenticated("Vault rejected the credential driver token") + } + StatusCode::FORBIDDEN => Status::permission_denied(format!( + "Vault token is not allowed to read secret path '{path}'" + )), + StatusCode::NOT_FOUND => { + Status::not_found(format!("Vault secret path '{path}' was not found")) + } + other => Status::unavailable(format!("Vault secret path '{path}' returned HTTP {other}")), + } +} + +fn extract_secret_value( + body: &serde_json::Value, + reference: &VaultSecretReference, +) -> Result { + let data = body + .get("data") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| { + Status::failed_precondition(format!( + "Vault secret path '{}' response is missing data", + reference.api_path + )) + })?; + let fields = match reference.kv_version { + KvVersion::V1 => data, + KvVersion::V2 => data + .get("data") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| { + Status::failed_precondition(format!( + "Vault KV v2 secret path '{}' response is missing data.data", + reference.api_path + )) + })?, + }; + let value = fields.get(&reference.key).ok_or_else(|| { + Status::not_found(format!( + "Vault secret path '{}' does not contain key '{}'", + reference.api_path, reference.key + )) + })?; + value.as_str().map(str::to_string).ok_or_else(|| { + Status::failed_precondition(format!( + "Vault secret path '{}' key '{}' is not a string", + reference.api_path, reference.key + )) + }) +} + +#[cfg(test)] +mod tests { + use openshell_core::proto::CredentialHandle; + use tonic::Code; + use wiremock::matchers::{body_string_contains, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use super::*; + + fn handle(value: &str) -> CredentialHandle { + CredentialHandle { + driver: "vault".to_string(), + handle: value.to_string(), + metadata: std::collections::HashMap::new(), + } + } + + fn table(values: &[(&str, toml::Value)]) -> toml::Table { + values + .iter() + .map(|(key, value)| ((*key).to_string(), value.clone())) + .collect() + } + + fn token_file(token: &str) -> tempfile::NamedTempFile { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), token).unwrap(); + file + } + + #[test] + fn settings_parse_kubernetes_auth() { + let settings = VaultDriverSettings::from_table(&table(&[ + ( + "address", + toml::Value::String("http://vault:8200".to_string()), + ), + ("mount", toml::Value::String("team-secret".to_string())), + ("kv_version", toml::Value::String("1".to_string())), + ("auth_method", toml::Value::String("kubernetes".to_string())), + ("role", toml::Value::String("openshell-gateway".to_string())), + ])) + .unwrap(); + + assert_eq!(settings.mount, "team-secret"); + assert_eq!(settings.kv_version, KvVersion::V1); + assert!(matches!( + settings.auth, + VaultAuthSettings::Kubernetes { .. } + )); + } + + #[test] + fn settings_parse_token_file_auth() { + let settings = VaultDriverSettings::from_table(&table(&[ + ( + "address", + toml::Value::String("http://vault:8200".to_string()), + ), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String("/run/secrets/vault-token".to_string()), + ), + ])) + .unwrap(); + + assert!(matches!(settings.auth, VaultAuthSettings::TokenFile { .. })); + assert_eq!(settings.kv_version, KvVersion::V2); + } + + #[test] + fn settings_reject_unknown_fields() { + let err = VaultDriverSettings::from_table(&table(&[ + ( + "address", + toml::Value::String("http://vault:8200".to_string()), + ), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String("/run/secrets/vault-token".to_string()), + ), + ("token", toml::Value::String("literal-secret".to_string())), + ])) + .unwrap_err(); + + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn settings_reject_token_file_without_token_path() { + let err = VaultDriverSettings::from_table(&table(&[ + ( + "address", + toml::Value::String("http://vault:8200".to_string()), + ), + ("auth_method", toml::Value::String("token_file".to_string())), + ])) + .unwrap_err(); + + assert!(err.to_string().contains("token_path is required")); + } + + #[test] + fn api_path_builds_kv2_api_path_from_logical_path() { + assert_eq!( + api_path_for_reference( + "secret", + KvVersion::V2, + "openshell/provider-credentials/abc" + ), + "secret/data/openshell/provider-credentials/abc" + ); + } + + #[test] + fn delete_api_path_builds_kv2_metadata_path_from_logical_path() { + assert_eq!( + delete_api_path_for_reference( + "secret", + KvVersion::V2, + "openshell/provider-credentials/abc" + ), + "secret/metadata/openshell/provider-credentials/abc" + ); + } + + #[test] + fn handle_rejects_malformed_value() { + let err = VaultCredentialDriver::logical_path_from_handle(&handle("providers/nvidia")) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("malformed")); + } + + #[test] + fn handle_rejects_invalid_path() { + let err = + VaultCredentialDriver::logical_path_from_handle(&handle("v1:../providers/nvidia")) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("path segments")); + } + + #[test] + fn handle_rejects_unexpected_managed_path() { + let err = validate_managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "prov-123", + "openshell/provider-credentials/other", + ) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("managed path")); + } + + #[test] + fn staged_paths_keep_provider_ownership_and_use_distinct_object_identity() { + let committed = managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "prov-123", + ); + let staged = managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "refresh-456", + ); + + assert_ne!(committed, staged); + validate_managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "refresh-456", + &staged, + ) + .unwrap(); + assert!( + validate_managed_secret_path( + "default", + "other-provider", + "nvidia-prod", + "NVIDIA_API_KEY", + "refresh-456", + &staged, + ) + .is_err() + ); + } + + #[tokio::test] + async fn store_and_resolve_token_file_kv2_secret() { + let mock_server = MockServer::start().await; + let logical_path = managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "prov-123", + ); + let api_path = format!("/v1/secret/data/{logical_path}"); + Mock::given(method("POST")) + .and(path(api_path.as_str())) + .and(header("x-vault-token", "dev-token")) + .and(body_string_contains("nvapi-test")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path(api_path.as_str())) + .and(header("x-vault-token", "dev-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "data": { + "value": "nvapi-test" + }, + "metadata": { + "version": 1 + } + } + }))) + .mount(&mock_server) + .await; + let token_file = token_file("dev-token\n"); + let driver = VaultCredentialDriver::from_config(&table(&[ + ("address", toml::Value::String(mock_server.uri())), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String(token_file.path().display().to_string()), + ), + ])) + .unwrap(); + + let stored = driver + .store_credential(StoreCredentialRequest { + provider_name: "nvidia-prod".to_string(), + credential_key: "NVIDIA_API_KEY".to_string(), + value: "nvapi-test".to_string(), + existing_handle: None, + workspace: "default".to_string(), + provider_id: "prov-123".to_string(), + object_id: "prov-123".to_string(), + }) + .await + .unwrap(); + assert_eq!(stored.handle, format!("v1:{logical_path}")); + + let resolved = driver + .resolve_credentials(vec![ResolveCredentialRequest { + request_id: "credential-0".to_string(), + provider_name: "nvidia-prod".to_string(), + credential_key: "NVIDIA_API_KEY".to_string(), + handle: Some(stored), + workspace: "default".to_string(), + provider_id: "prov-123".to_string(), + }]) + .await + .unwrap(); + + assert_eq!(resolved[0].value, "nvapi-test"); + } + + #[tokio::test] + async fn store_with_existing_handle_reuses_logical_path() { + let mock_server = MockServer::start().await; + let logical_path = managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "prov-123", + ); + Mock::given(method("POST")) + .and(path(format!("/v1/secret/data/{logical_path}"))) + .and(header("x-vault-token", "dev-token")) + .and(body_string_contains("updated-secret")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + let token_file = token_file("dev-token\n"); + let driver = VaultCredentialDriver::from_config(&table(&[ + ("address", toml::Value::String(mock_server.uri())), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String(token_file.path().display().to_string()), + ), + ])) + .unwrap(); + + let stored = driver + .store_credential(StoreCredentialRequest { + provider_name: "nvidia-prod".to_string(), + credential_key: "NVIDIA_API_KEY".to_string(), + value: "updated-secret".to_string(), + existing_handle: Some(handle(&format!("v1:{logical_path}"))), + workspace: "default".to_string(), + provider_id: "prov-123".to_string(), + object_id: "prov-123".to_string(), + }) + .await + .unwrap(); + + assert_eq!(stored.handle, format!("v1:{logical_path}")); + } + + #[tokio::test] + async fn delete_token_file_kv2_secret() { + let mock_server = MockServer::start().await; + let logical_path = managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "prov-123", + ); + Mock::given(method("DELETE")) + .and(path(format!("/v1/secret/metadata/{logical_path}"))) + .and(header("x-vault-token", "dev-token")) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + let token_file = token_file("dev-token\n"); + let driver = VaultCredentialDriver::from_config(&table(&[ + ("address", toml::Value::String(mock_server.uri())), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String(token_file.path().display().to_string()), + ), + ])) + .unwrap(); + + driver + .delete_credential(DeleteCredentialRequest { + provider_name: "nvidia-prod".to_string(), + credential_key: "NVIDIA_API_KEY".to_string(), + handle: Some(handle(&format!("v1:{logical_path}"))), + workspace: "default".to_string(), + provider_id: "prov-123".to_string(), + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn resolve_kubernetes_auth_kv2_secret() { + let mock_server = MockServer::start().await; + let logical_path = managed_secret_path( + "test-workspace", + "test-provider-id", + "github-prod", + "GITHUB_TOKEN", + "test-provider-id", + ); + Mock::given(method("POST")) + .and(path("/v1/auth/kubernetes/login")) + .and(body_string_contains("openshell-gateway")) + .and(body_string_contains("jwt-test")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "auth": { + "client_token": "bao-token" + } + }))) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v1/secret/data/{logical_path}"))) + .and(header("x-vault-token", "bao-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "data": { + "value": "ghp-test" + } + } + }))) + .mount(&mock_server) + .await; + let jwt_file = token_file("jwt-test\n"); + let driver = VaultCredentialDriver::from_config(&table(&[ + ("address", toml::Value::String(mock_server.uri())), + ("auth_method", toml::Value::String("kubernetes".to_string())), + ("role", toml::Value::String("openshell-gateway".to_string())), + ( + "service_account_token_path", + toml::Value::String(jwt_file.path().display().to_string()), + ), + ])) + .unwrap(); + + let resolved = driver + .resolve_credentials(vec![ResolveCredentialRequest { + request_id: "credential-0".to_string(), + provider_name: "github-prod".to_string(), + credential_key: "GITHUB_TOKEN".to_string(), + handle: Some(handle(&format!("v1:{logical_path}"))), + workspace: "test-workspace".to_string(), + provider_id: "test-provider-id".to_string(), + }]) + .await + .unwrap(); + + assert_eq!(resolved[0].value, "ghp-test"); + } + + #[tokio::test] + async fn resolve_maps_missing_key() { + let mock_server = MockServer::start().await; + let logical_path = managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "prov-123", + ); + Mock::given(method("GET")) + .and(path(format!("/v1/secret/data/{logical_path}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "data": {} + } + }))) + .mount(&mock_server) + .await; + let token_file = token_file("dev-token\n"); + let driver = VaultCredentialDriver::from_config(&table(&[ + ("address", toml::Value::String(mock_server.uri())), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String(token_file.path().display().to_string()), + ), + ])) + .unwrap(); + + let err = driver + .resolve_credentials(vec![ResolveCredentialRequest { + request_id: "credential-0".to_string(), + provider_name: "nvidia-prod".to_string(), + credential_key: "NVIDIA_API_KEY".to_string(), + handle: Some(handle(&format!("v1:{logical_path}"))), + workspace: "default".to_string(), + provider_id: "prov-123".to_string(), + }]) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::NotFound); + assert!(err.message().contains("does not contain key")); + } + + #[tokio::test] + async fn resolve_maps_permission_denied() { + let mock_server = MockServer::start().await; + let logical_path = managed_secret_path( + "default", + "prov-123", + "nvidia-prod", + "NVIDIA_API_KEY", + "prov-123", + ); + Mock::given(method("GET")) + .and(path(format!("/v1/secret/data/{logical_path}"))) + .respond_with(ResponseTemplate::new(403)) + .mount(&mock_server) + .await; + let token_file = token_file("dev-token\n"); + let driver = VaultCredentialDriver::from_config(&table(&[ + ("address", toml::Value::String(mock_server.uri())), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String(token_file.path().display().to_string()), + ), + ])) + .unwrap(); + + let err = driver + .resolve_credentials(vec![ResolveCredentialRequest { + request_id: "credential-0".to_string(), + provider_name: "nvidia-prod".to_string(), + credential_key: "NVIDIA_API_KEY".to_string(), + handle: Some(handle(&format!("v1:{logical_path}"))), + workspace: "default".to_string(), + provider_id: "prov-123".to_string(), + }]) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::PermissionDenied); + } +} diff --git a/crates/openshell-driver-vault/src/main.rs b/crates/openshell-driver-vault/src/main.rs new file mode 100644 index 0000000000..1b8265ef83 --- /dev/null +++ b/crates/openshell-driver-vault/src/main.rs @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use clap::Parser; +use futures::Stream; +use miette::{IntoDiagnostic, Result, miette}; +use openshell_core::VERSION; +use openshell_core::proto::credentials::v1::credential_driver_server::CredentialDriverServer; +use openshell_driver_vault::{CredentialDriverService, VaultCredentialDriver}; +use tokio::net::{UnixListener, UnixStream}; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[derive(Parser, Debug)] +#[command(name = "openshell-driver-vault")] +#[command(version = VERSION)] +struct Args { + #[arg(long, env = "OPENSHELL_CREDENTIAL_DRIVER_SOCKET")] + bind_socket: PathBuf, + + #[arg(long, env = "OPENSHELL_LOG_LEVEL", default_value = "info")] + log_level: String, + + #[arg(long, env = "OPENSHELL_VAULT_ADDRESS")] + address: Option, + + #[arg(long, env = "OPENSHELL_VAULT_MOUNT")] + mount: Option, + + #[arg(long, env = "OPENSHELL_VAULT_KV_VERSION")] + kv_version: Option, + + #[arg(long, env = "OPENSHELL_VAULT_AUTH_METHOD")] + auth_method: Option, + + #[arg(long, env = "OPENSHELL_VAULT_ROLE")] + role: Option, + + #[arg(long, env = "OPENSHELL_VAULT_KUBERNETES_AUTH_MOUNT")] + kubernetes_auth_mount: Option, + + #[arg(long, env = "OPENSHELL_VAULT_SERVICE_ACCOUNT_TOKEN_PATH")] + service_account_token_path: Option, + + #[arg(long, env = "OPENSHELL_VAULT_TOKEN_PATH")] + token_path: Option, + + #[arg(long, env = "OPENSHELL_VAULT_TIMEOUT_SECS")] + timeout_secs: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), + ) + .init(); + + let driver = VaultCredentialDriver::from_config(&driver_config(&args)).into_diagnostic()?; + + prepare_socket(&args.bind_socket)?; + let listener = UnixListener::bind(&args.bind_socket).into_diagnostic()?; + restrict_socket_permissions(&args.bind_socket)?; + + info!(socket = %args.bind_socket.display(), "Starting Vault credential driver"); + let result = tonic::transport::Server::builder() + .add_service(CredentialDriverServer::new(CredentialDriverService::new( + driver, + ))) + .serve_with_incoming(UnixIncoming::new(listener)) + .await + .into_diagnostic(); + let _ = std::fs::remove_file(&args.bind_socket); + result +} + +fn driver_config(args: &Args) -> toml::Table { + let mut config = toml::Table::new(); + insert_string(&mut config, "address", args.address.as_ref()); + insert_string(&mut config, "mount", args.mount.as_ref()); + insert_string(&mut config, "kv_version", args.kv_version.as_ref()); + insert_string(&mut config, "auth_method", args.auth_method.as_ref()); + insert_string(&mut config, "role", args.role.as_ref()); + insert_string( + &mut config, + "kubernetes_auth_mount", + args.kubernetes_auth_mount.as_ref(), + ); + insert_path( + &mut config, + "service_account_token_path", + args.service_account_token_path.as_ref(), + ); + insert_path(&mut config, "token_path", args.token_path.as_ref()); + if let Some(timeout_secs) = args.timeout_secs { + config.insert( + "timeout_secs".to_string(), + toml::Value::Integer(i64::try_from(timeout_secs).unwrap_or(i64::MAX)), + ); + } + config +} + +fn insert_string(config: &mut toml::Table, key: &str, value: Option<&String>) { + if let Some(value) = value { + config.insert(key.to_string(), toml::Value::String(value.clone())); + } +} + +fn insert_path(config: &mut toml::Table, key: &str, value: Option<&PathBuf>) { + if let Some(value) = value { + config.insert( + key.to_string(), + toml::Value::String(value.display().to_string()), + ); + } +} + +fn prepare_socket(socket_path: &Path) -> Result<()> { + let parent = socket_path.parent().ok_or_else(|| { + miette!( + "credential driver socket path '{}' has no parent directory", + socket_path.display() + ) + })?; + std::fs::create_dir_all(parent).into_diagnostic()?; + + match std::fs::symlink_metadata(socket_path) { + Ok(metadata) if metadata.file_type().is_socket() => { + std::fs::remove_file(socket_path).into_diagnostic()?; + } + Ok(_) => { + return Err(miette!( + "credential driver socket path '{}' exists but is not a Unix socket", + socket_path.display() + )); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err).into_diagnostic(), + } + Ok(()) +} + +fn restrict_socket_permissions(socket_path: &Path) -> Result<()> { + let mut permissions = std::fs::metadata(socket_path) + .into_diagnostic()? + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(socket_path, permissions).into_diagnostic() +} + +struct UnixIncoming { + listener: UnixListener, +} + +impl UnixIncoming { + fn new(listener: UnixListener) -> Self { + Self { listener } + } +} + +impl Stream for UnixIncoming { + type Item = io::Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut().listener.poll_accept(cx) { + Poll::Ready(Ok((stream, _addr))) => Poll::Ready(Some(Ok(stream))), + Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))), + Poll::Pending => Poll::Pending, + } + } +} diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index 0ee2740c77..0d250b6e71 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -22,6 +22,7 @@ path = "src/main.rs" openshell-core = { path = "../openshell-core", default-features = false } openshell-otel = { path = "../openshell-otel" } openshell-policy = { path = "../openshell-policy" } +openshell-driver-podman = { path = "../openshell-driver-podman" } openshell-vfio = { path = "../openshell-vfio" } bollard = { version = "0.20", features = ["ssh"] } @@ -59,12 +60,21 @@ default = ["telemetry"] ## default; build with `--no-default-features` for a telemetry-free VM driver ## that reports telemetry disabled to the sandboxes it launches. telemetry = ["openshell-core/telemetry"] +## Convenience alias: every default feature except `telemetry`. Build a +## telemetry-free VM driver with +## `--no-default-features --features defaults-without-telemetry` and stay +## correct as new default features are added. Cargo cannot subtract a single +## default feature, so this alias must be paired with `--no-default-features`; +## enabling it alongside `telemetry` is a compile error rather than a silent +## telemetry-on build. Kept in sync with `default` by +## `rust:verify:defaults-without-telemetry`. +defaults-without-telemetry = [] [dev-dependencies] +openshell-otel-test-support = { path = "../openshell-otel-test-support" } temp-env = "0.3" tempfile = "3" opentelemetry_sdk = { workspace = true, features = ["testing"] } -opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic", "trace"] } # smol-rs/polling drives the BSD/macOS parent-death detection in # procguard via kqueue's EVFILT_PROC / NOTE_EXIT filter. We could use diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 23f75227a7..5c61ae1823 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -9,7 +9,7 @@ Standalone libkrun-backed [`ComputeDriver`](../../proto/compute_driver.proto) fo ```mermaid flowchart LR subgraph host["Host process"] - gateway["openshell-server
(compute::vm::spawn)"] + gateway["openshell-gateway
(vm::spawn)"] driver["openshell-driver-vm
├── libkrun (VM)
├── gvproxy (net)
└── openshell-sandbox.zst"] gateway <-->|"gRPC over UDS
compute-driver.sock"| driver end @@ -40,10 +40,14 @@ First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw By default `mise run gateway:vm`: - Listens on plaintext HTTP at `127.0.0.1:18081`. -- Registers the CLI gateway `vm-dev` by writing `~/.config/openshell/gateways/vm-dev/metadata.json`. It does not modify the workspace `.env`. +- Configures the gateway installation name as `vm-dev` and registers the same + name with the CLI by writing + `~/.config/openshell/gateways/vm-dev/metadata.json`. It does not modify the + workspace `.env`. - Persists the gateway SQLite DB under `.cache/gateway-vm/gateway.db`. - Places the VM driver state (per-sandbox `overlay.ext4`, image cache, and `run/compute-driver.sock`) under `/tmp/openshell-vm-driver-$USER-vm-dev/` so the AF_UNIX socket path stays under macOS `SUN_LEN`. - Writes `.cache/gateway-vm/gateway.toml` with `[openshell.drivers.vm].driver_dir = "$PWD/target/debug"` so the freshly built `openshell-driver-vm` is used instead of an older installed copy from `~/.local/libexec/openshell`, `/usr/libexec/openshell`, or `/usr/local/libexec`. +- Enables OTLP trace export to `http://127.0.0.1:4317` only when a local collector is listening there. Otherwise, it omits the OTLP configuration to avoid repeated export failures. For GPU passthrough (VFIO), pass `-- --gpu` and run with root privileges: @@ -68,7 +72,7 @@ Override defaults via environment: # custom port (fails fast if in use) OPENSHELL_SERVER_PORT=18091 mise run gateway:vm -# custom CLI gateway name + namespace +# custom gateway installation/CLI name + namespace OPENSHELL_VM_GATEWAY_NAME=vm-feature-a \ OPENSHELL_SANDBOX_NAMESPACE=vm-feature-a \ mise run gateway:vm @@ -98,7 +102,7 @@ mise run vm:supervisor # if openshell-sandbox.zst is not already presen # 2. Build both binaries with the staged artifacts embedded OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed \ - cargo build -p openshell-server -p openshell-driver-vm + cargo build -p openshell-gateway -p openshell-driver-vm # 3. macOS only: codesign the driver for Hypervisor.framework codesign \ @@ -150,6 +154,14 @@ Select the VM driver with `--drivers vm`, `OPENSHELL_DRIVERS=vm`, or `compute_dr | `guest_tls_ca` | unset | CA cert for the guest's mTLS client bundle. Required when `grpc_endpoint` uses `https://`. | | `guest_tls_cert` | unset | Guest client certificate. | | `guest_tls_key` | unset | Guest client private key. | +| `https_proxy` | unset | Corporate forward proxy (`http://host:port` or `https://host:port`) the in-guest supervisor chains policy-approved TLS CONNECT egress through. On the libkrun backend a proxy on the gateway host's loopback must be addressed as `http://host.openshell.internal:` — guest egress leaves through gvproxy, which NATs `192.168.127.254` to the host's `127.0.0.1`. The QEMU/TAP backend (GPU sandboxes) has no such NAT and its nftables rules expose only the gateway port to the guest, so a gateway-host proxy URL is rejected at launch there; use an address routable from the guest's masqueraded egress. | +| `no_proxy` | unset | Comma-separated bypass list for the corporate proxy only. OpenShell policy evaluation still applies. | +| `proxy_auth_file` | unset | Gateway-host path to a `user:pass` credential file. Staged root-only into the per-sandbox overlay and removed with the sandbox. | +| `proxy_auth_allow_insecure` | unset | Required with `proxy_auth_file` against an `http://` proxy: acknowledges that Basic auth is cleartext on the connection to the proxy. | +| `proxy_connect_by_hostname` | unset | Send hostnames rather than validated IPs in CONNECT. Last resort for proxies whose ACLs reject IP targets. | +| `proxy_ca_bundle` | unset | Gateway-host path to a PEM CA bundle trusted for an `https://` proxy and for certificates a TLS-intercepting proxy re-signs. | + +The proxy settings are operator-owned and deployment-level: they are not accepted through `template.driver_config.vm`, and they reach the supervisor on its command line through a per-sandbox argument file the driver writes into the overlay upperdir on every launch, so a sandbox image cannot forge or shadow them. Every present-but-invalid value is fatal at gateway or sandbox startup rather than degrading to a direct dial. See [`openshell-gateway --help`](../openshell-server/src/cli.rs) for the gateway process flag surface. @@ -194,10 +206,22 @@ during the first prepare. The driver also writes the accepted `DriverSandbox` launch request to `/sandboxes//sandbox.pb`. If the gateway restarts, it starts a -new VM driver process; that process scans the sandbox state directories, -restarts each persisted VM launcher, and preserves any existing `overlay.ext4` -instead of cloning a fresh overlay template. If a restart happened before the -overlay was created, the driver creates it during the resume attempt. +new VM driver process. During graceful shutdown, the gateway first sends the +shared `StopSandbox` request for each persisted running-intent sandbox, which +stops its launcher while retaining the launch request and `overlay.ext4`. +After driver initialization, the gateway sends the idempotent `StartSandbox` +request for that retained intent. Explicitly stopped sandboxes remain excluded. + +Stop writes a marker in the sandbox state directory before terminating +the launcher and releasing host GPU and network allocations. It retains +`sandbox.pb`, `overlay.ext4`, and lifecycle-extension state. Startup registers +marked sandboxes without launching compute. Start removes the marker and uses +the normal persisted restore path with the existing overlay. Delete removes the +entire sandbox state directory, including a stop marker and overlay. + +The driver records a terminal tombstone when the canonical main process exits. +Driver startup reports that sandbox as terminal instead of relaunching the VM, +even when the process exited successfully. ## Logs and debugging @@ -275,5 +299,5 @@ the user explicitly overrides it. ## TODOs -- The gateway still configures the driver via CLI args; this will move to a gRPC bootstrap call so the driver interface is uniform across backends. See the `TODO(driver-abstraction)` notes in `crates/openshell-server/src/lib.rs` and `crates/openshell-server/src/compute/vm.rs`. +- The gateway still configures the driver via CLI args; this will move to a gRPC bootstrap call so the driver interface is uniform across backends. See the `TODO(driver-abstraction)` note in `crates/openshell-gateway/src/vm.rs`. - macOS local builds are codesigned by `tasks/scripts/gateway-vm.sh`; the generated Homebrew formula signs the release tarball driver for local installs. diff --git a/crates/openshell-driver-vm/build.rs b/crates/openshell-driver-vm/build.rs index 1763590545..92532ed7b2 100644 --- a/crates/openshell-driver-vm/build.rs +++ b/crates/openshell-driver-vm/build.rs @@ -62,24 +62,11 @@ fn main() { return; }; - if !compressed_dir.is_dir() { - println!( - "cargo:warning=Compressed runtime dir not found: {}", - compressed_dir.display() - ); - println!("cargo:warning=Run: mise run vm:setup && mise run vm:supervisor"); - generate_stub_resources( - &out_dir, - &[ - &format!("{libkrun_name}.zst"), - &format!("{libkrunfw_name}.zst"), - "gvproxy.zst", - "openshell-sandbox.zst", - "umoci.zst", - ], - ); - return; - } + assert!( + compressed_dir.is_dir(), + "Compressed runtime dir not found: {}. Run: mise run vm:setup && mise run vm:supervisor", + compressed_dir.display() + ); let files = [ (format!("{libkrun_name}.zst"), format!("{libkrun_name}.zst")), @@ -95,20 +82,30 @@ fn main() { ("umoci.zst".to_string(), "umoci.zst".to_string()), ]; - let mut all_found = true; + for (src_name, _) in &files { + let src_path = compressed_dir.join(src_name); + let metadata = fs::metadata(&src_path).unwrap_or_else(|e| { + panic!( + "Required compressed artifact unavailable: {}: {e}", + src_path.display() + ) + }); + assert!( + metadata.is_file(), + "Required compressed artifact is not a file: {}", + src_path.display() + ); + assert!( + metadata.len() != 0, + "Required compressed artifact is empty: {}", + src_path.display() + ); + } + for (src_name, dst_name) in &files { let src_path = compressed_dir.join(src_name); let dst_path = out_dir.join(dst_name); - if !src_path.exists() { - println!( - "cargo:warning=Missing compressed artifact: {}", - src_path.display() - ); - all_found = false; - continue; - } - if dst_path.exists() { let _ = fs::remove_file(&dst_path); } @@ -123,22 +120,6 @@ fn main() { let size = fs::metadata(&dst_path).map_or(0, |m| m.len()); println!("cargo:warning=Embedded {src_name}: {size} bytes"); } - - if !all_found { - println!( - "cargo:warning=Some artifacts missing. Run: mise run vm:setup && mise run vm:supervisor" - ); - generate_stub_resources( - &out_dir, - &[ - &format!("{libkrun_name}.zst"), - &format!("{libkrunfw_name}.zst"), - "gvproxy.zst", - "openshell-sandbox.zst", - "umoci.zst", - ], - ); - } } fn generate_stub_resources(out_dir: &Path, names: &[&str]) { diff --git a/crates/openshell-driver-vm/runtime/README.md b/crates/openshell-driver-vm/runtime/README.md index 11aab67f43..b686874ba2 100644 --- a/crates/openshell-driver-vm/runtime/README.md +++ b/crates/openshell-driver-vm/runtime/README.md @@ -41,7 +41,7 @@ mise run vm:supervisor # Build the gateway and VM driver with embedded runtime artifacts OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed \ - cargo build -p openshell-server -p openshell-driver-vm + cargo build -p openshell-gateway -p openshell-driver-vm ``` Use `FROM_SOURCE=1 mise run vm:setup` to build the runtime from source instead diff --git a/crates/openshell-driver-vm/runtime/pins.env b/crates/openshell-driver-vm/runtime/pins.env index d05c66e839..34a9f0bf33 100644 --- a/crates/openshell-driver-vm/runtime/pins.env +++ b/crates/openshell-driver-vm/runtime/pins.env @@ -31,7 +31,7 @@ COMMUNITY_SANDBOX_IMAGE="${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-com # ── gvproxy (networking proxy) ────────────────────────────────────────── # Repo: https://github.com/containers/gvisor-tap-vsock -GVPROXY_VERSION="${GVPROXY_VERSION:-v0.8.8}" +GVPROXY_VERSION="${GVPROXY_VERSION:-v0.8.9}" # ── umoci (guest OCI unpacker) ────────────────────────────────────────── # Repo: https://github.com/opencontainers/umoci diff --git a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh index 14dbc0466b..32d6ed1dff 100644 --- a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh +++ b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh @@ -192,6 +192,67 @@ prepare_guest_image_rootfs() { rm -rf "$payload_dir" } +# Driver-owned arguments appended to the supervisor's command line. +# +# The VM driver cannot build the supervisor's argv the way the container +# drivers do, so it writes the arguments it chose into the overlay upperdir +# and this script appends them verbatim. Populated by +# read_supervisor_extra_args; empty until then. +SUPERVISOR_EXTRA_ARGS=() + +# Upper bound on driver-supplied supervisor arguments. +# +# The corporate proxy settings are the only producer today and top out at ten +# entries. The cap exists so a corrupt or oversized file cannot expand into an +# unbounded command line. +SUPERVISOR_EXTRA_ARGS_MAX=32 + +read_supervisor_extra_args() { + # Read the driver-authored supervisor argument list, one argument per + # line, verbatim -- no word splitting, globbing, or expansion, so values + # containing spaces (e.g. a NO_PROXY list) survive intact. + # + # Security: this is the operator-owned egress boundary. The driver writes + # this file into the overlay upperdir on every launch, including an empty + # file when it has no arguments to pass, so the upperdir copy always + # shadows the read-only image layer. A sandbox image can therefore neither + # supply its own supervisor arguments by baking a file at this path nor + # disable the operator's by omitting one. A missing file means the driver + # passed nothing; a file it cannot read means the overlay is broken, and + # we fail closed rather than start a supervisor with a silently truncated + # egress configuration. + local args_file + args_file="$(root_path /opt/openshell/supervisor-args)" + + SUPERVISOR_EXTRA_ARGS=() + if [ ! -f "$args_file" ]; then + return 0 + fi + if [ ! -r "$args_file" ]; then + ts "FATAL: supervisor argument list ${args_file} is not readable" + exit 1 + fi + + local arg + while IFS= read -r arg; do + # render_guest_supervisor_args never emits a blank line, so one means + # the file was truncated or tampered with after the driver wrote it. + if [ -z "$arg" ]; then + ts "FATAL: empty entry in supervisor argument list" + exit 1 + fi + if [ "${#SUPERVISOR_EXTRA_ARGS[@]}" -ge "$SUPERVISOR_EXTRA_ARGS_MAX" ]; then + ts "FATAL: supervisor argument list exceeds ${SUPERVISOR_EXTRA_ARGS_MAX} entries" + exit 1 + fi + SUPERVISOR_EXTRA_ARGS+=("$arg") + done < "$args_file" + + if [ "${#SUPERVISOR_EXTRA_ARGS[@]}" -gt 0 ]; then + ts "supervisor arguments from driver: ${#SUPERVISOR_EXTRA_ARGS[@]} entries" + fi +} + exec_supervisor_in_newroot() { local chroot_bin local bootstrap="/.openshell-bootstrap" @@ -214,14 +275,16 @@ exec_supervisor_in_newroot() { "${bootstrap}/lib64/ld-linux-aarch64.so.1"; do if [ -x "/newroot${loader}" ]; then lib_path="${bootstrap}/lib:${bootstrap}/lib64:${bootstrap}/usr/lib:${bootstrap}/usr/lib64:${bootstrap}/lib/aarch64-linux-gnu:${bootstrap}/lib/x86_64-linux-gnu:${bootstrap}/usr/lib/aarch64-linux-gnu:${bootstrap}/usr/lib/x86_64-linux-gnu" - exec "$chroot_bin" /newroot "$loader" --library-path "$lib_path" "$supervisor" --workdir /sandbox + exec "$chroot_bin" /newroot "$loader" --library-path "$lib_path" \ + "$supervisor" --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" fi done - exec "$chroot_bin" /newroot "$supervisor" --workdir /sandbox + exec "$chroot_bin" /newroot "$supervisor" --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" fi if [ -x /newroot/opt/openshell/bin/openshell-sandbox ]; then - exec "$chroot_bin" /newroot /opt/openshell/bin/openshell-sandbox --workdir /sandbox + exec "$chroot_bin" /newroot /opt/openshell/bin/openshell-sandbox \ + --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" fi done @@ -833,11 +896,13 @@ if [ -n "${OPENSHELL_SANDBOX_ID:-}" ]; then ts "OPENSHELL_SANDBOX_ID=${OPENSHELL_SANDBOX_ID}" fi +read_supervisor_extra_args + ts "starting openshell-sandbox supervisor" if [ "${ROOT_PREFIX:-}" = "/newroot" ]; then exec_supervisor_in_newroot fi -exec /opt/openshell/bin/openshell-sandbox --workdir /sandbox +exec /opt/openshell/bin/openshell-sandbox --workdir /sandbox "${SUPERVISOR_EXTRA_ARGS[@]+"${SUPERVISOR_EXTRA_ARGS[@]}"}" } if [ "${1:-}" != "--post-overlay" ]; then diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 1099442f77..af55998a2a 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -24,6 +24,7 @@ use nix::errno::Errno; use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use oci_client::client::{Client as OciClient, ClientConfig}; +use oci_client::errors::{OciDistributionError, OciErrorCode}; use oci_client::manifest::{ ImageIndexEntry, OCI_IMAGE_MEDIA_TYPE, OciDescriptor, OciImageManifest, }; @@ -38,11 +39,13 @@ use openshell_core::progress::{ }; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DriverCondition as SandboxCondition, DriverPlatformEvent as PlatformEvent, - DriverSandbox as Sandbox, DriverSandboxStatus as SandboxStatus, - DriverSandboxTemplate as SandboxTemplate, GetCapabilitiesRequest, GetCapabilitiesResponse, - GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, - GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition as SandboxCondition, + DriverPlatformEvent as PlatformEvent, DriverSandbox as Sandbox, + DriverSandboxStatus as SandboxStatus, DriverSandboxTemplate as SandboxTemplate, + EnsureWorkspaceRequest, EnsureWorkspaceResponse, GetCapabilitiesRequest, + GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, + GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, + ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent, @@ -57,8 +60,9 @@ use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fs; +use std::future::Future; use std::io::Read; -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Component, Path, PathBuf}; @@ -84,6 +88,9 @@ const DEFAULT_MEM_MIB: u32 = 2048; const DEFAULT_OVERLAY_DISK_MIB: u64 = 4096; const DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY: usize = 4; const MAX_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY: usize = 16; +const REGISTRY_REQUEST_MAX_ATTEMPTS: usize = 4; +const REGISTRY_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(250); +const REGISTRY_RETRY_MAX_DELAY: Duration = Duration::from_secs(1); #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(default, deny_unknown_fields)] @@ -130,7 +137,7 @@ impl VmSandboxDriverConfig { /// Code paths route via `GVPROXY_HOST_LOOPBACK_ALIAS` (DNS / /etc/hosts) /// instead so logs stay readable; this constant is kept for documentation /// and parity with the guest init script. -#[allow(dead_code)] +#[allow(dead_code)] // Documentation/parity anchor; all routing goes via the alias. const GVPROXY_HOST_LOOPBACK_IP: &str = "192.168.127.254"; const OPENSHELL_HOST_GATEWAY_ALIAS: &str = "host.openshell.internal"; /// Hostname gvproxy resolves (via its embedded DNS) to the host-loopback IP. @@ -145,12 +152,12 @@ const OPENSHELL_HOST_GATEWAY_ALIAS: &str = "host.openshell.internal"; /// Both names ultimately route through the gvproxy NAT path on /// `GVPROXY_HOST_LOOPBACK_IP` — they do **not** go through the gateway IP. const GVPROXY_HOST_LOOPBACK_ALIAS: &str = OPENSHELL_HOST_GATEWAY_ALIAS; -const GUEST_SSH_SOCKET_PATH: &str = "/run/openshell/ssh.sock"; -const GUEST_TLS_CA_PATH: &str = "/opt/openshell/tls/ca.crt"; -const GUEST_TLS_CERT_PATH: &str = "/opt/openshell/tls/tls.crt"; -const GUEST_TLS_KEY_PATH: &str = "/opt/openshell/tls/tls.key"; -const GUEST_SANDBOX_TOKEN_PATH: &str = "/opt/openshell/auth/sandbox.jwt"; -const GUEST_INIT_DROPIN_DIR: &str = "/opt/openshell/init.d"; +const GUEST_SSH_SOCKET_PATH: &str = openshell_core::container_paths::SSH_SOCKET_PATH; +const GUEST_TLS_CA_PATH: &str = openshell_core::container_paths::VM_GUEST_TLS_CA_PATH; +const GUEST_TLS_CERT_PATH: &str = openshell_core::container_paths::VM_GUEST_TLS_CERT_PATH; +const GUEST_TLS_KEY_PATH: &str = openshell_core::container_paths::VM_GUEST_TLS_KEY_PATH; +const GUEST_SANDBOX_TOKEN_PATH: &str = openshell_core::container_paths::VM_GUEST_SANDBOX_TOKEN_PATH; +const GUEST_INIT_DROPIN_DIR: &str = openshell_core::container_paths::VM_GUEST_INIT_DROPIN_DIR; /// Guest path of the driver-authored manifest enumerating which /// `init.d` drop-ins the guest init script is allowed to execute. /// @@ -158,13 +165,31 @@ const GUEST_INIT_DROPIN_DIR: &str = "/opt/openshell/init.d"; /// else found under `init.d` — e.g. files baked into a user-controlled /// guest image — is ignored. The driver writes this file into the overlay /// upperdir on every launch, so the image cannot forge or shadow it. -const GUEST_INIT_DROPIN_MANIFEST: &str = "/opt/openshell/init.d.manifest"; +const GUEST_INIT_DROPIN_MANIFEST: &str = + openshell_core::container_paths::VM_GUEST_INIT_DROPIN_MANIFEST; +/// Guest path of the root-only corporate proxy credential staged by the driver. +const GUEST_UPSTREAM_PROXY_AUTH_PATH: &str = + openshell_core::container_paths::VM_GUEST_UPSTREAM_PROXY_AUTH_PATH; +/// Guest path of the corporate proxy CA bundle staged by the driver. +const GUEST_PROXY_CA_PATH: &str = openshell_core::container_paths::VM_GUEST_PROXY_CA_PATH; +/// Guest path of the driver-authored supervisor argument list. +/// +/// The counterpart of [`GUEST_INIT_DROPIN_MANIFEST`] for the supervisor's own +/// command line: written into the overlay upperdir on every launch (empty +/// when there is nothing to pass) so the guest appends exactly the arguments +/// the driver chose and a sandbox image cannot forge or shadow them. +const GUEST_SUPERVISOR_ARGS_PATH: &str = + openshell_core::container_paths::VM_GUEST_SUPERVISOR_ARGS_PATH; const IMAGE_CACHE_ROOT_DIR: &str = "images"; const IMAGE_CACHE_ROOTFS_IMAGE: &str = "rootfs.ext4"; const OVERLAY_TEMPLATE_CACHE_DIR: &str = "overlay-templates"; const OVERLAY_TEMPLATE_CACHE_LAYOUT_VERSION: &str = "sandbox-overlay-ext4-v1"; const SANDBOX_OVERLAY_IMAGE: &str = "overlay.ext4"; const SANDBOX_REQUEST_FILE: &str = "sandbox.pb"; +const SANDBOX_STOPPED_FILE: &str = "stopped"; +/// Durable tombstone preventing driver restart from relaunching a sandbox +/// whose canonical main process already terminated. +const MAIN_PROCESS_EXITED_FILE: &str = "main-process-exited"; const GUEST_IMAGE_CONFIG_DIR: &str = "openshell-image"; const GUEST_IMAGE_OCI_LAYOUT_DIR: &str = "oci"; const GUEST_IMAGE_OCI_REF: &str = "openshell"; @@ -210,7 +235,7 @@ enum GuestImagePayloadSource { LocalDocker { rootfs_archive: PathBuf }, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct VmDriverConfig { pub openshell_endpoint: String, pub state_dir: PathBuf, @@ -236,6 +261,104 @@ pub struct VmDriverConfig { /// When empty, defaults to the resolved UID. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_gid: Option, + + /// Corporate forward proxy URL (`http://host:port` or `https://host:port`) + /// passed to the in-guest supervisor. + /// + /// The supervisor chains policy-approved TLS tunnels through this proxy + /// with HTTP CONNECT instead of dialing destinations directly. This is an + /// operator-owned egress boundary: it travels on the supervisor's argv, + /// which sandbox spec/template environment and image `ENV` cannot + /// influence. A proxy on the gateway host's loopback is reachable from the + /// guest only through the gvproxy host alias + /// (`host.openshell.internal`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub https_proxy: Option, + + /// Comma-separated `NO_PROXY` list passed alongside the proxy URL. + /// + /// Matching destinations are dialed directly instead of through the + /// corporate proxy. This bypasses only the corporate proxy, never + /// `OpenShell` policy evaluation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_proxy: Option, + + /// Path (on the gateway host) to a file containing the corporate proxy + /// credential in `user:pass` form. + /// + /// The driver validates it at sandbox-create time and stages it into the + /// per-sandbox overlay at [`GUEST_UPSTREAM_PROXY_AUTH_PATH`], root-only. + /// Credentials are never embedded in the proxy URL and never reach the + /// guest environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_auth_file: Option, + + /// Explicit acknowledgement that proxy credentials are sent in cleartext. + /// + /// `Proxy-Authorization: Basic` over the plain-TCP connection to an + /// `http://` proxy is recoverable by anyone on the network path, so + /// [`Self::proxy_auth_file`] requires this acknowledgement. An `https://` + /// proxy carries the credential inside the verified TLS session and does + /// not need it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_auth_allow_insecure: Option, + + /// Send the destination hostname in CONNECT requests instead of a + /// validated IP. + /// + /// The default binds the tunnel to an address that passed the sandbox's + /// SSRF and `allowed_ips` validation. Set this only when the proxy's ACLs + /// filter on hostnames and reject IP CONNECT targets: the proxy then + /// resolves the name itself and its own ACLs become the effective egress + /// control for proxied TLS. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_connect_by_hostname: Option, + + /// Path (on the gateway host) to a PEM CA bundle trusted for the + /// corporate proxy. + /// + /// The driver stages it into the per-sandbox overlay at + /// [`GUEST_PROXY_CA_PATH`] and passes that path via + /// `--upstream-proxy-ca-bundle`. It is trusted both for the handshake + /// with an `https://` proxy and for server certificates re-signed by a + /// TLS-intercepting proxy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_ca_bundle: Option, +} + +/// Redacting `Debug` so a proxy URL or credential path never reaches a log. +/// +/// A validated proxy URL cannot embed credentials, but `Debug` can be emitted +/// before validation runs, so presence is logged rather than the value. +impl std::fmt::Debug for VmDriverConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VmDriverConfig") + .field("openshell_endpoint", &self.openshell_endpoint) + .field("state_dir", &self.state_dir) + .field("launcher_bin", &self.launcher_bin) + .field("default_image", &self.default_image) + .field("bootstrap_image", &self.bootstrap_image) + .field("log_level", &self.log_level) + .field("krun_log_level", &self.krun_log_level) + .field("vcpus", &self.vcpus) + .field("mem_mib", &self.mem_mib) + .field("overlay_disk_mib", &self.overlay_disk_mib) + .field("guest_tls_ca", &self.guest_tls_ca) + .field("guest_tls_cert", &self.guest_tls_cert) + .field("guest_tls_key", &self.guest_tls_key) + .field("gpu_enabled", &self.gpu_enabled) + .field("gpu_mem_mib", &self.gpu_mem_mib) + .field("gpu_vcpus", &self.gpu_vcpus) + .field("sandbox_uid", &self.sandbox_uid) + .field("sandbox_gid", &self.sandbox_gid) + .field("https_proxy", &self.https_proxy.is_some()) + .field("no_proxy", &self.no_proxy) + .field("proxy_auth_file", &self.proxy_auth_file.is_some()) + .field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure) + .field("proxy_connect_by_hostname", &self.proxy_connect_by_hostname) + .field("proxy_ca_bundle", &self.proxy_ca_bundle) + .finish() + } } /// Default sandbox UID used by the VM driver when no config value is set. @@ -262,6 +385,12 @@ impl Default for VmDriverConfig { gpu_vcpus: 4, sandbox_uid: None, sandbox_gid: None, + https_proxy: None, + no_proxy: None, + proxy_auth_file: None, + proxy_auth_allow_insecure: None, + proxy_connect_by_hostname: None, + proxy_ca_bundle: None, } } } @@ -300,6 +429,29 @@ impl VmDriverConfig { Ok(()) } + /// Validate the operator's corporate upstream-proxy settings, fail-closed. + /// + /// Delegates to the validator shared with the Podman and Kubernetes + /// drivers and with the in-guest supervisor, so a value accepted here is + /// never rejected inside the guest — and no misconfiguration can silently + /// degrade to a direct dial. + /// + /// # Errors + /// + /// Returns a message naming the offending key. + pub fn validate_proxy_config(&self) -> Result<(), String> { + openshell_core::driver_utils::validate_upstream_proxy_settings( + &openshell_core::driver_utils::UpstreamProxySettings { + url: self.https_proxy.as_deref(), + no_proxy: self.no_proxy.as_deref(), + auth_file: self.proxy_auth_file.as_deref(), + auth_allow_insecure: self.proxy_auth_allow_insecure, + connect_by_hostname: self.proxy_connect_by_hostname, + ca_bundle: self.proxy_ca_bundle.as_deref(), + }, + ) + } + fn requires_tls_materials(&self) -> bool { self.openshell_endpoint.starts_with("https://") } @@ -440,6 +592,7 @@ impl VmDriver { .validate() .map_err(|err| err.message().to_string())?; config.validate_sandbox_identity()?; + config.validate_proxy_config()?; if config.openshell_endpoint.trim().is_empty() { return Err("openshell endpoint is required".to_string()); } @@ -516,6 +669,9 @@ impl VmDriver { driver_name: DRIVER_NAME.to_string(), driver_version: openshell_core::VERSION.to_string(), default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: true, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, } } @@ -603,7 +759,7 @@ impl VmDriver { registry.remove(&sandbox.id); let _ = tokio::fs::remove_dir_all(&state_dir).await; return Err(Status::internal(format!( - "write sandbox resume metadata failed: {err}" + "write sandbox start metadata failed: {err}" ))); } @@ -898,6 +1054,16 @@ impl VmDriver { return Err(err); } + // Staged on every launch, including a restart onto a preserved + // overlay, so the driver's copy always shadows the image layer. + if let Err(err) = inject_guest_upstream_proxy(&overlay_disk, &self.config).await { + self.lifecycle_extensions + .after_launch_failed(&sandbox, &state_dir, LaunchAbortReason::GuestPrepareFailed) + .await; + self.release_gpu_and_subnet(&sandbox.id); + return Err(err); + } + let endpoint_override = if plan.backend == VmBackend::Qemu { plan.host_ip.as_deref().map(|host_ip| { guest_visible_openshell_endpoint_for_tap(&self.config.openshell_endpoint, host_ip) @@ -1057,11 +1223,134 @@ impl VmDriver { Ok(()) } + pub async fn stop_sandbox(&self, sandbox_id: &str, sandbox_name: &str) -> Result<(), Status> { + if !sandbox_id.is_empty() { + validate_sandbox_id(sandbox_id)?; + } + let record_id = { + let registry = self.registry.lock().await; + if registry.contains_key(sandbox_id) { + Some(sandbox_id.to_string()) + } else { + registry + .iter() + .find(|(_, record)| record.snapshot.name == sandbox_name) + .map(|(id, _)| id.clone()) + } + } + .ok_or_else(|| Status::not_found("sandbox not found"))?; + + let state_dir = { + let registry = self.registry.lock().await; + registry + .get(&record_id) + .ok_or_else(|| Status::not_found("sandbox not found"))? + .state_dir + .clone() + }; + + // Persist intent before detaching process handles or releasing host + // allocations. If this write fails, the live record remains intact. + tokio::fs::write(state_dir.join(SANDBOX_STOPPED_FILE), b"stopped\n") + .await + .map_err(|err| Status::internal(format!("persist stop marker failed: {err}")))?; + + let (process, provisioning_task, has_gpu, has_qemu_network, snapshot) = { + let mut registry = self.registry.lock().await; + let record = registry + .get_mut(&record_id) + .ok_or_else(|| Status::not_found("sandbox not found"))?; + ( + record.process.take(), + record.provisioning_task.take(), + record.gpu_bdf.take().is_some(), + std::mem::take(&mut record.qemu_network_allocated), + record.snapshot.clone(), + ) + }; + + if let Some(task) = provisioning_task { + task.abort(); + } + if let Some(process) = process { + let mut process = process.lock().await; + process.deleting = true; + terminate_vm_process(&mut process.child) + .await + .map_err(|err| Status::internal(format!("failed to stop vm: {err}")))?; + } + self.lifecycle_extensions + .after_launch_failed(&snapshot, &state_dir, LaunchAbortReason::Stopped) + .await; + self.release_allocations(&record_id, has_gpu, has_qemu_network); + + if let Some(snapshot) = self + .set_snapshot_condition(&record_id, stopped_condition(), false) + .await + { + self.publish_snapshot(snapshot); + } + self.publish_platform_event( + record_id, + platform_event("vm", "Normal", "Stopped", "VM sandbox stopped".to_string()), + ); + Ok(()) + } + + pub async fn start_sandbox(&self, sandbox_id: &str, sandbox_name: &str) -> Result<(), Status> { + if !sandbox_id.is_empty() { + validate_sandbox_id(sandbox_id)?; + } + let (record_id, state_dir, already_running) = { + let registry = self.registry.lock().await; + let (id, record) = if let Some(entry) = registry.get_key_value(sandbox_id) { + entry + } else { + registry + .iter() + .find(|(_, record)| record.snapshot.name == sandbox_name) + .ok_or_else(|| Status::not_found("sandbox not found"))? + }; + ( + id.clone(), + record.state_dir.clone(), + record.process.is_some() || record.provisioning_task.is_some(), + ) + }; + if already_running { + return Ok(()); + } + + let sandbox = read_sandbox_request(&state_dir.join(SANDBOX_REQUEST_FILE)) + .await + .map_err(|err| { + Status::internal(format!("read sandbox start metadata failed: {err}")) + })?; + let stopped_record = self + .registry + .lock() + .await + .remove(&record_id) + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let restored = self + .restore_persisted_sandbox(sandbox, state_dir, true, &tracing::Span::current()) + .await; + if !restored { + self.registry + .lock() + .await + .entry(record_id) + .or_insert(stopped_record); + return Err(Status::internal("failed to start persisted VM sandbox")); + } + Ok(()) + } + #[tracing::instrument( - name = "vm.delete", + name = "vm.teardown", skip(self), fields( - otel.name = "vm.delete", + otel.name = "vm.teardown", otel.status_code = tracing::field::Empty, sandbox.id = %sandbox_id, sandbox.name = %sandbox_name, @@ -1263,24 +1552,81 @@ impl VmDriver { continue; } - self.restore_persisted_sandbox(sandbox, state_dir, &tracing::Span::current()) + if tokio::fs::metadata(state_dir.join(SANDBOX_STOPPED_FILE)) + .await + .is_ok() + { + let snapshot = sandbox_snapshot(&sandbox, stopped_condition(), false); + let mut registry = self.registry.lock().await; + registry.entry(sandbox.id.clone()).or_insert(SandboxRecord { + snapshot: snapshot.clone(), + state_dir: state_dir.clone(), + process: None, + provisioning_task: None, + gpu_bdf: None, + qemu_network_allocated: false, + deleting: false, + }); + drop(registry); + self.publish_snapshot(snapshot); + info!(sandbox_id = %sandbox.id, "vm driver: restored stopped sandbox without launching compute"); + continue; + } + + if tokio::fs::try_exists(state_dir.join(MAIN_PROCESS_EXITED_FILE)) + .await + .unwrap_or(false) + { + let snapshot = sandbox_snapshot( + &sandbox, + error_condition( + "ProcessExited", + "Canonical main process exited before VM driver restart", + ), + false, + ); + let mut registry = self.registry.lock().await; + registry.entry(sandbox.id.clone()).or_insert(SandboxRecord { + snapshot: snapshot.clone(), + state_dir: state_dir.clone(), + process: None, + provisioning_task: None, + gpu_bdf: None, + qemu_network_allocated: false, + deleting: false, + }); + drop(registry); + self.publish_snapshot(snapshot); + info!( + sandbox_id = %sandbox.id, + "vm driver: preserved terminal sandbox without restarting canonical process" + ); + continue; + } + + self.restore_persisted_sandbox(sandbox, state_dir, false, &tracing::Span::current()) .await; } } + /// Restore a persisted sandbox and report whether the driver accepted it. + /// For explicit start, the stop marker is cleared only after all + /// restore preflight checks pass and the replacement registry record is + /// installed. A failed restore therefore remains durably stopped. async fn restore_persisted_sandbox( &self, sandbox: Sandbox, state_dir: PathBuf, + clear_stop_marker: bool, reconciliation_span: &tracing::Span, - ) { + ) -> bool { let Some(image_ref) = self.resolved_sandbox_image(&sandbox) else { warn!( sandbox_id = %sandbox.id, sandbox_name = %sandbox.name, "vm driver: cannot restore persisted sandbox without image" ); - return; + return false; }; let tls_paths = match self.config.tls_paths() { Ok(paths) => paths, @@ -1291,7 +1637,7 @@ impl VmDriver { error = %err, "vm driver: cannot restore persisted sandbox TLS configuration" ); - return; + return false; } }; @@ -1303,7 +1649,7 @@ impl VmDriver { error = %err.message(), "vm driver: cannot restore persisted sandbox extension state" ); - return; + return false; } let persisted = RestoreContext { @@ -1318,14 +1664,14 @@ impl VmDriver { error = %err, "vm driver: lifecycle extension rejected persisted sandbox restore" ); - return; + return false; } let snapshot = sandbox_snapshot(&sandbox, provisioning_condition(), false); { let mut registry = self.registry.lock().await; if registry.contains_key(&sandbox.id) { - return; + return false; } registry.insert( sandbox.id.clone(), @@ -1341,6 +1687,23 @@ impl VmDriver { ); } + if clear_stop_marker { + match tokio::fs::remove_file(state_dir.join(SANDBOX_STOPPED_FILE)).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + self.registry.lock().await.remove(&sandbox.id); + warn!( + sandbox_id = %sandbox.id, + state_dir = %state_dir.display(), + error = %err, + "vm driver: cannot clear stop marker for persisted sandbox restore" + ); + return false; + } + } + } + self.publish_platform_event( sandbox.id.clone(), platform_event( @@ -1391,6 +1754,7 @@ impl VmDriver { } else { task.abort(); } + true } fn release_gpu(&self, sandbox_id: &str) { @@ -1486,26 +1850,44 @@ impl VmDriver { if plan.gpu_bdf.is_none() { plan.gpu_bdf = gpu_bdf; } - if has_complete_qemu_network(plan) { - return Ok(()); + if !has_complete_qemu_network(plan) { + let subnet = self + .subnet_allocator + .lock() + .map_err(|e| Status::internal(format!("subnet allocator lock poisoned: {e}")))? + .allocate(sandbox_id) + .map_err(Status::failed_precondition)?; + let mac = mac_from_sandbox_id(sandbox_id); + plan.tap_device = Some(tap_device_name(sandbox_id)); + plan.guest_ip = Some(subnet.guest_ip.to_string()); + plan.host_ip = Some(subnet.host_ip.to_string()); + plan.vsock_cid = Some(allocate_vsock_cid()); + plan.guest_mac = Some(format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + )); + plan.gateway_port = gateway_port_from_endpoint(&self.config.openshell_endpoint); + } + + // The corporate-proxy host-loopback recipe is a libkrun/gvproxy + // property and has no QEMU/TAP equivalent (see + // `proxy_url_targets_gateway_host`). Run it here, after the subnet + // allocation above has settled `plan.host_ip`, because the address to + // compare against is this sandbox's own TAP host address. Fail the + // create with the reason rather than boot a sandbox whose + // policy-approved CONNECTs all time out against an unreachable proxy. + if let Some(url) = self.config.https_proxy.as_deref() + && proxy_url_targets_gateway_host(url, plan.host_ip.as_deref()) + { + let tap_host = plan.host_ip.as_deref().unwrap_or("the TAP host address"); + return Err(Status::failed_precondition(format!( + "https_proxy '{url}' addresses the gateway host, which a QEMU/TAP sandbox \ + (GPU sandboxes) cannot reach: host.openshell.internal resolves to this \ + sandbox's TAP host address {tap_host} and the driver's nftables rules allow \ + only the gateway port from the guest. Configure a proxy address routable \ + from the guest's masqueraded egress, or run this sandbox without a GPU" + ))); } - - let subnet = self - .subnet_allocator - .lock() - .map_err(|e| Status::internal(format!("subnet allocator lock poisoned: {e}")))? - .allocate(sandbox_id) - .map_err(Status::failed_precondition)?; - let mac = mac_from_sandbox_id(sandbox_id); - plan.tap_device = Some(tap_device_name(sandbox_id)); - plan.guest_ip = Some(subnet.guest_ip.to_string()); - plan.host_ip = Some(subnet.host_ip.to_string()); - plan.vsock_cid = Some(allocate_vsock_cid()); - plan.guest_mac = Some(format!( - "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] - )); - plan.gateway_port = gateway_port_from_endpoint(&self.config.openshell_endpoint); Ok(()) } @@ -1936,14 +2318,15 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - client - .auth(&reference, &auth, RegistryOperation::Pull) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}" - )) - })?; + retry_registry_request("authenticate with registry", || { + client.auth(&reference, &auth, RegistryOperation::Pull) + }) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}" + )) + })?; info!(image_ref = %image_ref, "vm driver: fetching manifest digest"); self.publish_vm_progress( sandbox_id, @@ -1954,14 +2337,15 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - let source_image_identity = client - .fetch_manifest_digest(&reference, &auth) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to resolve vm sandbox image '{image_ref}': {err}" - )) - })?; + let source_image_identity = retry_registry_request("fetch manifest digest", || { + client.fetch_manifest_digest(&reference, &auth) + }) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to resolve vm sandbox image '{image_ref}': {err}" + )) + })?; info!( image_ref = %image_ref, image_identity = %source_image_identity, @@ -2338,14 +2722,15 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - client - .auth(&reference, &auth, RegistryOperation::Pull) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}" - )) - })?; + retry_registry_request("authenticate with registry", || { + client.auth(&reference, &auth, RegistryOperation::Pull) + }) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}" + )) + })?; self.publish_vm_progress( sandbox_id, @@ -2356,14 +2741,15 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - let source_image_identity = client - .fetch_manifest_digest(&reference, &auth) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to resolve vm sandbox image '{image_ref}': {err}" - )) - })?; + let source_image_identity = retry_registry_request("fetch manifest digest", || { + client.fetch_manifest_digest(&reference, &auth) + }) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to resolve vm sandbox image '{image_ref}': {err}" + )) + })?; let cache_identity = prepared_image_cache_identity(&source_image_identity); let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity); @@ -2389,14 +2775,15 @@ impl VmDriver { self.reset_image_staging_dir(&staging_dir).await?; let layout_dir = staging_dir.join(GUEST_IMAGE_OCI_LAYOUT_DIR); - let (manifest, _) = client - .pull_image_manifest(&reference, &auth) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to pull vm sandbox image manifest '{image_ref}': {err}" - )) - })?; + let (manifest, _) = retry_registry_request("pull image manifest", || { + client.pull_image_manifest(&reference, &auth) + }) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to pull vm sandbox image manifest '{image_ref}': {err}" + )) + })?; tokio::fs::create_dir_all(oci_layout_blobs_dir(&layout_dir)) .await .map_err(|err| Status::internal(format!("create guest OCI layout failed: {err}")))?; @@ -2993,6 +3380,25 @@ impl VmDriver { }; if let Some(status) = exit_status { + let state_dir = { + let registry = self.registry.lock().await; + registry + .get(&sandbox_id) + .map(|record| record.state_dir.clone()) + }; + if let Some(state_dir) = state_dir + && let Err(error) = write_private_file( + &state_dir.join(MAIN_PROCESS_EXITED_FILE), + b"terminal\n".to_vec(), + ) + .await + { + warn!( + sandbox_id = %sandbox_id, + %error, + "vm driver: failed to persist canonical-process exit tombstone" + ); + } let message = status.code().map_or_else( || "VM process exited".to_string(), |code| format!("VM process exited with status {code}"), @@ -3100,6 +3506,16 @@ impl VmDriver { #[tonic::async_trait] impl ComputeDriver for VmDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Err(Status::unimplemented( + "VM driver does not authenticate sandbox credentials", + )) + } + async fn get_capabilities( &self, _request: Request, @@ -3178,17 +3594,28 @@ impl ComputeDriver for VmDriver { async fn stop_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented( - "stop sandbox is not implemented by the vm compute driver", - )) + let request = request.into_inner(); + self.stop_sandbox(&request.sandbox_id, &request.sandbox_name) + .await?; + Ok(Response::new(StopSandboxResponse {})) } - async fn delete_sandbox( + async fn start_sandbox( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + self.start_sandbox(&request.sandbox_id, &request.sandbox_name) + .await?; + Ok(Response::new(StartSandboxResponse {})) + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { let request = request.into_inner(); let response = self .delete_sandbox(&request.sandbox_id, &request.sandbox_name) @@ -3252,6 +3679,20 @@ impl ComputeDriver for VmDriver { let stream: Self::WatchSandboxesStream = Box::pin(ReceiverStream::new(out_rx)); Ok(Response::new(stream)) } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(DeleteWorkspaceResponse {})) + } } #[cfg(target_os = "linux")] @@ -3402,7 +3843,7 @@ async fn connect_local_container_engine() -> Option { return Some(docker); } - let podman_socket = openshell_core::config::detect_podman_socket()?; + let podman_socket = detect_podman_socket()?; if let Ok(docker) = Docker::connect_with_unix(podman_socket.to_str()?, 120, bollard::API_DEFAULT_VERSION) && docker.ping().await.is_ok() @@ -3417,6 +3858,10 @@ async fn connect_local_container_engine() -> Option { None } +fn detect_podman_socket() -> Option { + openshell_driver_podman::driver::detect_socket() +} + fn is_openshell_local_build_image_ref(image_ref: &str) -> bool { image_ref.starts_with("openshell/sandbox-from:") } @@ -3531,6 +3976,134 @@ fn registry_client() -> OciClient { }) } +async fn retry_registry_request( + operation: &str, + request: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + retry_registry_request_with_delay(operation, REGISTRY_RETRY_INITIAL_DELAY, request).await +} + +async fn retry_registry_request_with_delay( + operation: &str, + initial_delay: Duration, + mut request: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut delay = initial_delay; + for attempt in 1..=REGISTRY_REQUEST_MAX_ATTEMPTS { + match request().await { + Ok(value) => return Ok(value), + Err(err) + if attempt < REGISTRY_REQUEST_MAX_ATTEMPTS && registry_error_is_retryable(&err) => + { + warn!( + operation, + attempt, + max_attempts = REGISTRY_REQUEST_MAX_ATTEMPTS, + retry_delay_ms = delay.as_millis(), + error = %err, + "vm driver: transient registry request failed; retrying" + ); + tokio::time::sleep(delay).await; + delay = delay.saturating_mul(2).min(REGISTRY_RETRY_MAX_DELAY); + } + Err(err) => return Err(err), + } + } + + unreachable!("registry request retry loop always returns") +} + +fn registry_error_is_retryable(err: &OciDistributionError) -> bool { + match err { + OciDistributionError::AuthenticationFailure(message) => { + registry_message_is_retryable(message) + } + OciDistributionError::RegistryError { envelope, .. } => { + envelope.errors.iter().any(|error| { + error.code == OciErrorCode::Toomanyrequests + || registry_message_is_retryable(&error.message) + }) + } + OciDistributionError::RequestError(err) => { + err.is_connect() + || err.is_timeout() + || err + .status() + .is_some_and(|status| matches!(status.as_u16(), 408 | 425 | 429 | 500..=599)) + } + OciDistributionError::ServerError { code, message, .. } => { + matches!(code, 408 | 425 | 429 | 500..=599) || registry_message_is_retryable(message) + } + OciDistributionError::IoError(err) => matches!( + err.kind(), + std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::WouldBlock + ), + _ => false, + } +} + +fn registry_message_is_retryable(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("retry-after") + || message.contains("too many requests") + || message.contains("rate limit") +} + +enum RegistryBlobPullError { + CreateFile(std::io::Error), + Registry(OciDistributionError), +} + +async fn pull_registry_blob_file( + operation: &str, + client: &OciClient, + reference: &Reference, + descriptor: &OciDescriptor, + blob_path: &Path, +) -> Result { + let mut delay = REGISTRY_RETRY_INITIAL_DELAY; + for attempt in 1..=REGISTRY_REQUEST_MAX_ATTEMPTS { + let mut file = tokio::fs::File::create(blob_path) + .await + .map_err(RegistryBlobPullError::CreateFile)?; + match client.pull_blob(reference, descriptor, &mut file).await { + Ok(()) => return Ok(file), + Err(err) + if attempt < REGISTRY_REQUEST_MAX_ATTEMPTS && registry_error_is_retryable(&err) => + { + warn!( + operation, + attempt, + max_attempts = REGISTRY_REQUEST_MAX_ATTEMPTS, + retry_delay_ms = delay.as_millis(), + error = %err, + "vm driver: transient registry request failed; retrying" + ); + tokio::time::sleep(delay).await; + delay = delay.saturating_mul(2).min(REGISTRY_RETRY_MAX_DELAY); + } + Err(err) => return Err(RegistryBlobPullError::Registry(err)), + } + } + + unreachable!("registry blob retry loop always returns") +} + fn linux_platform_resolver(manifests: &[ImageIndexEntry]) -> Option { let expected_arch = linux_oci_arch(); manifests @@ -3615,22 +4188,24 @@ impl VmDriver { staging_dir: &Path, rootfs: &Path, ) -> Result<(), Status> { - client - .auth(reference, auth, RegistryOperation::Pull) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}" - )) - })?; - let (manifest, _) = client - .pull_image_manifest(reference, auth) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to pull vm sandbox image manifest '{image_ref}': {err}" - )) - })?; + retry_registry_request("authenticate with registry", || { + client.auth(reference, auth, RegistryOperation::Pull) + }) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}" + )) + })?; + let (manifest, _) = retry_registry_request("pull image manifest", || { + client.pull_image_manifest(reference, auth) + }) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to pull vm sandbox image manifest '{image_ref}': {err}" + )) + })?; tokio::fs::create_dir_all(rootfs) .await @@ -3750,18 +4325,23 @@ async fn download_registry_layer_blob( .join("layers") .join(format!("{index:02}-{digest_component}.root")); - let mut file = tokio::fs::File::create(&blob_path) - .await - .map_err(|err| Status::internal(format!("create layer blob failed: {err}")))?; - client - .pull_blob(reference, &layer, &mut file) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to download layer '{}' for vm sandbox image '{image_ref}': {err}", - layer.digest - )) - })?; + let mut file = pull_registry_blob_file( + "download image layer", + client, + reference, + &layer, + &blob_path, + ) + .await + .map_err(|err| match err { + RegistryBlobPullError::CreateFile(err) => { + Status::internal(format!("create layer blob failed: {err}")) + } + RegistryBlobPullError::Registry(err) => Status::failed_precondition(format!( + "failed to download layer '{}' for vm sandbox image '{image_ref}': {err}", + layer.digest + )), + })?; file.flush() .await .map_err(|err| Status::internal(format!("flush layer blob failed: {err}")))?; @@ -3848,18 +4428,23 @@ async fn download_registry_descriptor_blob_file( .map_err(|err| Status::internal(format!("create OCI blob dir failed: {err}")))?; } - let mut file = tokio::fs::File::create(&blob_path) - .await - .map_err(|err| Status::internal(format!("create OCI {kind} blob failed: {err}")))?; - client - .pull_blob(reference, descriptor, &mut file) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to download {kind} '{}' for vm sandbox image '{image_ref}': {err}", - descriptor.digest - )) - })?; + let mut file = pull_registry_blob_file( + "download image blob", + client, + reference, + descriptor, + &blob_path, + ) + .await + .map_err(|err| match err { + RegistryBlobPullError::CreateFile(err) => { + Status::internal(format!("create OCI {kind} blob failed: {err}")) + } + RegistryBlobPullError::Registry(err) => Status::failed_precondition(format!( + "failed to download {kind} '{}' for vm sandbox image '{image_ref}': {err}", + descriptor.digest + )), + })?; file.flush() .await .map_err(|err| Status::internal(format!("flush OCI {kind} blob failed: {err}")))?; @@ -4171,6 +4756,53 @@ fn guest_visible_openshell_endpoint(endpoint: &str) -> String { endpoint.to_string() } +/// Whether a corporate proxy URL points at the gateway host itself, as seen +/// from a QEMU/TAP guest whose TAP host address is `tap_host_ip`. +/// +/// On the libkrun backend gvproxy NATs the host-loopback alias +/// `host.openshell.internal` (and any loopback URL, which the driver rewrites +/// to that alias) to the gateway host's `127.0.0.1`, so a proxy bound to host +/// loopback is reachable from the guest. The QEMU/TAP backend used for GPU +/// sandboxes has no equivalent: `host.openshell.internal` resolves to the TAP +/// host address, and the driver's own nftables `input` chain accepts only the +/// gateway port from the guest and drops the rest, so no proxy on the gateway +/// host is reachable regardless of the address it binds. +/// +/// The gateway host is therefore reached from a QEMU guest under exactly three +/// spellings: the guest's own loopback (never the host's, but a configuration +/// that plainly means the host), the documented host aliases that +/// `write_host_gateway_aliases` seeds to the TAP host address, and that TAP +/// host address written literally. `tap_host_ip` is this sandbox's allocated +/// address, so the comparison must be made after the launch plan's subnet +/// allocation; `None` means the plan carries no TAP host and only the +/// address-independent spellings are classified. +/// +/// gvproxy's `GVPROXY_HOST_LOOPBACK_IP` is deliberately **not** matched here. +/// It is special only to libkrun; on QEMU/TAP it is an ordinary address that +/// may well be routable through the guest's masqueraded egress, and rejecting +/// it would refuse a working configuration. +/// +/// Used to reject an unreachable configuration up front on the QEMU path +/// instead of letting every policy-approved CONNECT time out. +fn proxy_url_targets_gateway_host(url: &str, tap_host_ip: Option<&str>) -> bool { + let Ok(parsed) = Url::parse(url) else { + // Unparseable URLs are rejected by shared validation before launch. + return false; + }; + let tap_host = tap_host_ip.and_then(|ip| ip.parse::().ok()); + match parsed.host() { + Some(Host::Ipv4(ip)) => ip.is_loopback() || tap_host == Some(IpAddr::V4(ip)), + Some(Host::Ipv6(ip)) => ip.is_loopback() || tap_host == Some(IpAddr::V6(ip)), + Some(Host::Domain(host)) => { + host.eq_ignore_ascii_case("localhost") + || host.eq_ignore_ascii_case(OPENSHELL_HOST_GATEWAY_ALIAS) + || host.eq_ignore_ascii_case("host.containers.internal") + || host.eq_ignore_ascii_case("host.docker.internal") + } + None => false, + } +} + fn gateway_port_from_endpoint(endpoint: &str) -> Option { Url::parse(endpoint).ok().and_then(|url| url.port()) } @@ -4239,9 +4871,17 @@ fn build_guest_environment( openshell_core::sandbox_env::SSH_SOCKET_PATH.to_string(), GUEST_SSH_SOCKET_PATH.to_string(), ); + // The libkrun guest environment path does not preserve spaces in values + // before guest startup. Use a whitespace-free base64url envelope so + // command arguments remain lossless. + let main_process = + openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec_base64url( + sandbox.spec.as_ref(), + ) + .expect("main process config serialization cannot fail"); environment.insert( - openshell_core::sandbox_env::SANDBOX_COMMAND.to_string(), - "tail -f /dev/null".to_string(), + openshell_core::sandbox_env::MAIN_PROCESS_SPEC.to_string(), + main_process, ); environment.insert( openshell_core::sandbox_env::LOG_LEVEL.to_string(), @@ -4265,8 +4905,19 @@ fn build_guest_environment( openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(), openshell_core::telemetry::enabled_env_value().to_string(), ); + // Runtime capabilities are driver-owned. The VM driver does not yet + // provide policy DNS and transparent TCP interception. + environment.insert( + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), + String::new(), + ); environment.remove(openshell_core::sandbox_env::SANDBOX_TOKEN); environment.remove(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE); + // Prevent user-supplied environment from overriding the TLS server name + // the supervisor verifies — a sandbox user who can redirect the gateway + // hostname could otherwise present a certificate for a name they control + // and intercept the sandbox JWT. + environment.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME); if sandbox .spec .as_ref() @@ -4845,6 +5496,182 @@ fn inject_guest_init_dropins( span_status.finish(Ok(())) } +/// Build the corporate upstream-proxy arguments passed to the guest supervisor. +/// +/// This operator-owned egress boundary travels on the supervisor's argv, +/// which sandbox spec/template environment and image `ENV` cannot influence. +/// Credentials are never on argv — only the root-only guest path is passed; +/// the supervisor reads the credential from that file. +fn upstream_proxy_cli_args(config: &VmDriverConfig) -> Vec { + let mut args = Vec::new(); + if let Some(url) = &config.https_proxy { + args.push("--upstream-proxy".to_string()); + args.push(url.clone()); + } + if let Some(list) = &config.no_proxy { + args.push("--upstream-no-proxy".to_string()); + args.push(list.clone()); + } + if config.proxy_auth_file.is_some() { + args.push("--upstream-proxy-auth-file".to_string()); + // The guest path, never the gateway-host path the operator configured. + args.push(GUEST_UPSTREAM_PROXY_AUTH_PATH.to_string()); + } + // Config validation guarantees the acknowledgement is `true` whenever an + // auth file is configured against an http:// proxy; the supervisor + // independently refuses credentials without it. + if config.proxy_auth_allow_insecure == Some(true) { + args.push("--upstream-proxy-auth-allow-insecure".to_string()); + } + // Absent means the default validated-IP CONNECT binding; only the + // explicit hostname opt-in is passed through. + if config.proxy_connect_by_hostname == Some(true) { + args.push("--upstream-proxy-connect-by-hostname".to_string()); + } + if config.proxy_ca_bundle.is_some() { + args.push("--upstream-proxy-ca-bundle".to_string()); + args.push(GUEST_PROXY_CA_PATH.to_string()); + } + args +} + +/// Render the supervisor argument list as newline-separated arguments. +/// +/// One argument per line, verbatim: the guest reads the lines into an array +/// without word splitting or globbing, so values containing spaces survive +/// intact. An empty list renders an empty file, which the guest reads as "no +/// extra arguments". +fn render_guest_supervisor_args(args: &[String]) -> Vec { + let mut body = args.join("\n"); + if !body.is_empty() { + body.push('\n'); + } + body.into_bytes() +} + +/// Reject argument values the newline-delimited guest file cannot represent. +/// +/// Every value here is operator-supplied config, so this is a guard against +/// misconfiguration rather than an attack: a stray newline would otherwise +/// split one value into two arguments in the guest. +fn validate_guest_supervisor_args(args: &[String]) -> Result<(), String> { + for arg in args { + if arg.contains('\n') || arg.contains('\r') || arg.contains('\0') { + return Err( + "corporate proxy settings must not contain newline or NUL characters".to_string(), + ); + } + } + Ok(()) +} + +/// Read and validate the corporate proxy credential from the gateway host. +/// +/// Uses the validators shared with the supervisor, so a credential accepted +/// here is never rejected inside the guest. The error never carries the file +/// contents. +async fn read_sandbox_proxy_credential(path: &str) -> Result { + let path_owned = path.to_string(); + let raw = tokio::task::spawn_blocking(move || { + openshell_core::driver_utils::read_upstream_proxy_credential_file(&path_owned) + }) + .await + .map_err(|err| Status::internal(format!("proxy_auth_file read task failed: {err}")))? + .map_err(Status::invalid_argument)?; + let credential = openshell_core::driver_utils::parse_upstream_proxy_credential(&raw) + .map_err(|err| Status::invalid_argument(format!("proxy_auth_file '{path}': {err}")))?; + Ok(credential.to_string()) +} + +/// Read and validate the corporate proxy CA bundle from the gateway host. +/// +/// Uses the reader shared with the supervisor, so the bundle is bounded and +/// non-regular files are rejected (an operator path such as `/dev/zero` can +/// otherwise exhaust driver memory), and a bundle accepted here contributes at +/// least one trust anchor rustls accepts rather than merely looking like PEM. +/// Checked here rather than only in the guest so the operator gets an error +/// attributable to `proxy_ca_bundle` instead of an opaque supervisor startup +/// failure inside every sandbox. The error never carries the file contents. +async fn read_sandbox_proxy_ca_bundle(path: &str) -> Result, Status> { + let path_owned = path.to_string(); + let pem = tokio::task::spawn_blocking(move || { + openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file( + &path_owned, + "proxy_ca_bundle", + ) + }) + .await + .map_err(|err| Status::internal(format!("proxy_ca_bundle read task failed: {err}")))? + .map_err(Status::invalid_argument)?; + Ok(pem.into_bytes()) +} + +/// Stage the corporate upstream-proxy configuration into the guest overlay. +/// +/// Writes three files into the overlay upperdir the driver owns: +/// +/// * the credential at [`GUEST_UPSTREAM_PROXY_AUTH_PATH`], mode `0600`; +/// * the CA bundle at [`GUEST_PROXY_CA_PATH`], mode `0644` (a CA certificate +/// is not secret); +/// * the supervisor argument list at [`GUEST_SUPERVISOR_ARGS_PATH`], mode +/// `0644`. +/// +/// All three are written on every launch, empty when the corresponding +/// setting is absent. Writing rather than skipping is what makes the channel +/// unforgeable: the upperdir copy always shadows the read-only image layer, so +/// a sandbox image cannot supply its own arguments or credential by baking a +/// file at these paths, and cannot disable the operator's by omitting one. It +/// also clears material a previous launch staged into a preserved overlay +/// after the operator removed the setting. +/// +/// A microVM has no bind mounts or container secrets, so the credential lives +/// at rest inside the per-sandbox overlay disk on the host — the same +/// delivery the per-sandbox gateway JWT already uses. It is removed with the +/// sandbox when the state directory is deleted. +#[allow(clippy::result_large_err)] +async fn inject_guest_upstream_proxy( + overlay_disk: &Path, + config: &VmDriverConfig, +) -> Result<(), Status> { + // Written whether or not they are configured. Writing empty files when + // the operator removed a setting clears material a previous launch staged + // into a preserved overlay, and shadows anything an image baked at these + // paths, so a staged file is only ever the one this launch produced. + let credential = match config.proxy_auth_file.as_deref() { + Some(path) => format!("{}\n", read_sandbox_proxy_credential(path).await?).into_bytes(), + None => Vec::new(), + }; + let credential_path = overlay_upper_path(GUEST_UPSTREAM_PROXY_AUTH_PATH); + write_rootfs_image_file(overlay_disk, &credential_path, &credential) + .map_err(|err| Status::internal(format!("write VM guest proxy credential: {err}")))?; + set_rootfs_image_file_mode(overlay_disk, &credential_path, 0o600) + .map_err(|err| Status::internal(format!("set VM guest proxy credential mode: {err}")))?; + + let ca_bundle = match config.proxy_ca_bundle.as_deref() { + Some(path) => read_sandbox_proxy_ca_bundle(path).await?, + None => Vec::new(), + }; + let ca_path = overlay_upper_path(GUEST_PROXY_CA_PATH); + write_rootfs_image_file(overlay_disk, &ca_path, &ca_bundle) + .map_err(|err| Status::internal(format!("write VM guest proxy CA bundle: {err}")))?; + set_rootfs_image_file_mode(overlay_disk, &ca_path, 0o644) + .map_err(|err| Status::internal(format!("set VM guest proxy CA bundle mode: {err}")))?; + + let args = upstream_proxy_cli_args(config); + validate_guest_supervisor_args(&args).map_err(Status::failed_precondition)?; + let guest_path = overlay_upper_path(GUEST_SUPERVISOR_ARGS_PATH); + write_rootfs_image_file( + overlay_disk, + &guest_path, + &render_guest_supervisor_args(&args), + ) + .map_err(|err| Status::internal(format!("write VM guest supervisor arguments: {err}")))?; + set_rootfs_image_file_mode(overlay_disk, &guest_path, 0o644).map_err(|err| { + Status::internal(format!("set VM guest supervisor arguments mode: {err}")) + })?; + Ok(()) +} + /// Render the drop-in allow-list as newline-separated, ASCII-sorted, /// de-duplicated names. Names are already validated to be path-safe by /// [`validate_guest_init_dropins`]. @@ -5183,6 +6010,16 @@ fn deleting_condition() -> SandboxCondition { } } +fn stopped_condition() -> SandboxCondition { + SandboxCondition { + r#type: "Stopped".to_string(), + status: "True".to_string(), + reason: "ComputeStopped".to_string(), + message: "VM compute is stopped and persistent state is retained".to_string(), + last_transition_time: String::new(), + } +} + fn error_condition(reason: &str, message: &str) -> SandboxCondition { SandboxCondition { r#type: "Ready".to_string(), @@ -5306,12 +6143,59 @@ mod tests { use prost_types::{Struct, Value, value::Kind}; use std::fs; use std::path::Path; - use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use tonic::Code; static ENV_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + + #[test] + fn registry_throttling_errors_are_retryable() { + let error = OciDistributionError::RegistryError { + envelope: oci_client::errors::OciEnvelope { + errors: vec![oci_client::errors::OciError { + code: OciErrorCode::Toomanyrequests, + message: "retry-after: 829.756µs, allowed: 44000/minute".to_string(), + detail: serde_json::Value::Null, + }], + }, + url: "https://ghcr.io/v2/example/manifests/latest".to_string(), + }; + + assert!(registry_error_is_retryable(&error)); + } + + #[test] + fn permanent_registry_errors_are_not_retryable() { + let error = OciDistributionError::UnauthorizedError { + url: "https://example.invalid/v2/image/manifests/latest".to_string(), + }; + + assert!(!registry_error_is_retryable(&error)); + } + + #[tokio::test] + async fn registry_request_retries_transient_errors_until_success() { + let attempts = AtomicUsize::new(0); + let result = retry_registry_request_with_delay("test request", Duration::ZERO, || async { + let attempt = attempts.fetch_add(1, Ordering::Relaxed); + if attempt < 2 { + Err(OciDistributionError::ServerError { + code: 503, + url: "https://example.invalid/v2/".to_string(), + message: "temporarily unavailable".to_string(), + }) + } else { + Ok("success") + } + }) + .await; + + assert_eq!(result.unwrap(), "success"); + assert_eq!(attempts.load(Ordering::Relaxed), 3); + } + struct TestTracing { exporter: opentelemetry_sdk::trace::InMemorySpanExporter, _provider: opentelemetry_sdk::trace::SdkTracerProvider, @@ -5417,7 +6301,7 @@ mod tests { let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { tonic::transport::Server::builder() - .layer(crate::otel_tracing::compute_driver_rpc_layer()) + .layer(openshell_otel::compute_driver_rpc_layer()) .add_service(ComputeDriverServer::new(driver)) .serve_with_incoming_shutdown( tokio_stream::wrappers::TcpListenerStream::new(listener), @@ -5454,7 +6338,7 @@ mod tests { let spans = traced.exporter.get_finished_spans().unwrap(); let rpc_spans = spans .iter() - .filter(|span| span.name == "driver.get_capabilities") + .filter(|span| span.name == "openshell.compute.v1.ComputeDriver/GetCapabilities") .collect::>(); assert_eq!( rpc_spans.len(), @@ -5535,14 +6419,14 @@ mod tests { let spans = traced.exporter.get_finished_spans().unwrap(); let expected = [ - "driver.get_capabilities", - "driver.validate_sandbox_create", - "driver.create_sandbox", - "driver.get_sandbox", - "driver.list_sandboxes", - "driver.stop_sandbox", - "driver.delete_sandbox", - "driver.watch_sandboxes", + "openshell.compute.v1.ComputeDriver/GetCapabilities", + "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate", + "openshell.compute.v1.ComputeDriver/CreateSandbox", + "openshell.compute.v1.ComputeDriver/GetSandbox", + "openshell.compute.v1.ComputeDriver/ListSandboxes", + "openshell.compute.v1.ComputeDriver/StopSandbox", + "openshell.compute.v1.ComputeDriver/DeleteSandbox", + "openshell.compute.v1.ComputeDriver/WatchSandboxes", ]; for name in expected { let span = spans @@ -5553,10 +6437,10 @@ mod tests { assert_has_parent(span); } for name in [ - "driver.validate_sandbox_create", - "driver.create_sandbox", - "driver.get_sandbox", - "driver.stop_sandbox", + "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate", + "openshell.compute.v1.ComputeDriver/CreateSandbox", + "openshell.compute.v1.ComputeDriver/GetSandbox", + "openshell.compute.v1.ComputeDriver/StopSandbox", ] { let span = spans.iter().find(|span| span.name == name).unwrap(); assert!( @@ -5567,12 +6451,12 @@ mod tests { } let delete_rpc = spans .iter() - .find(|span| span.name == "driver.delete_sandbox") + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/DeleteSandbox") .expect("delete RPC span"); let cleanup = spans .iter() .find(|span| { - span.name == "vm.delete" + span.name == "vm.teardown" && span.span_context.trace_id() == delete_rpc.span_context.trace_id() }) .expect("delete cleanup span"); @@ -5698,11 +6582,50 @@ mod tests { } } + #[tokio::test] + async fn startup_does_not_restore_terminal_canonical_process() { + let temp = tempfile::tempdir().unwrap(); + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.state_dir = temp.path().to_path_buf(); + let sandbox = Sandbox { + id: "sb-terminal-main".to_string(), + name: "terminal-main".to_string(), + spec: Some(SandboxSpec { + template: Some(SandboxTemplate { + image: "unused-image".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + let state_dir = temp.path().join("sandboxes").join(&sandbox.id); + tokio::fs::create_dir_all(&state_dir).await.unwrap(); + write_sandbox_request(&state_dir, &sandbox).await.unwrap(); + write_private_file( + &state_dir.join(MAIN_PROCESS_EXITED_FILE), + b"terminal\n".to_vec(), + ) + .await + .unwrap(); + + driver.restore_persisted_sandboxes().await; + + let registry = driver.registry.lock().await; + let record = registry.get(&sandbox.id).expect("terminal record"); + assert!(record.process.is_none()); + assert!(record.provisioning_task.is_none()); + let status = record.snapshot.status.as_ref().expect("terminal status"); + assert!(status.conditions.iter().any(|condition| { + condition.reason == "ProcessExited" && condition.status == "False" + })); + } + #[tokio::test] async fn background_provisioning_does_not_extend_the_rpc_span_lifetime() { let traced = TestTracing::new(); let _dispatch = tracing::dispatcher::set_default(&traced.dispatch); - let rpc = tracing::info_span!("driver.create_sandbox"); + let rpc = tracing::info_span!("openshell.compute.v1.ComputeDriver/CreateSandbox"); let entered = rpc.enter(); let provisioning = provisioning_span(&rpc.context(), "sb-lifetime", "invalid image reference"); @@ -5715,7 +6638,7 @@ mod tests { .get_finished_spans() .unwrap() .iter() - .any(|span| span.name == "driver.create_sandbox"), + .any(|span| span.name == "openshell.compute.v1.ComputeDriver/CreateSandbox"), "the RPC span should finish while background provisioning is still active" ); drop(provisioning); @@ -5846,7 +6769,7 @@ mod tests { let spans = traced.exporter.get_finished_spans().unwrap(); let deletion = spans .iter() - .find(|span| span.name == "vm.delete") + .find(|span| span.name == "vm.teardown") .expect("delete span"); assert!( matches!(deletion.status, opentelemetry::trace::Status::Error { .. }), @@ -6348,13 +7271,13 @@ mod tests { } #[tokio::test] - async fn sandbox_request_metadata_round_trips_for_resume() { + async fn sandbox_request_metadata_round_trips_for_start() { let base = unique_temp_dir(); let state_dir = base.join("sandboxes").join("sandbox-123"); std::fs::create_dir_all(&state_dir).unwrap(); let sandbox = Sandbox { id: "sandbox-123".to_string(), - name: "resume-sandbox".to_string(), + name: "start-sandbox".to_string(), namespace: "vm-dev".to_string(), spec: Some(SandboxSpec { environment: HashMap::from([("KEY".to_string(), "value".to_string())]), @@ -6394,8 +7317,64 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + #[tokio::test] + async fn failed_start_preserves_stopped_state() { + let temp = tempfile::tempdir().unwrap(); + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.state_dir = temp.path().to_path_buf(); + let sandbox = Sandbox { + id: "sandbox-stopped".to_string(), + name: "stopped".to_string(), + ..Default::default() + }; + let state_dir = temp.path().join("sandboxes").join(&sandbox.id); + create_private_dir_all(&state_dir).await.unwrap(); + write_sandbox_request(&state_dir, &sandbox).await.unwrap(); + tokio::fs::write(state_dir.join(SANDBOX_STOPPED_FILE), b"stopped\n") + .await + .unwrap(); + let snapshot = sandbox_snapshot(&sandbox, stopped_condition(), false); + driver.registry.lock().await.insert( + sandbox.id.clone(), + SandboxRecord { + snapshot, + state_dir: state_dir.clone(), + process: None, + provisioning_task: None, + gpu_bdf: None, + qemu_network_allocated: false, + deleting: false, + }, + ); + + let err = driver + .start_sandbox(&sandbox.id, &sandbox.name) + .await + .expect_err("start without an image should fail"); + + assert_eq!(err.code(), Code::Internal); + assert!( + tokio::fs::metadata(state_dir.join(SANDBOX_STOPPED_FILE)) + .await + .is_ok(), + "failed start must retain its durable stop marker" + ); + let restored = driver + .get_sandbox(&sandbox.id, &sandbox.name) + .await + .unwrap() + .expect("failed start must retain its stopped registry record"); + let condition = restored + .status + .as_ref() + .and_then(|status| status.conditions.first()) + .expect("stopped condition"); + assert_eq!(condition.r#type, "Stopped"); + assert_eq!(condition.status, "True"); + } + #[test] - fn prepare_sandbox_overlay_preserves_existing_overlay_on_resume() { + fn prepare_sandbox_overlay_preserves_existing_overlay_on_start() { let base = unique_temp_dir(); std::fs::create_dir_all(&base).unwrap(); let template = base.join("template.ext4"); @@ -6419,7 +7398,7 @@ mod tests { } #[test] - fn prepare_sandbox_overlay_creates_missing_overlay_on_resume() { + fn prepare_sandbox_overlay_creates_missing_overlay_on_start() { let base = unique_temp_dir(); std::fs::create_dir_all(&base).unwrap(); let template = base.join("template.ext4"); @@ -6683,6 +7662,87 @@ mod tests { ))); } + #[test] + fn validate_sandbox_identity_accepts_non_root_system_ids() { + let config = VmDriverConfig { + sandbox_uid: Some(500), + sandbox_gid: Some(30), + ..Default::default() + }; + assert!(config.validate_sandbox_identity().is_ok()); + } + + #[test] + fn persisted_legacy_sandbox_without_command_uses_scratch_main() { + let config = VmDriverConfig { + openshell_endpoint: "http://127.0.0.1:8080".to_string(), + ..Default::default() + }; + // Requests persisted before the canonical-main contract have a + // present DriverSandboxSpec but no command or tty fields. + let sandbox = Sandbox { + id: "legacy-sandbox".to_string(), + name: "legacy-sandbox".to_string(), + spec: Some(SandboxSpec::default()), + ..Default::default() + }; + + let env = build_guest_environment(&sandbox, &config, None); + let encoded = env + .iter() + .find_map(|entry| { + entry.strip_prefix(&format!( + "{}=", + openshell_core::sandbox_env::MAIN_PROCESS_SPEC + )) + }) + .expect("main process environment"); + let main = openshell_core::sandbox_env::MainProcessConfig::decode(encoded) + .expect("legacy persisted request should produce a valid main config"); + + assert_eq!( + main, + openshell_core::sandbox_env::MainProcessConfig::scratch() + ); + } + + #[test] + fn build_guest_environment_preserves_main_command_spaces() { + let config = VmDriverConfig { + openshell_endpoint: "https://127.0.0.1:8080".to_string(), + ..Default::default() + }; + let command = vec![ + "sh".to_string(), + "-lc".to_string(), + "echo ready; while true; do sleep 1; done".to_string(), + ]; + let sandbox = Sandbox { + id: "space-command".to_string(), + name: "space-command".to_string(), + spec: Some(SandboxSpec { + command: command.clone(), + ..Default::default() + }), + ..Default::default() + }; + + let env = build_guest_environment(&sandbox, &config, None); + let encoded = env + .iter() + .find_map(|entry| { + entry.strip_prefix(&format!( + "{}=", + openshell_core::sandbox_env::MAIN_PROCESS_SPEC + )) + }) + .expect("main process environment"); + + assert!(!encoded.contains(char::is_whitespace)); + let main = openshell_core::sandbox_env::MainProcessConfig::decode(encoded).unwrap(); + assert_eq!(main.command, command); + } + #[test] fn build_guest_environment_uses_token_file_without_raw_token_env() { let config = VmDriverConfig { @@ -6715,6 +7775,36 @@ mod tests { ))); } + #[test] + fn build_guest_environment_strips_gateway_tls_server_name() { + let config = VmDriverConfig { + openshell_endpoint: "http://127.0.0.1:8080".to_string(), + ..Default::default() + }; + let sandbox = Sandbox { + id: "sandbox-123".to_string(), + name: "sandbox-123".to_string(), + spec: Some(SandboxSpec { + environment: HashMap::from([( + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME.to_string(), + "evil.attacker.example.com".to_string(), + )]), + ..Default::default() + }), + ..Default::default() + }; + + let env = build_guest_environment(&sandbox, &config, None); + + assert!( + !env.iter().any(|v| v.starts_with(&format!( + "{}=", + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME + ))), + "GATEWAY_TLS_SERVER_NAME must be stripped from the guest environment" + ); + } + #[test] fn build_guest_environment_uses_deployment_telemetry_toggle() { let _guard = ENV_LOCK.lock().unwrap(); @@ -6761,6 +7851,36 @@ mod tests { ); } + #[test] + fn build_guest_environment_clears_unsupported_network_capabilities() { + let config = VmDriverConfig { + openshell_endpoint: "http://127.0.0.1:8080".to_string(), + ..Default::default() + }; + let sandbox = Sandbox { + id: "sandbox-123".to_string(), + name: "sandbox-123".to_string(), + spec: Some(SandboxSpec { + environment: HashMap::from([( + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.to_string(), + )]), + ..Default::default() + }), + ..Default::default() + }; + let env = build_guest_environment(&sandbox, &config, None); + assert!(env.contains(&format!( + "{}=", + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES + ))); + assert!(!env.contains(&format!( + "{}={}", + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY + ))); + } + #[test] fn build_guest_environment_uses_endpoint_override_for_tap() { let config = VmDriverConfig { @@ -7543,6 +8663,12 @@ mod tests { } } + fn test_driver_with_proxy(https_proxy: &str) -> VmDriver { + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.https_proxy = Some(https_proxy.to_string()); + driver + } + #[derive(Debug)] struct QemuRequiringExtension { name: String, @@ -7872,4 +8998,466 @@ mod tests { assert!(err.is_resource_exhausted()); assert_eq!(err.message(), "pool empty"); } + + /// A driver config carrying only corporate proxy settings. + fn proxy_config( + https_proxy: Option<&str>, + auth_file: Option<&str>, + ca_bundle: Option<&str>, + ) -> VmDriverConfig { + VmDriverConfig { + openshell_endpoint: "http://127.0.0.1:8080".to_string(), + https_proxy: https_proxy.map(ToString::to_string), + proxy_auth_file: auth_file.map(ToString::to_string), + proxy_auth_allow_insecure: auth_file.map(|_| true), + proxy_ca_bundle: ca_bundle.map(ToString::to_string), + ..Default::default() + } + } + + #[test] + fn driver_config_debug_redacts_the_proxy_url_and_credential_path() { + // `Debug` can be emitted before validation runs, and an unvalidated + // proxy URL may still carry inline `user:pass@` credentials. + let rendered = format!( + "{:?}", + proxy_config( + Some("http://user:secret@proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + Some("/etc/openshell/tls/corp-ca.pem"), + ) + ); + assert!( + !rendered.contains("secret") && !rendered.contains("proxy.corp.test"), + "the proxy URL must be logged as presence only: {rendered}" + ); + assert!( + !rendered.contains("/etc/openshell/secrets/proxy-auth"), + "the credential path must be logged as presence only: {rendered}" + ); + assert!( + rendered.contains("https_proxy: true") && rendered.contains("proxy_auth_file: true"), + "presence of each must still be visible for debugging: {rendered}" + ); + // A CA path is not sensitive and stays readable. + assert!( + rendered.contains("corp-ca.pem"), + "the CA bundle path is not a secret and should stay legible: {rendered}" + ); + } + + #[test] + fn proxy_material_is_staged_inside_the_per_sandbox_overlay() { + // Everything the driver stages lands in the overlay upperdir, which + // lives in the sandbox's own state directory. That is what makes the + // credential removable with the sandbox (remove_sandbox_state_dir + // deletes the whole directory) and unforgeable by the guest image + // (the upperdir shadows the read-only image layer). + for guest_path in [ + GUEST_UPSTREAM_PROXY_AUTH_PATH, + GUEST_PROXY_CA_PATH, + GUEST_SUPERVISOR_ARGS_PATH, + ] { + assert!( + guest_path.starts_with("/opt/openshell/"), + "{guest_path} must be under the reserved guest control root" + ); + assert_eq!( + overlay_upper_path(guest_path), + format!("/upper{guest_path}"), + "{guest_path} must be staged into the overlay upperdir" + ); + } + } + + #[test] + fn upstream_proxy_args_are_empty_without_a_configured_proxy() { + assert!(upstream_proxy_cli_args(&VmDriverConfig::default()).is_empty()); + // The file is still written, empty, so the guest cannot fall back to + // an image-baked argument list. + assert!(render_guest_supervisor_args(&[]).is_empty()); + } + + #[test] + fn upstream_proxy_args_pass_guest_paths_not_host_paths() { + let config = proxy_config( + Some("http://proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + Some("/etc/openshell/tls/corp-ca.pem"), + ); + let args = upstream_proxy_cli_args(&config); + + // The credential and CA live at fixed guest paths; the gateway-host + // paths the operator configured must never reach the guest argv. + let auth = args + .iter() + .position(|arg| arg == "--upstream-proxy-auth-file") + .map(|i| args[i + 1].as_str()); + assert_eq!(auth, Some(GUEST_UPSTREAM_PROXY_AUTH_PATH)); + let ca = args + .iter() + .position(|arg| arg == "--upstream-proxy-ca-bundle") + .map(|i| args[i + 1].as_str()); + assert_eq!(ca, Some(GUEST_PROXY_CA_PATH)); + assert!( + !args + .iter() + .any(|arg| arg.contains("/etc/openshell/secrets") || arg.contains("corp-ca.pem")), + "host paths leaked into the guest argv: {args:?}" + ); + } + + #[test] + fn upstream_proxy_args_pass_only_explicit_opt_ins() { + let mut config = proxy_config(Some("https://proxy.corp.test:3130"), None, None); + config.no_proxy = Some("10.0.0.0/8,.svc.cluster.local".to_string()); + let args = upstream_proxy_cli_args(&config); + assert_eq!( + args, + vec![ + "--upstream-proxy".to_string(), + "https://proxy.corp.test:3130".to_string(), + "--upstream-no-proxy".to_string(), + "10.0.0.0/8,.svc.cluster.local".to_string(), + ] + ); + + // `Some(false)` must not be passed as the presence flag it is on the + // supervisor side. + config.proxy_connect_by_hostname = Some(false); + assert!( + !upstream_proxy_cli_args(&config) + .iter() + .any(|arg| arg == "--upstream-proxy-connect-by-hostname") + ); + config.proxy_connect_by_hostname = Some(true); + assert!( + upstream_proxy_cli_args(&config) + .iter() + .any(|arg| arg == "--upstream-proxy-connect-by-hostname") + ); + } + + #[test] + fn guest_supervisor_args_render_one_argument_per_line() { + let args = vec![ + "--upstream-proxy".to_string(), + "http://proxy.corp.test:3128".to_string(), + "--upstream-no-proxy".to_string(), + "a.example, b.example".to_string(), + ]; + // A value containing a space stays one line, so the guest reads it + // back as a single argument rather than word-splitting it. + assert_eq!( + String::from_utf8(render_guest_supervisor_args(&args)).unwrap(), + "--upstream-proxy\nhttp://proxy.corp.test:3128\n--upstream-no-proxy\na.example, b.example\n" + ); + } + + #[test] + fn guest_supervisor_args_reject_line_breaking_values() { + // A newline would split one operator value into two guest arguments. + for bad in ["a\nb", "a\rb", "a\0b"] { + assert!( + validate_guest_supervisor_args(&[bad.to_string()]).is_err(), + "{bad:?} must be rejected" + ); + } + validate_guest_supervisor_args(&["--upstream-proxy".to_string()]) + .expect("ordinary arguments are accepted"); + } + + #[test] + fn proxy_config_validation_rejects_settings_without_a_proxy_url() { + let config = VmDriverConfig { + no_proxy: Some("10.0.0.0/8".to_string()), + ..Default::default() + }; + let err = config + .validate_proxy_config() + .expect_err("a bypass list without a proxy would hide a fail-open state"); + assert!(err.contains("no_proxy"), "{err}"); + + let config = proxy_config(Some("http://proxy.corp.test:3128"), None, None); + config + .validate_proxy_config() + .expect("a lone proxy URL is a complete configuration"); + } + + #[test] + fn proxy_config_validation_requires_the_cleartext_acknowledgement() { + let mut config = proxy_config( + Some("http://proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + None, + ); + config.proxy_auth_allow_insecure = None; + let err = config + .validate_proxy_config() + .expect_err("Basic auth to an http:// proxy is cleartext on the wire"); + assert!(err.contains("proxy_auth_allow_insecure"), "{err}"); + } + + #[tokio::test] + async fn proxy_ca_bundle_without_a_certificate_fails_the_sandbox() { + let dir = std::env::temp_dir().join(format!("openshell-vm-ca-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("not-a-ca.pem"); + std::fs::write(&path, b"this is not a certificate\n").unwrap(); + + let err = read_sandbox_proxy_ca_bundle(path.to_str().unwrap()) + .await + .expect_err("a certificate-free bundle must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("no PEM certificate"), "{err}"); + + std::fs::write(&path, b"").unwrap(); + let err = read_sandbox_proxy_ca_bundle(path.to_str().unwrap()) + .await + .expect_err("an empty bundle must fail closed"); + assert!(err.message().contains("no PEM certificate"), "{err}"); + + // PEM framing that base64-decodes but is not X.509 DER: accepted by + // `rustls_pemfile` alone, contributes zero trust anchors at runtime, + // and so would make every guest supervisor fail after boot. + std::fs::write( + &path, + b"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + ) + .unwrap(); + let err = read_sandbox_proxy_ca_bundle(path.to_str().unwrap()) + .await + .expect_err("a bundle with invalid DER must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("no usable trust anchors"), "{err}"); + + let err = read_sandbox_proxy_ca_bundle(dir.join("missing.pem").to_str().unwrap()) + .await + .expect_err("an unreadable bundle must fail closed"); + assert!(err.message().contains("could not be read"), "{err}"); + + // A special file must be rejected on its type, not read: an + // unbounded read of /dev/zero would exhaust driver memory. + #[cfg(unix)] + { + let err = read_sandbox_proxy_ca_bundle("/dev/zero") + .await + .expect_err("a non-regular bundle path must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("not a regular file"), "{err}"); + } + + // Oversized regular file: rejected on the stat'd length, again + // without reading it whole. + let oversized = dir.join("oversized.pem"); + let bound = openshell_core::driver_utils::MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES; + std::fs::write(&oversized, vec![b'x'; usize::try_from(bound).unwrap() + 1]).unwrap(); + let err = read_sandbox_proxy_ca_bundle(oversized.to_str().unwrap()) + .await + .expect_err("an oversized bundle must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("exceeds"), "{err}"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn qemu_backend_rejects_a_gateway_host_proxy() { + // gvproxy's host-loopback NAT has no QEMU/TAP equivalent, so a proxy + // on the gateway host is unreachable from a GPU sandbox and must be + // rejected rather than time out on every CONNECT. The address that + // reaches the gateway host from a QEMU guest is this sandbox's own + // TAP host address, so the classifier is parameterized by it. + let tap_host = Some("10.0.128.1"); + for url in [ + "http://host.openshell.internal:8080", + "http://host.containers.internal:8080", + "http://host.docker.internal:8080", + "http://127.0.0.1:8080", + "http://localhost:8080", + "https://[::1]:8080", + // The address the aliases above resolve to inside the guest. + "http://10.0.128.1:8080", + ] { + assert!(proxy_url_targets_gateway_host(url, tap_host), "{url}"); + } + for url in [ + "http://proxy.corp.example:8080", + "https://10.1.2.3:3128", + // Special only to libkrun/gvproxy. On QEMU/TAP it is an ordinary + // address that may be routable through the guest's masqueraded + // egress, so rejecting it would refuse a working configuration. + "http://192.168.127.254:8080", + // Another sandbox's TAP host, not this one's. + "http://10.0.128.5:8080", + "not a url", + ] { + assert!(!proxy_url_targets_gateway_host(url, tap_host), "{url}"); + } + + // Without an allocated TAP host only the address-independent + // spellings classify; the loopback and alias guards still hold. + assert!(proxy_url_targets_gateway_host( + "http://127.0.0.1:8080", + None + )); + assert!(proxy_url_targets_gateway_host( + "http://host.openshell.internal:8080", + None + )); + assert!(!proxy_url_targets_gateway_host( + "http://10.0.128.1:8080", + None + )); + } + + #[test] + fn qemu_launch_plan_rejects_a_proxy_at_the_allocated_tap_host() { + // The preflight has to run against the address this sandbox actually + // got, which only exists once the launch plan's subnet is allocated. + // A proxy there is what `host.openshell.internal` resolves to in the + // guest, and the driver's own nftables input chain drops the port. + let probe = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + let tap_host = probe + .build_vm_launch_plan("sandbox-proxy-tap", true, true, None) + .expect("gpu plan should build") + .host_ip + .expect("a QEMU plan carries a TAP host address"); + probe.release_subnet("sandbox-proxy-tap"); + + let driver = test_driver_with_proxy(&format!("http://{tap_host}:8080")); + let mut plan = driver + .build_vm_launch_plan("sandbox-proxy-tap", true, true, None) + .expect("gpu plan should build"); + assert_eq!(plan.host_ip.as_deref(), Some(tap_host.as_str())); + + let err = driver + .resolve_launch_plan_backend("sandbox-proxy-tap", true, None, &mut plan) + .expect_err("a proxy at the TAP host address is unreachable from the guest"); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains(&tap_host), "{err}"); + + driver.release_subnet("sandbox-proxy-tap"); + } + + #[test] + fn qemu_launch_plan_allows_a_proxy_at_the_gvproxy_host_loopback_address() { + // 192.168.127.254 carries no meaning on QEMU/TAP, so a launch must + // proceed rather than be refused for a libkrun-only reason. + let driver = test_driver_with_proxy(&format!("http://{GVPROXY_HOST_LOOPBACK_IP}:8080")); + let mut plan = driver + .build_vm_launch_plan("sandbox-proxy-gvproxy", true, true, None) + .expect("gpu plan should build"); + assert_ne!(plan.host_ip.as_deref(), Some(GVPROXY_HOST_LOOPBACK_IP)); + + driver + .resolve_launch_plan_backend("sandbox-proxy-gvproxy", true, None, &mut plan) + .expect("a routable proxy address must not block a GPU launch"); + assert_eq!(plan.backend, VmBackend::Qemu); + + driver.release_subnet("sandbox-proxy-gvproxy"); + } + + #[tokio::test] + async fn proxy_credential_is_validated_against_the_supervisor_rules() { + let dir = std::env::temp_dir().join(format!("openshell-vm-cred-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("proxy-auth"); + + std::fs::write(&path, "proxyuser:proxypass\n").unwrap(); + assert_eq!( + read_sandbox_proxy_credential(path.to_str().unwrap()) + .await + .expect("a well-formed credential is accepted"), + "proxyuser:proxypass" + ); + + // Rejected here rather than inside every sandbox's supervisor. + std::fs::write(&path, "no-separator\n").unwrap(); + let err = read_sandbox_proxy_credential(path.to_str().unwrap()) + .await + .expect_err("a malformed credential must fail closed"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!( + !err.message().contains("no-separator"), + "the error must not echo credential file contents: {err}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn guest_environment_carries_no_corporate_proxy_settings() { + // The egress boundary is argv-only: `build_guest_environment` merges + // user-supplied environment, so anything it emitted here would be + // attacker-influenced. + let config = proxy_config( + Some("http://proxy.corp.test:3128"), + Some("/etc/openshell/secrets/proxy-auth"), + Some("/etc/openshell/tls/corp-ca.pem"), + ); + let sandbox = Sandbox { + id: "sb-proxy".to_string(), + name: "proxy".to_string(), + spec: Some(SandboxSpec { + environment: [ + ( + "HTTPS_PROXY".to_string(), + "http://attacker:3128".to_string(), + ), + ("NO_PROXY".to_string(), "*".to_string()), + ] + .into_iter() + .collect(), + ..Default::default() + }), + ..Default::default() + }; + + let env = build_guest_environment(&sandbox, &config, None); + assert!( + !env.iter().any(|entry| entry.starts_with("--upstream")), + "driver environment must never carry supervisor arguments: {env:?}" + ); + // A sandbox may still set the conventional variables for its own + // workload, but the supervisor ignores them on this path -- what + // matters is that the driver never derives the boundary from them. + assert!( + !env.iter() + .any(|entry| entry.contains("proxy.corp.test") || entry.contains("proxy-auth")), + "operator proxy settings must not reach the guest environment: {env:?}" + ); + } + + #[test] + fn sandbox_driver_config_cannot_carry_proxy_settings() { + // The upstream proxy is host network topology, not a per-sandbox + // setting: the caller-supplied envelope must reject it outright + // rather than silently ignoring it. + for key in [ + "https_proxy", + "no_proxy", + "proxy_auth_file", + "proxy_auth_allow_insecure", + "proxy_connect_by_hostname", + "proxy_ca_bundle", + ] { + let template = SandboxTemplate { + driver_config: Some(Struct { + fields: std::iter::once(( + key.to_string(), + Value { + kind: Some(Kind::StringValue("http://attacker:3128".to_string())), + }, + )) + .collect(), + }), + ..Default::default() + }; + assert!( + VmSandboxDriverConfig::from_template(&template).is_err(), + "template.driver_config.vm must reject '{key}'" + ); + } + } } diff --git a/crates/openshell-driver-vm/src/lib.rs b/crates/openshell-driver-vm/src/lib.rs index 98ba6b0c9a..f34c7dda8d 100644 --- a/crates/openshell-driver-vm/src/lib.rs +++ b/crates/openshell-driver-vm/src/lib.rs @@ -1,6 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// `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 +// a telemetry-on build that reads as telemetry-free. Fail the build instead. +#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] +compile_error!( + "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ + build a telemetry-free VM driver with `--no-default-features --features defaults-without-telemetry`" +); + pub mod driver; mod embedded_runtime; mod ffi; diff --git a/crates/openshell-driver-vm/src/lifecycle.rs b/crates/openshell-driver-vm/src/lifecycle.rs index 646070c3a1..25ec91db67 100644 --- a/crates/openshell-driver-vm/src/lifecycle.rs +++ b/crates/openshell-driver-vm/src/lifecycle.rs @@ -32,6 +32,8 @@ pub enum LaunchAbortReason { /// opportunity to release host resources they allocated in /// [`LifecycleExtension::before_launch`]. ProcessExited, + /// The gateway intentionally stopped the sandbox while retaining disk state. + Stopped, } #[derive(Debug, Clone)] diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index 949d4ce05c..b8788e4fc9 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -6,7 +6,6 @@ use futures::Stream; use miette::{IntoDiagnostic, Result}; use openshell_core::VERSION; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; -use openshell_driver_vm::otel_tracing::compute_driver_rpc_layer; #[cfg(target_os = "macos")] use openshell_driver_vm::{VM_RUNTIME_DIR_ENV, configured_runtime_dir}; use openshell_driver_vm::{VmBackend, VmDriver, VmDriverConfig, VmLaunchConfig, procguard, run_vm}; @@ -18,8 +17,6 @@ use std::pin::Pin; use std::task::{Context, Poll}; use tokio::net::{UnixListener, UnixStream}; use tracing::info; -use tracing_subscriber::EnvFilter; -use tracing_subscriber::prelude::*; #[derive(Parser, Debug)] #[command(name = "openshell-driver-vm")] @@ -91,6 +88,9 @@ struct Args { #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] otlp_endpoint: Option, + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + #[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")] openshell_endpoint: Option, @@ -143,6 +143,30 @@ struct Args { #[arg(long, env = "OPENSHELL_VM_SANDBOX_GID")] sandbox_gid: Option, + // Corporate forward proxy for sandbox egress. Operator-owned: these reach + // the guest supervisor on its argv, which the sandbox image and the + // user-supplied environment cannot influence. + #[arg(long, env = "OPENSHELL_VM_HTTPS_PROXY")] + https_proxy: Option, + + #[arg(long, env = "OPENSHELL_VM_NO_PROXY")] + no_proxy: Option, + + #[arg(long, env = "OPENSHELL_VM_PROXY_AUTH_FILE")] + proxy_auth_file: Option, + + // Value-taking rather than a presence flag so an explicit `false` in + // `[openshell.drivers.vm]` survives the gateway -> driver hop and still + // trips the "acknowledgement without a credential" check. + #[arg(long, env = "OPENSHELL_VM_PROXY_AUTH_ALLOW_INSECURE")] + proxy_auth_allow_insecure: Option, + + #[arg(long, env = "OPENSHELL_VM_PROXY_CONNECT_BY_HOSTNAME")] + proxy_connect_by_hostname: Option, + + #[arg(long, env = "OPENSHELL_VM_PROXY_CA_BUNDLE")] + proxy_ca_bundle: Option, + #[arg(long, hide = true)] vm_backend: Option, @@ -186,22 +210,15 @@ async fn main() -> Result<()> { return Ok(()); } - let (tracer_provider, setup_error) = - openshell_driver_vm::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); - tracing_subscriber::registry() - .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) - .with(tracing_subscriber::fmt::layer()) - .with( - tracer_provider - .as_ref() - .map(openshell_driver_vm::otel_tracing::layer), - ) - .init(); - if let Some(error) = setup_error { - tracing::error!(%error, "OTLP exporting could not be started"); - } else if let Some(endpoint) = &args.otlp_endpoint { - info!(endpoint, "OTLP exporting enabled"); - } + let _tracing = openshell_otel::install_driver_tracing( + openshell_driver_vm::otel_tracing::TRACING, + openshell_otel::DriverTracingConfig { + endpoint: args.otlp_endpoint.as_deref(), + gateway_name: args.gateway_name.as_deref(), + service_version: VERSION, + log_level: &args.log_level, + }, + ); let listen_mode = compute_driver_listen_mode(&args).map_err(|err| miette::miette!("{err}"))?; @@ -238,11 +255,17 @@ async fn main() -> Result<()> { gpu_vcpus: args.gpu_vcpus, sandbox_uid: args.sandbox_uid, sandbox_gid: args.sandbox_gid, + https_proxy: args.https_proxy.clone(), + no_proxy: args.no_proxy.clone(), + proxy_auth_file: args.proxy_auth_file.clone(), + proxy_auth_allow_insecure: args.proxy_auth_allow_insecure, + proxy_connect_by_hostname: args.proxy_connect_by_hostname, + proxy_ca_bundle: args.proxy_ca_bundle.clone(), }) .await .map_err(|err| miette::miette!("{err}"))?; - let result = match listen_mode { + match listen_mode { ComputeDriverListenMode::Unix { socket_path, expected_peer_pid, @@ -253,7 +276,7 @@ async fn main() -> Result<()> { let listener = UnixListener::bind(&socket_path).into_diagnostic()?; restrict_socket_permissions(&socket_path).map_err(|err| miette::miette!("{err}"))?; let result = tonic::transport::Server::builder() - .layer(compute_driver_rpc_layer()) + .layer(openshell_otel::compute_driver_rpc_layer()) .add_service(ComputeDriverServer::new(driver)) .serve_with_incoming_shutdown( AuthenticatedUnixIncoming::new(listener, expected_peer_pid), @@ -267,19 +290,13 @@ async fn main() -> Result<()> { ComputeDriverListenMode::Tcp(bind_address) => { info!(address = %bind_address, "Starting unauthenticated dev vm compute driver"); tonic::transport::Server::builder() - .layer(compute_driver_rpc_layer()) + .layer(openshell_otel::compute_driver_rpc_layer()) .add_service(ComputeDriverServer::new(driver)) .serve_with_shutdown(bind_address, shutdown_signal()) .await .into_diagnostic() } - }; - if let Some(provider) = &tracer_provider - && let Err(error) = provider.shutdown() - { - tracing::warn!(%error, "OTLP tracer provider shutdown failed"); } - result } async fn shutdown_signal() { @@ -615,6 +632,63 @@ mod tests { use clap::Parser; use std::path::PathBuf; + #[test] + fn corporate_proxy_flags_parse_into_driver_settings() { + let args = Args::parse_from([ + "openshell-driver-vm", + "--openshell-endpoint", + "https://host.openshell.internal:17670", + "--https-proxy", + "http://proxy.corp.com:8080", + "--no-proxy", + "10.0.0.0/8,.svc.cluster.local", + "--proxy-auth-file", + "/etc/openshell/secrets/proxy-auth", + "--proxy-auth-allow-insecure", + "true", + "--proxy-connect-by-hostname", + "false", + "--proxy-ca-bundle", + "/etc/openshell/tls/proxy-ca.pem", + ]); + + assert_eq!( + args.https_proxy.as_deref(), + Some("http://proxy.corp.com:8080") + ); + assert_eq!( + args.no_proxy.as_deref(), + Some("10.0.0.0/8,.svc.cluster.local") + ); + assert_eq!( + args.proxy_auth_file.as_deref(), + Some("/etc/openshell/secrets/proxy-auth") + ); + assert_eq!(args.proxy_auth_allow_insecure, Some(true)); + // Value-taking rather than a presence flag, so the gateway can + // forward an explicit `false` from `[openshell.drivers.vm]`. + assert_eq!(args.proxy_connect_by_hostname, Some(false)); + assert_eq!( + args.proxy_ca_bundle.as_deref(), + Some("/etc/openshell/tls/proxy-ca.pem") + ); + } + + #[test] + fn corporate_proxy_settings_default_to_unset() { + let args = Args::parse_from([ + "openshell-driver-vm", + "--openshell-endpoint", + "https://host.openshell.internal:17670", + ]); + assert!(args.https_proxy.is_none()); + assert!(args.no_proxy.is_none()); + assert!(args.proxy_auth_file.is_none()); + assert!(args.proxy_auth_allow_insecure.is_none()); + assert!(args.proxy_connect_by_hostname.is_none()); + assert!(args.proxy_ca_bundle.is_none()); + } + #[test] fn peer_authorization_accepts_matching_uid_and_pid() { authorize_peer_credentials( @@ -691,11 +765,13 @@ mod tests { } #[test] - fn accepts_gateway_otlp_endpoint() { + fn accepts_gateway_otlp_configuration() { let args = Args::try_parse_from([ "openshell-driver-vm", "--otlp-endpoint", "http://127.0.0.1:4317", + "--gateway-name", + "production-us-west", ]); assert!( args.is_ok(), diff --git a/crates/openshell-driver-vm/src/otel_tracing.rs b/crates/openshell-driver-vm/src/otel_tracing.rs index fac2720cd7..a931ff502e 100644 --- a/crates/openshell-driver-vm/src/otel_tracing.rs +++ b/crates/openshell-driver-vm/src/otel_tracing.rs @@ -1,267 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OpenTelemetry trace exporting. +//! OpenTelemetry tracing identity for the VM compute driver. -use http::Request; -use openshell_otel::{ - HeaderMapExtractor, OtlpTraceConfig, RecordGrpcFailure, SdkTracerProvider, ServiceName, - SetupError, -}; -use opentelemetry::propagation::TextMapPropagator; -use opentelemetry::trace::TraceContextExt as _; -use opentelemetry_sdk::propagation::TraceContextPropagator; -use tower_http::trace::{GrpcMakeClassifier, MakeSpan, TraceLayer}; -use tracing::{Span, Subscriber}; -use tracing_opentelemetry::OpenTelemetrySpanExt as _; -use tracing_subscriber::registry::LookupSpan; - -const SERVICE_NAME: &str = "openshell-driver-vm"; -const INSTRUMENTATION_SCOPE: &str = "openshell-driver-vm"; -const COMPUTE_DRIVER_SERVICE: &str = "openshell.compute.v1.ComputeDriver"; - -/// Trace every inbound compute-driver RPC at the tonic service boundary. -pub fn compute_driver_rpc_layer() --> TraceLayer { - TraceLayer::new_for_grpc() - .make_span_with(ComputeDriverRpcSpan) - .on_request(()) - .on_response(()) - .on_body_chunk(()) - .on_eos(()) - .on_failure(RecordGrpcFailure) -} - -/// Creates the server span for an inbound compute-driver request. -#[derive(Debug, Clone, Copy)] -pub struct ComputeDriverRpcSpan; - -impl MakeSpan for ComputeDriverRpcSpan { - fn make_span(&mut self, request: &Request) -> Span { - let (operation, method) = compute_driver_rpc_operation(request.uri().path()); - let span = tracing::info_span!( - "driver_rpc", - otel.name = operation, - otel.kind = "server", - otel.status_code = tracing::field::Empty, - rpc.system = "grpc", - rpc.service = COMPUTE_DRIVER_SERVICE, - rpc.method = method, - rpc.grpc.status_code = tracing::field::Empty, - ); - let parent = TraceContextPropagator::new().extract_with_context( - &opentelemetry::Context::new(), - &HeaderMapExtractor::new(request.headers()), - ); - if parent.span().span_context().is_valid() { - let _ = span.set_parent(parent); - } - span - } -} - -fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'static str) { - match path.rsplit('/').next() { - Some("GetCapabilities") => ("driver.get_capabilities", "get_capabilities"), - Some("GetGatewayListenerRequirements") => ( - "driver.get_gateway_listener_requirements", - "get_gateway_listener_requirements", - ), - Some("ValidateSandboxCreate") => { - ("driver.validate_sandbox_create", "validate_sandbox_create") - } - Some("CreateSandbox") => ("driver.create_sandbox", "create_sandbox"), - Some("GetSandbox") => ("driver.get_sandbox", "get_sandbox"), - Some("ListSandboxes") => ("driver.list_sandboxes", "list_sandboxes"), - Some("StopSandbox") => ("driver.stop_sandbox", "stop_sandbox"), - Some("DeleteSandbox") => ("driver.delete_sandbox", "delete_sandbox"), - Some("WatchSandboxes") => ("driver.watch_sandboxes", "watch_sandboxes"), - _ => ("driver.unknown", "unknown"), - } -} - -/// Build a tracer provider for the configured OTLP/gRPC endpoint. -#[must_use] -pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { - openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { - endpoint, - service_name: ServiceName::Fixed(SERVICE_NAME), - service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), - })) -} - -/// Build the tracing layer that exports VM-driver spans. -pub fn layer(provider: &SdkTracerProvider) -> openshell_otel::OtlpLayer -where - S: Subscriber + for<'span> LookupSpan<'span>, -{ - openshell_otel::layer(provider, INSTRUMENTATION_SCOPE) -} +pub const TRACING: openshell_otel::ComputeDriverTracing = openshell_otel::compute_driver_tracing!(); #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; - - use opentelemetry_proto::tonic::collector::trace::v1::{ - ExportTraceServiceRequest, ExportTraceServiceResponse, - trace_service_server::{TraceService, TraceServiceServer}, - }; - use opentelemetry_proto::tonic::trace::v1::Span; - use tracing_subscriber::layer::SubscriberExt as _; - - #[derive(Default)] - struct Received { - spans: Vec, - service_names: Vec, - } - - #[derive(Clone)] - struct Collector { - received: Arc>, - exported: Arc, - } - - #[tonic::async_trait] - impl TraceService for Collector { - async fn export( - &self, - request: tonic::Request, - ) -> Result, tonic::Status> { - { - let mut received = self.received.lock().unwrap(); - for resource_span in request.into_inner().resource_spans { - if let Some(resource) = resource_span.resource { - received.service_names.extend( - resource - .attributes - .into_iter() - .filter(|attribute| attribute.key == "service.name") - .filter_map(|attribute| attribute.value) - .filter_map(|value| value.value) - .filter_map(|value| match value { - opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) => Some(value), - _ => None, - }), - ); - } - for scope_span in resource_span.scope_spans { - received.spans.extend(scope_span.spans); - } - } - } - self.exported.notify_one(); - Ok(tonic::Response::new(ExportTraceServiceResponse::default())) - } - } - - #[test] - fn compute_driver_rpc_names_are_explicitly_mapped_and_schema_bounded() { - for (rpc, operation, method) in [ - ( - "GetCapabilities", - "driver.get_capabilities", - "get_capabilities", - ), - ( - "GetGatewayListenerRequirements", - "driver.get_gateway_listener_requirements", - "get_gateway_listener_requirements", - ), - ( - "ValidateSandboxCreate", - "driver.validate_sandbox_create", - "validate_sandbox_create", - ), - ("CreateSandbox", "driver.create_sandbox", "create_sandbox"), - ("GetSandbox", "driver.get_sandbox", "get_sandbox"), - ("ListSandboxes", "driver.list_sandboxes", "list_sandboxes"), - ("StopSandbox", "driver.stop_sandbox", "stop_sandbox"), - ("DeleteSandbox", "driver.delete_sandbox", "delete_sandbox"), - ( - "WatchSandboxes", - "driver.watch_sandboxes", - "watch_sandboxes", - ), - ] { - assert_eq!( - super::compute_driver_rpc_operation(&format!( - "/openshell.compute.v1.ComputeDriver/{rpc}" - )), - (operation, method), - "{rpc} must keep an explicit low-cardinality span identity" - ); - } - assert_eq!( - super::compute_driver_rpc_operation( - "/openshell.compute.v1.ComputeDriver/AttackerControlled12345" - ), - ("driver.unknown", "unknown"), - "paths absent from the protobuf schema must not create span names" - ); - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn vm_driver_spans_reach_otlp_collector_with_distinct_service_name() { - let received = Arc::new(Mutex::new(Received::default())); - let exported = Arc::new(tokio::sync::Notify::new()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let collector = Collector { - received: Arc::clone(&received), - exported: Arc::clone(&exported), - }; - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let server = tokio::spawn(async move { - tonic::transport::Server::builder() - .add_service(TraceServiceServer::new(collector)) - .serve_with_incoming_shutdown( - tokio_stream::wrappers::TcpListenerStream::new(listener), - async { - let _ = shutdown_rx.await; - }, - ) - .await - }); - - let (provider, error) = super::provider_for(Some(&format!("http://{address}"))); - assert!(error.is_none(), "valid OTLP endpoint should configure"); - let provider = provider.expect("provider"); - let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); - tracing::subscriber::with_default(subscriber, || { - let span = tracing::info_span!("vm.provision", sandbox.id = "sb-otlp"); - drop(span.enter()); - drop(span); - }); - let export_completed = exported.notified(); - provider.force_flush().unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(5), export_completed) - .await - .expect("OTLP export should complete"); - provider.shutdown().unwrap(); - - shutdown_tx - .send(()) - .expect("collector server should be running"); - tokio::time::timeout(std::time::Duration::from_secs(5), server) - .await - .expect("collector server shutdown should not deadlock") - .expect("collector server task should not panic") - .expect("collector server should shut down cleanly"); - - let received = received.lock().unwrap(); - received - .spans - .iter() - .find(|span| span.name == "vm.provision") - .expect("VM span should reach collector"); - assert!( - received - .service_names - .iter() - .any(|name| name == "openshell-driver-vm"), - "VM spans should use a distinct service name, got {:?}", - received.service_names - ); + async fn driver_spans_reach_otlp_collector_with_resource_identity() { + openshell_otel_test_support::assert_compute_driver_tracing( + super::TRACING, + openshell_core::VERSION, + "vm.provision", + ) + .await; } } diff --git a/crates/openshell-driver-vm/src/rootfs.rs b/crates/openshell-driver-vm/src/rootfs.rs index c71ebe6884..9046913c9d 100644 --- a/crates/openshell-driver-vm/src/rootfs.rs +++ b/crates/openshell-driver-vm/src/rootfs.rs @@ -15,8 +15,9 @@ const UMOCI: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/umoci.zst")); const ROOTFS_VARIANT_MARKER: &str = ".openshell-rootfs-variant"; const SANDBOX_GUEST_INIT_PATH: &str = "/srv/openshell-vm-sandbox-init.sh"; const SANDBOX_SUPERVISOR_PATH: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_BINARY; -const SANDBOX_UMOCI_PATH: &str = "/opt/openshell/bin/umoci"; -const SANDBOX_OWNER_NORMALIZED_MARKER: &str = "/opt/openshell/.sandbox-owner-normalized"; +const SANDBOX_UMOCI_PATH: &str = openshell_core::container_paths::VM_UMOCI_PATH; +const SANDBOX_OWNER_NORMALIZED_MARKER: &str = + openshell_core::container_paths::VM_SANDBOX_OWNER_NORMALIZED_MARKER; const ROOTFS_IMAGE_MIN_SIZE_BYTES: u64 = 512 * 1024 * 1024; const ROOTFS_IMAGE_MIN_HEADROOM_BYTES: u64 = 256 * 1024 * 1024; const EXT4_IMAGE_MIN_HEADROOM_BYTES: u64 = 16 * 1024 * 1024; diff --git a/crates/openshell-extension-core/Cargo.toml b/crates/openshell-extension-core/Cargo.toml new file mode 100644 index 0000000000..babbeca295 --- /dev/null +++ b/crates/openshell-extension-core/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-extension-core" +description = "Shared extension identity, authentication, and transport primitives for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +hyper-util = { workspace = true, features = ["tokio"] } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tower = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +rcgen = { workspace = true } +rustls = { workspace = true } +serde_json = { workspace = true } +tokio-stream = { workspace = true, features = ["net"] } +tonic = { workspace = true, features = ["server", "tls-native-roots"] } + +[lints] +workspace = true diff --git a/crates/openshell-extension-core/README.md b/crates/openshell-extension-core/README.md new file mode 100644 index 0000000000..e8f342bbd4 --- /dev/null +++ b/crates/openshell-extension-core/README.md @@ -0,0 +1,25 @@ +# OpenShell extension core + +`openshell-extension-core` contains protocol-neutral primitives shared by two or +more OpenShell extension mechanisms. It currently owns extension identity and +audience values, the extension JWT claim contract, refreshable bearer +credentials and the per-service store that holds them, and outbound gRPC +transport construction for HTTP, HTTPS, and Unix sockets. + +`ExtensionKind` is non-exhaustive so additional extension points can join the +shared identity and audience model without breaking consumers. A new kind must +still define its own registration, transport, and authorization semantics +before gateway wiring accepts it. + +Outbound HTTPS authentication is selected through the shared +`ExtensionServerTrust` policy. Platform roots and operator-provided CA bundles +are implemented today. The non-exhaustive policy boundary allows future +SPIFFE X.509-SVID or public-key pinning support to be added once without +changing middleware and interceptor clients independently. Unix sockets retain +their local operating-system access boundary. + +Middleware- or interceptor-specific protobuf clients, policy selection, +orchestration, and lifecycle management stay in their owning crates. Gateway +signing authority also stays in `openshell-server`. This ownership rule keeps +this crate from becoming a general-purpose dumping ground as extension support +grows. diff --git a/crates/openshell-extension-core/src/auth.rs b/crates/openshell-extension-core/src/auth.rs new file mode 100644 index 0000000000..cc559754a3 --- /dev/null +++ b/crates/openshell-extension-core/src/auth.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tonic::metadata::AsciiMetadataValue; +use tonic::{Request, Status}; + +#[derive(Clone)] +pub struct BearerTokenSlot { + inner: Arc>>, +} + +#[derive(Clone)] +struct Token { + authorization: AsciiMetadataValue, + expires_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TokenSlotError { + #[error("extension bearer token is not valid for an HTTP authorization header")] + InvalidToken, + #[error("extension bearer token expiry must be a positive Unix timestamp in milliseconds")] + InvalidExpiry, +} + +impl BearerTokenSlot { + /// Create an empty slot. Requests fail closed until [`Self::update`] is called. + pub fn empty() -> Self { + Self { + inner: Arc::new(RwLock::new(None)), + } + } + + pub fn new(token: &str, expires_at_ms: i64) -> Result { + let slot = Self::empty(); + slot.update(token, expires_at_ms)?; + Ok(slot) + } + + /// Replace the credential without rebuilding channels or generated clients. + pub fn update(&self, token: &str, expires_at_ms: i64) -> Result<(), TokenSlotError> { + if expires_at_ms <= 0 { + return Err(TokenSlotError::InvalidExpiry); + } + if token.is_empty() || token.bytes().any(|byte| byte.is_ascii_whitespace()) { + return Err(TokenSlotError::InvalidToken); + } + let authorization = AsciiMetadataValue::try_from(format!("Bearer {token}")) + .map_err(|_| TokenSlotError::InvalidToken)?; + *self + .inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Token { + authorization, + expires_at_ms, + }); + Ok(()) + } + + /// Remove the credential immediately. Subsequent requests fail closed. + pub fn clear(&self) { + *self + .inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } + + pub fn expires_at_ms(&self) -> Option { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|token| token.expires_at_ms) + } + + pub fn interceptor(&self) -> BearerTokenInterceptor { + BearerTokenInterceptor { + slot: Some(self.clone()), + } + } + + fn authorization_at(&self, now_ms: i64) -> Result { + let guard = self + .inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let token = guard + .as_ref() + .ok_or_else(|| Status::unauthenticated("extension bearer token is unavailable"))?; + if token.expires_at_ms <= now_ms { + return Err(Status::unauthenticated( + "extension bearer token has expired", + )); + } + Ok(token.authorization.clone()) + } +} + +impl Default for BearerTokenSlot { + fn default() -> Self { + Self::empty() + } +} + +impl fmt::Debug for BearerTokenSlot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BearerTokenSlot") + .field("expires_at_ms", &self.expires_at_ms()) + .finish_non_exhaustive() + } +} + +#[derive(Clone)] +pub struct BearerTokenInterceptor { + slot: Option, +} + +impl BearerTokenInterceptor { + /// Create an explicit no-op interceptor for legacy registrations that have + /// not opted into audience authentication. + pub const fn disabled() -> Self { + Self { slot: None } + } +} + +impl fmt::Debug for BearerTokenInterceptor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BearerTokenInterceptor") + .field("enabled", &self.slot.is_some()) + .field("slot", &self.slot) + .finish() + } +} + +impl tonic::service::Interceptor for BearerTokenInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + let Some(slot) = &self.slot else { + return Ok(request); + }; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }); + let authorization = slot.authorization_at(now_ms)?; + request + .metadata_mut() + .insert("authorization", authorization); + Ok(request) + } +} + +#[cfg(test)] +mod tests { + use tonic::service::Interceptor; + + use super::*; + + #[test] + fn slot_rotates_all_interceptor_clones_in_place() { + let slot = BearerTokenSlot::new("first-secret", i64::MAX).unwrap(); + let mut first = slot.interceptor(); + let mut second = first.clone(); + + assert_eq!( + first + .call(Request::new(())) + .unwrap() + .metadata() + .get("authorization") + .unwrap(), + "Bearer first-secret" + ); + slot.update("second-secret", i64::MAX).unwrap(); + assert_eq!( + second + .call(Request::new(())) + .unwrap() + .metadata() + .get("authorization") + .unwrap(), + "Bearer second-secret" + ); + } + + #[test] + fn empty_expired_and_cleared_slots_fail_closed() { + let slot = BearerTokenSlot::empty(); + assert_eq!( + slot.interceptor() + .call(Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + + slot.update("expired", 1).unwrap(); + assert_eq!( + slot.interceptor() + .call(Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + + slot.update("current", i64::MAX).unwrap(); + slot.clear(); + assert_eq!( + slot.interceptor() + .call(Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } + + #[test] + fn debug_and_errors_do_not_expose_token_material() { + let secret = "super-secret-extension-token"; + let slot = BearerTokenSlot::new(secret, i64::MAX).unwrap(); + assert!(!format!("{slot:?}").contains(secret)); + assert!(!format!("{:?}", slot.interceptor()).contains(secret)); + + let error = BearerTokenSlot::new("contains\nnewline", i64::MAX).unwrap_err(); + assert!(!error.to_string().contains("contains")); + assert_eq!( + BearerTokenSlot::new("", i64::MAX).unwrap_err(), + TokenSlotError::InvalidToken + ); + } + + #[test] + fn disabled_interceptor_leaves_authorization_untouched() { + let mut interceptor = BearerTokenInterceptor::disabled(); + let mut request = Request::new(()); + request + .metadata_mut() + .insert("authorization", "Bearer caller-value".parse().unwrap()); + let request = interceptor.call(request).unwrap(); + assert_eq!( + request.metadata().get("authorization").unwrap(), + "Bearer caller-value" + ); + assert!(format!("{interceptor:?}").contains("enabled: false")); + } +} diff --git a/crates/openshell-extension-core/src/identity.rs b/crates/openshell-extension-core/src/identity.rs new file mode 100644 index 0000000000..88de7ec867 --- /dev/null +++ b/crates/openshell-extension-core/src/identity.rs @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Extension mechanism that owns a service registration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExtensionKind { + Middleware, + Interceptor, +} + +impl ExtensionKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Middleware => "middleware", + Self::Interceptor => "interceptor", + } + } +} + +impl fmt::Display for ExtensionKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// A gateway-owned registration name for a middleware or interceptor service. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ExtensionIdentity(String); + +/// The exact JWT audience expected by an extension service. +/// +/// Audiences are intentionally opaque. Callers must resolve them from trusted +/// gateway configuration rather than constructing them from untrusted input. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ExtensionAudience(String); + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum IdentityError { + #[error("extension {kind} must not be empty")] + Empty { kind: &'static str }, + #[error("extension {kind} must not have leading or trailing whitespace")] + SurroundingWhitespace { kind: &'static str }, + #[error("extension {kind} must not contain control characters")] + ControlCharacter { kind: &'static str }, +} + +macro_rules! opaque_value { + ($ty:ident, $kind:literal) => { + impl $ty { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate(&value, $kind)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + } + + impl fmt::Debug for $ty { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple(stringify!($ty)) + .field(&self.0) + .finish() + } + } + + impl fmt::Display for $ty { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + + impl AsRef for $ty { + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl FromStr for $ty { + type Err = IdentityError; + + fn from_str(value: &str) -> Result { + Self::new(value) + } + } + }; +} + +opaque_value!(ExtensionIdentity, "identity"); +opaque_value!(ExtensionAudience, "audience"); + +impl ExtensionAudience { + /// Build the deterministic fallback audience for a validated registration. + /// + /// Explicit operator-configured audiences remain opaque and take precedence. + /// This helper gives both extension mechanisms the same fallback namespace. + pub fn for_registration( + kind: ExtensionKind, + registration_name: &str, + ) -> Result { + let identity = ExtensionIdentity::new(registration_name)?; + Ok(Self(format!("urn:openshell:extension:{kind}:{identity}"))) + } +} + +fn validate(value: &str, kind: &'static str) -> Result<(), IdentityError> { + if value.is_empty() { + return Err(IdentityError::Empty { kind }); + } + if value.trim() != value { + return Err(IdentityError::SurroundingWhitespace { kind }); + } + if value.chars().any(char::is_control) { + return Err(IdentityError::ControlCharacter { kind }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_and_audience_are_opaque_exact_values() { + let identity = ExtensionIdentity::new("content-filter").unwrap(); + let audience = ExtensionAudience::new("https://filters.example/openshell").unwrap(); + assert_eq!(identity.as_str(), "content-filter"); + assert_eq!(audience.as_str(), "https://filters.example/openshell"); + } + + #[test] + fn values_reject_ambiguous_whitespace_and_controls() { + assert_eq!( + ExtensionIdentity::new(" content-filter").unwrap_err(), + IdentityError::SurroundingWhitespace { kind: "identity" } + ); + assert_eq!( + ExtensionAudience::new("audience\n").unwrap_err(), + IdentityError::SurroundingWhitespace { kind: "audience" } + ); + assert_eq!( + ExtensionAudience::new("").unwrap_err(), + IdentityError::Empty { kind: "audience" } + ); + } + + #[test] + fn fallback_audience_is_kind_scoped_and_validates_registration() { + assert_eq!( + ExtensionAudience::for_registration(ExtensionKind::Middleware, "content-filter") + .unwrap() + .as_str(), + "urn:openshell:extension:middleware:content-filter" + ); + assert_eq!( + ExtensionAudience::for_registration(ExtensionKind::Interceptor, "content-filter") + .unwrap() + .as_str(), + "urn:openshell:extension:interceptor:content-filter" + ); + assert!(matches!( + ExtensionAudience::for_registration(ExtensionKind::Middleware, " bad-name"), + Err(IdentityError::SurroundingWhitespace { kind: "identity" }) + )); + } +} diff --git a/crates/openshell-extension-core/src/jwt.rs b/crates/openshell-extension-core/src/jwt.rs new file mode 100644 index 0000000000..4bf5d39189 --- /dev/null +++ b/crates/openshell-extension-core/src/jwt.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Maximum accepted lifetime for an extension bearer token. +/// +/// Extension credentials cross the gateway trust boundary and must remain +/// short-lived even when legacy sandbox bootstrap credentials do not expire. +pub const MAX_EXTENSION_TOKEN_TTL: Duration = Duration::from_secs(3_600); + +/// Explicit `typ` header value carried by every extension bearer token. +/// +/// Extension tokens and sandbox-to-gateway bootstrap tokens are signed by the +/// same key and differ only in their audience. Explicit typing (RFC 8725 +/// section 3.11) gives verifiers a second, independent discriminator: a +/// service that requires this `typ` cannot accept a sandbox bootstrap +/// credential even if it forgets to check `aud`. +pub const EXTENSION_JWT_TYP: &str = "openshell-ext+jwt"; + +/// `OpenShell` component calling an extension service. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtensionCallerKind { + Gateway, + Supervisor, +} + +/// JWT claim set accepted by external extension services. +/// +/// This intentionally differs from sandbox bootstrap claims. Sharing a signing +/// key does not make a sandbox-to-gateway credential valid at an extension. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtensionJwtClaims { + pub iss: String, + pub aud: String, + pub sub: String, + pub iat: i64, + pub exp: i64, + pub jti: String, + pub caller_kind: ExtensionCallerKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caller_kind_uses_stable_snake_case_wire_values() { + assert_eq!( + serde_json::to_string(&ExtensionCallerKind::Gateway).unwrap(), + "\"gateway\"" + ); + assert_eq!( + serde_json::to_string(&ExtensionCallerKind::Supervisor).unwrap(), + "\"supervisor\"" + ); + } + + #[test] + fn gateway_claims_omit_sandbox_id() { + let claims = ExtensionJwtClaims { + iss: "openshell-gateway:test".to_string(), + aud: "urn:openshell:extension:interceptor:test".to_string(), + sub: "openshell-gateway:test".to_string(), + iat: 1, + exp: 2, + jti: "unique".to_string(), + caller_kind: ExtensionCallerKind::Gateway, + sandbox_id: None, + }; + let json = serde_json::to_value(claims).unwrap(); + assert!(json.get("sandbox_id").is_none()); + assert_eq!(json["caller_kind"], "gateway"); + } +} diff --git a/crates/openshell-extension-core/src/lib.rs b/crates/openshell-extension-core/src/lib.rs new file mode 100644 index 0000000000..d24621f205 --- /dev/null +++ b/crates/openshell-extension-core/src/lib.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Protocol-neutral primitives shared by `OpenShell` extension mechanisms. +//! +//! Subsystem-specific protobuf clients and orchestration do not belong here. + +mod auth; +mod identity; +mod jwt; +mod store; +mod transport; + +pub use auth::{BearerTokenInterceptor, BearerTokenSlot, TokenSlotError}; +pub use identity::{ExtensionAudience, ExtensionIdentity, ExtensionKind, IdentityError}; +pub use jwt::{ + EXTENSION_JWT_TYP, ExtensionCallerKind, ExtensionJwtClaims, MAX_EXTENSION_TOKEN_TTL, +}; +pub use store::ExtensionCredentialStore; +pub use transport::{ + ExtensionChannelConfig, ExtensionServerTrust, TransportError, connect_channel, +}; diff --git a/crates/openshell-extension-core/src/store.rs b/crates/openshell-extension-core/src/store.rs new file mode 100644 index 0000000000..2e6b94be25 --- /dev/null +++ b/crates/openshell-extension-core/src/store.rs @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; + +use crate::auth::{BearerTokenSlot, TokenSlotError}; + +/// Fraction of a credential's remaining lifetime to consume before rotating. +/// Matches the gateway-token renewal policy so both credentials refresh well +/// before expiry rather than at it. +const REFRESH_AT_FRACTION: (i64, i64) = (4, 5); + +/// Shortest interval a caller is asked to wait before retrying a rotation. +const MIN_REFRESH_DELAY_MS: i64 = 1_000; + +#[derive(Clone)] +struct Entry { + slot: BearerTokenSlot, + /// Wall-clock point after which this credential should be rotated. Derived + /// once at install time because a slot records only its expiry, not the + /// lifetime it was issued with. + refresh_after_ms: i64, +} + +/// Per-service extension credentials held by one supervisor. +/// +/// Cloning shares the underlying map, so the registry's middleware clients and +/// the polling loop that rotates them observe the same slots. Ownership is +/// explicit rather than process-global: tests construct independent stores and +/// cannot interfere with each other. +#[derive(Clone, Default)] +pub struct ExtensionCredentialStore { + inner: Arc>>, +} + +impl ExtensionCredentialStore { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Install or rotate one credential, returning the shared slot. + /// + /// An existing slot is updated in place so clients already holding a clone + /// pick up the new token without rebuilding their channel. + pub fn install( + &self, + name: &str, + token: &str, + expires_at_ms: i64, + now_ms: i64, + ) -> Result { + let refresh_after_ms = refresh_deadline_ms(expires_at_ms, now_ms); + let mut slots = self.write(); + if let Some(entry) = slots.get_mut(name) { + entry.slot.update(token, expires_at_ms)?; + entry.refresh_after_ms = refresh_after_ms; + return Ok(entry.slot.clone()); + } + let slot = BearerTokenSlot::new(token, expires_at_ms)?; + slots.insert( + name.to_string(), + Entry { + slot: slot.clone(), + refresh_after_ms, + }, + ); + Ok(slot) + } + + #[must_use] + pub fn get(&self, name: &str) -> Option { + self.read().get(name).map(|entry| entry.slot.clone()) + } + + /// Snapshot the slots for `names`, or `None` if any is absent. + #[must_use] + pub fn slots_for(&self, names: &[String]) -> Option> { + let slots = self.read(); + names + .iter() + .map(|name| { + slots + .get(name) + .map(|entry| (name.clone(), entry.slot.clone())) + }) + .collect() + } + + #[must_use] + pub fn names(&self) -> Vec { + self.read().keys().cloned().collect() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.read().is_empty() + } + + /// True when any requested credential is missing or due for rotation. + /// + /// Callers use this to avoid rotating on every configuration poll: a + /// 15-minute credential polled every 10 seconds would otherwise mint and + /// re-authorize roughly ninety times per useful rotation. + #[must_use] + pub fn needs_refresh(&self, names: &[String], now_ms: i64) -> bool { + let slots = self.read(); + names.iter().any(|name| { + slots + .get(name) + .is_none_or(|entry| entry.refresh_after_ms <= now_ms) + }) + } + + /// Bound `fallback` by the soonest rotation deadline so a caller's sleep + /// never overshoots a credential that must be rotated sooner. + #[must_use] + pub fn next_refresh_delay( + &self, + fallback: std::time::Duration, + now_ms: i64, + ) -> std::time::Duration { + let Some(earliest) = self + .read() + .values() + .map(|entry| entry.refresh_after_ms) + .min() + else { + return fallback; + }; + let remaining_ms = earliest.saturating_sub(now_ms).max(MIN_REFRESH_DELAY_MS); + fallback.min(std::time::Duration::from_millis( + u64::try_from(remaining_ms).unwrap_or(u64::MAX), + )) + } + + /// Drop credentials for services no longer in the installed registry. + /// + /// Detached slots are cleared before removal so any client still holding a + /// clone fails closed instead of continuing with a valid token. + pub fn retain(&self, names: &HashSet<&str>) { + self.write().retain(|name, entry| { + let keep = names.contains(name.as_str()); + if !keep { + entry.slot.clear(); + } + keep + }); + } +} + +impl std::fmt::Debug for ExtensionCredentialStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExtensionCredentialStore") + .field("services", &self.names()) + .finish_non_exhaustive() + } +} + +fn refresh_deadline_ms(expires_at_ms: i64, now_ms: i64) -> i64 { + let remaining_ms = expires_at_ms.saturating_sub(now_ms); + if remaining_ms <= 0 { + return now_ms; + } + let consumed = remaining_ms + .saturating_mul(REFRESH_AT_FRACTION.0) + .checked_div(REFRESH_AT_FRACTION.1) + .unwrap_or(remaining_ms); + now_ms.saturating_add(consumed.max(MIN_REFRESH_DELAY_MS)) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tonic::service::Interceptor; + + use super::*; + + const NOW: i64 = 1_000_000; + + /// Interceptor assertions compare against the real clock, so tests that + /// exercise a live slot need an expiry in the actual future rather than + /// the synthetic `NOW` used for deterministic scheduling arithmetic. + const FAR_FUTURE_MS: i64 = 4_102_444_800_000; + + #[test] + fn rotation_updates_existing_slots_in_place() { + let store = ExtensionCredentialStore::new(); + let first = store + .install("guard", "first-secret", FAR_FUTURE_MS, NOW) + .unwrap(); + let mut interceptor = first.interceptor(); + + store + .install("guard", "second-secret", FAR_FUTURE_MS, NOW) + .unwrap(); + + // The registry's client holds a clone from the first install; rotation + // must reach it without rebuilding the channel. + assert_eq!( + interceptor + .call(tonic::Request::new(())) + .unwrap() + .metadata() + .get("authorization") + .unwrap(), + "Bearer second-secret" + ); + } + + #[test] + fn refresh_is_due_only_after_four_fifths_of_the_lifetime() { + let store = ExtensionCredentialStore::new(); + let names = vec!["guard".to_string()]; + store + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + + assert!(!store.needs_refresh(&names, NOW)); + assert!(!store.needs_refresh(&names, NOW + 719_000)); + assert!(store.needs_refresh(&names, NOW + 720_001)); + + // An unknown service always forces a refresh so newly attached + // middleware acquires a credential before it is used. + assert!(store.needs_refresh(&["other".to_string()], NOW)); + } + + #[test] + fn poll_delay_is_bounded_by_the_soonest_rotation() { + let store = ExtensionCredentialStore::new(); + let fallback = Duration::from_secs(10); + assert_eq!(store.next_refresh_delay(fallback, NOW), fallback); + + // 15-minute credential: rotation is due long after the poll interval, + // so polling cadence is unchanged. + store + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + assert_eq!(store.next_refresh_delay(fallback, NOW), fallback); + + // Short-lived credential: the caller must wake before it is stale. + store.install("brief", "secret", NOW + 5_000, NOW).unwrap(); + assert_eq!( + store.next_refresh_delay(fallback, NOW), + Duration::from_secs(4) + ); + } + + #[test] + fn detached_credentials_are_cleared_before_removal() { + let store = ExtensionCredentialStore::new(); + let slot = store + .install("detached", "secret", FAR_FUTURE_MS, NOW) + .unwrap(); + store.install("kept", "secret", FAR_FUTURE_MS, NOW).unwrap(); + + store.retain(&HashSet::from(["kept"])); + + assert_eq!(store.names(), vec!["kept".to_string()]); + // A client still holding the detached slot must fail closed rather + // than keep using a token that is technically still valid. + assert_eq!( + slot.interceptor() + .call(tonic::Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } + + #[test] + fn slots_for_requires_every_requested_service() { + let store = ExtensionCredentialStore::new(); + store + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + + assert!(store.slots_for(&["guard".to_string()]).is_some()); + assert!( + store + .slots_for(&["guard".to_string(), "missing".to_string()]) + .is_none() + ); + } + + #[test] + fn already_expired_credentials_are_immediately_due() { + let store = ExtensionCredentialStore::new(); + store.install("guard", "secret", NOW - 1, NOW).unwrap(); + assert!(store.needs_refresh(&["guard".to_string()], NOW)); + } + + #[test] + fn stores_are_independent() { + let first = ExtensionCredentialStore::new(); + let second = ExtensionCredentialStore::new(); + first + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + assert!(second.is_empty()); + } +} diff --git a/crates/openshell-extension-core/src/transport.rs b/crates/openshell-extension-core/src/transport.rs new file mode 100644 index 0000000000..4552faf70a --- /dev/null +++ b/crates/openshell-extension-core/src/transport.rs @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(unix)] +use hyper_util::rt::TokioIo; +#[cfg(unix)] +use tokio::net::UnixStream; +#[cfg(unix)] +use tonic::transport::Uri; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint}; +#[cfg(unix)] +use tower::service_fn; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); +const KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20); + +/// Trust policy used to authenticate a remote extension service. +/// +/// This enum is intentionally independent of middleware and interceptor +/// protocols. Future transport identities, such as SPIFFE X.509-SVIDs or +/// pinned public keys, can be added here without changing either caller. +#[derive(Clone, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum ExtensionServerTrust { + /// Authenticate the HTTPS service with the platform trust store and the + /// endpoint's DNS name. + #[default] + PlatformRoots, + /// Authenticate the HTTPS service with only this PEM CA bundle and the + /// endpoint's DNS name. + CustomCaPem(Vec), +} + +impl std::fmt::Debug for ExtensionServerTrust { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PlatformRoots => formatter.write_str("PlatformRoots"), + Self::CustomCaPem(_) => formatter.write_str("CustomCaPem()"), + } + } +} + +/// Configuration for an outbound extension gRPC channel. +#[derive(Clone, PartialEq, Eq)] +pub struct ExtensionChannelConfig { + endpoint: String, + server_trust: ExtensionServerTrust, +} + +impl ExtensionChannelConfig { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + server_trust: ExtensionServerTrust::default(), + } + } + + /// Select how the remote extension service is authenticated. + #[must_use] + pub fn with_server_trust(mut self, server_trust: ExtensionServerTrust) -> Self { + self.server_trust = server_trust; + self + } + + /// Pin HTTPS verification to this CA bundle instead of platform roots. + /// Normal TLS hostname verification remains enabled. + #[must_use] + pub fn with_custom_ca_pem(self, custom_ca_pem: impl Into>) -> Self { + self.with_server_trust(ExtensionServerTrust::CustomCaPem(custom_ca_pem.into())) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn server_trust(&self) -> &ExtensionServerTrust { + &self.server_trust + } + + /// Returns whether HTTPS verification uses an operator-provided CA bundle. + pub fn has_custom_ca(&self) -> bool { + matches!(&self.server_trust, ExtensionServerTrust::CustomCaPem(_)) + } +} + +impl std::fmt::Debug for ExtensionChannelConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExtensionChannelConfig") + .field("endpoint", &self.endpoint) + .field("server_trust", &self.server_trust) + .finish() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TransportError { + #[error("extension endpoint must not be empty")] + EmptyEndpoint, + #[error("extension endpoint must use http://, https://, or unix://")] + UnsupportedScheme, + #[error("custom CA certificates require an https:// extension endpoint")] + CustomCaRequiresHttps, + #[error("unix extension endpoint must contain an absolute socket path")] + InvalidUnixPath, + #[error("unix extension endpoints are not supported on this platform")] + UnixUnsupported, + #[error("invalid extension endpoint: {0}")] + InvalidEndpoint(#[source] tonic::transport::Error), + #[error("could not configure extension TLS: {0}")] + Tls(#[source] tonic::transport::Error), + #[error("could not connect to extension service: {0}")] + Connect(#[source] tonic::transport::Error), +} + +pub async fn connect_channel(config: &ExtensionChannelConfig) -> Result { + validate_config(config)?; + if let Some(path) = config.endpoint.strip_prefix("unix://") { + return connect_unix(PathBuf::from(path)).await; + } + + let mut endpoint = standard_endpoint(&config.endpoint)?; + if config.endpoint.starts_with("https://") { + let tls = match &config.server_trust { + ExtensionServerTrust::PlatformRoots => ClientTlsConfig::new().with_enabled_roots(), + ExtensionServerTrust::CustomCaPem(pem) => { + ClientTlsConfig::new().ca_certificate(Certificate::from_pem(pem)) + } + }; + endpoint = endpoint.tls_config(tls).map_err(TransportError::Tls)?; + } + endpoint.connect().await.map_err(TransportError::Connect) +} + +fn validate_config(config: &ExtensionChannelConfig) -> Result<(), TransportError> { + if config.endpoint.is_empty() { + return Err(TransportError::EmptyEndpoint); + } + let is_https = config.endpoint.starts_with("https://"); + let is_http = config.endpoint.starts_with("http://"); + let is_unix = config.endpoint.starts_with("unix://"); + if !is_https && !is_http && !is_unix { + return Err(TransportError::UnsupportedScheme); + } + if !matches!(&config.server_trust, ExtensionServerTrust::PlatformRoots) && !is_https { + return Err(TransportError::CustomCaRequiresHttps); + } + if let Some(path) = config.endpoint.strip_prefix("unix://") + && (path.is_empty() || !PathBuf::from(path).is_absolute()) + { + return Err(TransportError::InvalidUnixPath); + } + Ok(()) +} + +fn standard_endpoint(uri: &str) -> Result { + Endpoint::from_shared(uri.to_string()) + .map(|endpoint| { + endpoint + .connect_timeout(CONNECT_TIMEOUT) + .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL) + .keep_alive_while_idle(true) + .keep_alive_timeout(KEEP_ALIVE_TIMEOUT) + .http2_adaptive_window(true) + }) + .map_err(TransportError::InvalidEndpoint) +} + +#[cfg(unix)] +async fn connect_unix(path: PathBuf) -> Result { + standard_endpoint("http://[::]:50051")? + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + async move { UnixStream::connect(path).await.map(TokioIo::new) } + })) + .await + .map_err(TransportError::Connect) +} + +#[cfg(not(unix))] +async fn connect_unix(_path: PathBuf) -> Result { + Err(TransportError::UnixUnsupported) +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + use std::future::{Ready, ready}; + use std::task::{Context, Poll}; + + use rcgen::{BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair}; + use tokio::net::TcpListener; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::body::Body; + use tonic::server::NamedService; + use tonic::transport::{Identity, Server, ServerTlsConfig}; + use tower::Service; + + use super::*; + + #[derive(Clone)] + struct NoopGrpcService; + + impl NamedService for NoopGrpcService { + const NAME: &'static str = "openshell.test.Noop"; + } + + impl Service> for NoopGrpcService { + type Response = http::Response; + type Error = Infallible; + type Future = Ready>; + + fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _request: http::Request) -> Self::Future { + ready(Ok(http::Response::new(Body::empty()))) + } + } + + #[test] + fn accepts_supported_endpoint_forms() { + for endpoint in ["http://127.0.0.1:50051", "https://middleware.example:443"] { + validate_config(&ExtensionChannelConfig::new(endpoint)).unwrap(); + } + #[cfg(unix)] + validate_config(&ExtensionChannelConfig::new( + "unix:///run/openshell/middleware.sock", + )) + .unwrap(); + } + + #[test] + fn server_trust_is_an_explicit_shared_policy() { + let default = ExtensionChannelConfig::new("https://middleware.example"); + assert!(matches!( + default.server_trust(), + ExtensionServerTrust::PlatformRoots + )); + + let custom = + default.with_server_trust(ExtensionServerTrust::CustomCaPem(b"test CA".to_vec())); + assert!(matches!( + custom.server_trust(), + ExtensionServerTrust::CustomCaPem(pem) if pem == b"test CA" + )); + } + + #[test] + fn custom_ca_is_restricted_to_https() { + for endpoint in [ + "http://127.0.0.1:50051", + "unix:///run/openshell/middleware.sock", + ] { + let config = ExtensionChannelConfig::new(endpoint).with_custom_ca_pem(b"test CA"); + assert!(matches!( + validate_config(&config), + Err(TransportError::CustomCaRequiresHttps) + )); + } + validate_config( + &ExtensionChannelConfig::new("https://middleware.example") + .with_custom_ca_pem(b"test CA"), + ) + .unwrap(); + } + + #[test] + fn rejects_unsupported_schemes_and_relative_unix_paths() { + assert!(matches!( + validate_config(&ExtensionChannelConfig::new("tcp://middleware:50051")), + Err(TransportError::UnsupportedScheme) + )); + assert!(matches!( + validate_config(&ExtensionChannelConfig::new("unix://relative.sock")), + Err(TransportError::InvalidUnixPath) + )); + } + + #[test] + fn debug_does_not_render_ca_contents() { + let secretish_pem = b"private deployment CA material"; + let config = ExtensionChannelConfig::new("https://middleware.example") + .with_custom_ca_pem(secretish_pem); + assert!(!format!("{config:?}").contains("private deployment")); + } + + #[tokio::test] + async fn custom_ca_verifies_certificate_and_hostname() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca = ca_params.self_signed(&ca_key).unwrap(); + + let server_key = KeyPair::generate().unwrap(); + let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let server_cert = server_params.signed_by(&server_key, &ca, &ca_key).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let incoming = TcpListenerStream::new(listener); + let tls = ServerTlsConfig::new().identity(Identity::from_pem( + server_cert.pem(), + server_key.serialize_pem(), + )); + tokio::spawn(async move { + Server::builder() + .tls_config(tls) + .unwrap() + .add_service(NoopGrpcService) + .serve_with_incoming(incoming) + .await + .unwrap(); + }); + + let trusted = ExtensionChannelConfig::new(format!("https://localhost:{}", address.port())) + .with_custom_ca_pem(ca.pem()); + connect_channel(&trusted).await.unwrap(); + + let wrong_hostname = + ExtensionChannelConfig::new(format!("https://127.0.0.1:{}", address.port())) + .with_custom_ca_pem(ca.pem()); + assert!(matches!( + connect_channel(&wrong_hostname).await, + Err(TransportError::Connect(_)) + )); + + let rogue_key = KeyPair::generate().unwrap(); + let mut rogue_params = CertificateParams::new(Vec::::new()).unwrap(); + rogue_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let rogue_ca = rogue_params.self_signed(&rogue_key).unwrap(); + let wrong_ca = ExtensionChannelConfig::new(format!("https://localhost:{}", address.port())) + .with_custom_ca_pem(rogue_ca.pem()); + assert!(matches!( + connect_channel(&wrong_ca).await, + Err(TransportError::Connect(_)) + )); + } +} diff --git a/crates/openshell-gateway-interceptors/BUILD.bazel b/crates/openshell-gateway-interceptors/BUILD.bazel deleted file mode 100644 index ec507539e5..0000000000 --- a/crates/openshell-gateway-interceptors/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-gateway-interceptors", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-gateway-interceptors_test", - crate = ":openshell-gateway-interceptors", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-gateway-interceptors", - ":openshell-gateway-interceptors_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml index 7bcdd6eebf..f336562ec1 100644 --- a/crates/openshell-gateway-interceptors/Cargo.toml +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -12,8 +12,8 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-extension-core = { path = "../openshell-extension-core" } -hyper-util = { workspace = true, features = ["client", "http1", "http2", "tokio"] } json-patch = "1.4" metrics = { workspace = true } prost = { workspace = true } @@ -24,7 +24,6 @@ sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tonic = { workspace = true, features = ["channel", "tls-native-roots"] } -tower = { workspace = true } tracing = { workspace = true } [dev-dependencies] diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 32b4f3e525..d5bb5df59e 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -11,7 +11,12 @@ #![allow(clippy::result_large_err)] +use std::collections::BTreeMap; + use openshell_core::config::GatewayInterceptorConfig; +use openshell_extension_core::{BearerTokenInterceptor, BearerTokenSlot}; +use tonic::service::interceptor::InterceptedService; +use tonic::transport::Channel; pub(crate) mod plan; pub(crate) mod profile_source; @@ -38,13 +43,33 @@ pub enum InterceptorError { pub type Result = std::result::Result; +pub(crate) type ExtensionChannel = InterceptedService; + /// Return `None` when no interceptors are configured. pub async fn initialize( configs: Vec, +) -> Result> { + initialize_configured(configs, None).await +} + +/// Initialize gateway interceptors with rotating bearer-token slots. +/// +/// Every configured interceptor must have a corresponding slot. Slots may be +/// updated in place without rebuilding the execution plan or its gRPC clients. +pub async fn initialize_authenticated( + configs: Vec, + token_slots: BTreeMap, +) -> Result> { + initialize_configured(configs, Some(token_slots)).await +} + +async fn initialize_configured( + configs: Vec, + token_slots: Option>, ) -> Result> { if configs.is_empty() { return Ok(None); } - let runtime = GatewayInterceptorRuntime::build(configs).await?; + let runtime = GatewayInterceptorRuntime::build(configs, token_slots).await?; Ok(Some(runtime)) } diff --git a/crates/openshell-gateway-interceptors/src/plan.rs b/crates/openshell-gateway-interceptors/src/plan.rs index b65d5612fd..c89de6f6ee 100644 --- a/crates/openshell-gateway-interceptors/src/plan.rs +++ b/crates/openshell-gateway-interceptors/src/plan.rs @@ -4,10 +4,8 @@ //! Interceptor configuration and immutable execution planning. use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::PathBuf; use std::time::Duration; -use hyper_util::rt::TokioIo; use openshell_core::config::{ GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, @@ -16,16 +14,16 @@ use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, gateway_interceptor_client::GatewayInterceptorClient, }; -use tokio::net::UnixStream; +use openshell_extension_core::{ + BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, + connect_channel, +}; use tonic::Request; -use tonic::codegen::http::Uri; -use tonic::transport::{Channel, Endpoint}; -use tower::service_fn; use tracing::{info, warn}; use crate::profile_source::GatewayInterceptorProfileSource; use crate::routes::OpenShellRouteIndex; -use crate::{InterceptorError, Result}; +use crate::{ExtensionChannel, InterceptorError, Result}; pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 1_048_576; @@ -137,7 +135,7 @@ pub struct BindingPlan { pub(crate) timeout: Duration, pub(crate) max_response_bytes: usize, pub(crate) max_patches: usize, - pub(crate) client: GatewayInterceptorClient, + pub(crate) client: GatewayInterceptorClient, } impl std::fmt::Debug for BindingPlan { @@ -175,15 +173,31 @@ impl ExecutionPlan { pub(crate) async fn load( mut configs: Vec, routes: OpenShellRouteIndex, + token_slots: Option>, ) -> Result { validate_interceptor_configs(&configs)?; + validate_authenticated_slots(&configs, token_slots.as_ref())?; configs.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.name.cmp(&b.name))); let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); let mut profile_sources = BTreeMap::new(); for config in configs { - let channel = connect_endpoint(&config.grpc_endpoint).await?; + let channel = connect_endpoint(&config).await?; + let interceptor = match token_slots.as_ref() { + Some(_) if config.allow_insecure_transport => BearerTokenInterceptor::disabled(), + Some(slots) => slots + .get(&config.name) + .ok_or_else(|| { + InterceptorError::Config(format!( + "authenticated interceptor '{}' is missing a bearer-token slot", + config.name + )) + })? + .interceptor(), + None => BearerTokenInterceptor::disabled(), + }; + let channel = ExtensionChannel::new(channel, interceptor); let timeout = match config.timeout.as_deref() { Some(timeout) => parse_duration(timeout)?, None => DEFAULT_TIMEOUT, @@ -210,6 +224,11 @@ impl ExecutionPlan { )) })? .into_inner(); + validate_expected_audience( + &config, + &manifest.expected_audience, + token_slots.is_some(), + )?; let service_default = match config.binding_policy { GatewayInterceptorBindingPolicy::Dynamic => { let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; @@ -348,6 +367,54 @@ impl ExecutionPlan { } } +/// After authenticated Describe succeeds, reject an interceptor whose +/// configured audience differs from the one the service says it verifies. +/// +/// This is a post-authentication consistency assertion, not audience discovery: +/// a strict verifier may reject an incorrect audience before returning its +/// manifest. A service that does not advertise an audience is accepted unchanged. +fn validate_expected_audience( + config: &GatewayInterceptorConfig, + advertised: &str, + authenticated: bool, +) -> Result<()> { + if !authenticated || config.allow_insecure_transport || advertised.is_empty() { + return Ok(()); + } + let configured = config.resolved_audience(); + if advertised != configured { + return Err(InterceptorError::Config(format!( + "interceptor '{}' expects audience '{advertised}' but the gateway \ + is configured to mint '{configured}'", + config.name + ))); + } + Ok(()) +} + +fn validate_authenticated_slots( + configs: &[GatewayInterceptorConfig], + token_slots: Option<&BTreeMap>, +) -> Result<()> { + let Some(token_slots) = token_slots else { + return Ok(()); + }; + for config in configs { + // Registrations the operator opted out of extension authentication + // intentionally have no slot; everything else must fail closed. + if config.allow_insecure_transport { + continue; + } + if !token_slots.contains_key(&config.name) { + return Err(InterceptorError::Config(format!( + "authenticated interceptor '{}' is missing a bearer-token slot", + config.name + ))); + } + } + Ok(()) +} + #[derive(Debug, Clone)] struct NormalizedBinding { binding_id: String, @@ -857,42 +924,31 @@ pub fn parse_duration(value: &str) -> Result { ))) } -async fn connect_endpoint(endpoint: &str) -> Result { - let endpoint = endpoint.trim(); - if let Some(path) = endpoint.strip_prefix("unix://") { - return connect_unix_endpoint(PathBuf::from(path)).await; - } - Endpoint::from_shared(endpoint.to_string()) - .map_err(|e| { - InterceptorError::Config(format!("invalid interceptor endpoint '{endpoint}': {e}")) - })? - .connect() - .await - .map_err(|e| InterceptorError::Transport(format!("connect {endpoint}: {e}"))) -} - -#[cfg(unix)] -async fn connect_unix_endpoint(path: PathBuf) -> Result { - let display = path.display().to_string(); - Endpoint::from_static("http://[::]:50051") - .connect_with_connector(service_fn(move |_: Uri| { - let path = path.clone(); - async move { UnixStream::connect(path).await.map(TokioIo::new) } - })) - .await - .map_err(|e| InterceptorError::Transport(format!("connect unix://{display}: {e}"))) -} - -#[cfg(not(unix))] -async fn connect_unix_endpoint(path: PathBuf) -> Result { - Err(InterceptorError::Config(format!( - "unix interceptor endpoints are not supported on this platform: {}", - path.display() - ))) +async fn connect_endpoint(config: &GatewayInterceptorConfig) -> Result { + let endpoint = config.grpc_endpoint.trim(); + let mut channel_config = ExtensionChannelConfig::new(endpoint); + if let Some(path) = &config.tls_ca_cert_path { + let pem = tokio::fs::read(path).await.map_err(|error| { + InterceptorError::Config(format!( + "failed to read TLS CA certificate for interceptor '{}' from {}: {error}", + config.name, + path.display() + )) + })?; + channel_config = channel_config.with_server_trust(ExtensionServerTrust::CustomCaPem(pem)); + } + connect_channel(&channel_config).await.map_err(|error| { + InterceptorError::Transport(format!( + "connect interceptor '{}' at {endpoint}: {error}", + config.name + )) + }) } #[cfg(test)] mod tests { + use std::path::PathBuf; + use openshell_core::config::{ GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorPhaseConfig, @@ -963,6 +1019,92 @@ mod tests { ); } + #[test] + fn authenticated_interceptors_require_a_token_slot_per_registration() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "http://127.0.0.1:18081".to_string(), + ..GatewayInterceptorConfig::default() + }; + let error = + validate_authenticated_slots(std::slice::from_ref(&config), Some(&BTreeMap::new())) + .expect_err("missing token slot must fail closed"); + assert_eq!( + error.to_string(), + "invalid interceptor config: authenticated interceptor 'governance' is missing a bearer-token slot" + ); + + let slots = BTreeMap::from([(config.name.clone(), BearerTokenSlot::empty())]); + validate_authenticated_slots(&[config], Some(&slots)).unwrap(); + } + + #[test] + fn advertised_audience_mismatch_fails_at_startup() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "https://governance.example".to_string(), + ..GatewayInterceptorConfig::default() + }; + + // Matching and unadvertised audiences both pass. + validate_expected_audience(&config, "", true).expect("unadvertised audience is accepted"); + validate_expected_audience( + &config, + "urn:openshell:extension:interceptor:governance", + true, + ) + .expect("matching audience is accepted"); + + // Once authenticated Describe succeeds, reject a manifest that + // contradicts the operator-owned audience configuration. + let error = validate_expected_audience(&config, "urn:example:something-else", true) + .expect_err("mismatched audience must fail closed at startup"); + let message = error.to_string(); + assert!(message.contains("urn:example:something-else")); + assert!(message.contains("urn:openshell:extension:interceptor:governance")); + + // With gateway JWT signing disabled no token is minted at all, so the + // advertised audience is not something OpenShell can be wrong about. + validate_expected_audience(&config, "urn:example:something-else", false) + .expect("unauthenticated gateways skip the handshake"); + + // The check likewise does not apply where the registration opted out. + let opted_out = GatewayInterceptorConfig { + allow_insecure_transport: true, + ..config + }; + validate_expected_audience(&opted_out, "urn:example:something-else", true) + .expect("opted-out interceptors mint no token to mismatch"); + } + + #[test] + fn opted_out_interceptors_do_not_require_a_token_slot() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "http://127.0.0.1:18081".to_string(), + allow_insecure_transport: true, + ..GatewayInterceptorConfig::default() + }; + validate_authenticated_slots(&[config], Some(&BTreeMap::new())) + .expect("explicit opt-out needs no credential"); + } + + #[tokio::test] + async fn configured_ca_read_failure_names_interceptor_without_certificate_contents() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "https://governance.example".to_string(), + tls_ca_cert_path: Some(PathBuf::from("/definitely/missing/openshell-ca.pem")), + ..GatewayInterceptorConfig::default() + }; + let error = connect_endpoint(&config) + .await + .expect_err("missing CA must prevent connection"); + let message = error.to_string(); + assert!(message.contains("governance")); + assert!(message.contains("/definitely/missing/openshell-ca.pem")); + } + #[test] fn interceptor_binding_policy_defaults_to_dynamic() { assert_eq!( diff --git a/crates/openshell-gateway-interceptors/src/profile_source.rs b/crates/openshell-gateway-interceptors/src/profile_source.rs index 74cd3b26c2..48014258a2 100644 --- a/crates/openshell-gateway-interceptors/src/profile_source.rs +++ b/crates/openshell-gateway-interceptors/src/profile_source.rs @@ -11,9 +11,9 @@ use openshell_core::proto::gateway_interceptor::v1::{ }; use prost::Message as _; use sha2::Digest as _; -use tonic::{Request, transport::Channel}; +use tonic::Request; -use crate::{InterceptorError, Result}; +use crate::{ExtensionChannel, InterceptorError, Result}; #[derive(Debug, Clone)] pub struct ProviderProfileSourceSnapshot { @@ -26,7 +26,7 @@ pub struct GatewayInterceptorProfileSource { interceptor_name: String, source_id: String, timeout: Duration, - client: GatewayInterceptorClient, + client: GatewayInterceptorClient, } impl GatewayInterceptorProfileSource { @@ -34,7 +34,7 @@ impl GatewayInterceptorProfileSource { interceptor_name: String, source_id: String, timeout: Duration, - client: GatewayInterceptorClient, + client: GatewayInterceptorClient, ) -> Self { Self { interceptor_name, diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index 7938644b66..d7ea5a241c 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -316,6 +316,8 @@ mod tests { labels: HashMap::from([("team".to_string(), "agent".to_string())]), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), }; let bytes = request.encode_to_vec(); let json = codec @@ -371,6 +373,7 @@ mod tests { ("openshell.compute.v1.DriverSandboxSpec", "sandbox_token"), ("openshell.v1.IssueSandboxTokenResponse", "token"), ("openshell.v1.RefreshSandboxTokenResponse", "token"), + ("openshell.v1.ExtensionServiceCredential", "token"), ("openshell.v1.CreateSshSessionResponse", "token"), ("openshell.v1.RevokeSshSessionRequest", "token"), ("openshell.v1.TcpForwardInit", "authorization_token"), diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index d510956aed..cd0f59ea3c 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -14,6 +14,7 @@ use openshell_core::proto::gateway_interceptor::v1::{ InterceptorEvaluation, InterceptorResult, JsonPatch, ModifyOperationEvaluation, PostCommitEvaluation, ValidateEvaluation, interceptor_evaluation, }; +use openshell_extension_core::BearerTokenSlot; use prost::Message as _; use prost_types::Struct; use serde_json::{Map, Value}; @@ -77,10 +78,13 @@ impl ValidatedOperation { } impl GatewayInterceptorRuntime { - pub(crate) async fn build(configs: Vec) -> Result { + pub(crate) async fn build( + configs: Vec, + token_slots: Option>, + ) -> Result { let codec = ProtoJsonCodec::openshell()?; let routes = routes::OpenShellRouteIndex::from_descriptor_pool(codec.descriptor_pool())?; - let plan = ExecutionPlan::load(configs, routes).await?; + let plan = ExecutionPlan::load(configs, routes, token_slots).await?; Ok(Self { plan: Arc::new(plan), codec, @@ -602,6 +606,7 @@ mod tests { CreateProviderRequest, CreateSandboxRequest, Provider, SandboxSpec, SandboxTemplate, UpdateConfigRequest, }; + use openshell_extension_core::BearerTokenInterceptor; use serde_json::json; use std::collections::HashMap; use std::sync::{ @@ -724,8 +729,9 @@ mod tests { timeout: DEFAULT_TIMEOUT, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, max_patches: DEFAULT_MAX_PATCHES, - client: GatewayInterceptorClient::new( + client: GatewayInterceptorClient::with_interceptor( Channel::from_static("http://127.0.0.1:1").connect_lazy(), + BearerTokenInterceptor::disabled(), ), } } @@ -923,8 +929,9 @@ mod tests { timeout: DEFAULT_TIMEOUT, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, max_patches: DEFAULT_MAX_PATCHES, - client: GatewayInterceptorClient::new( + client: GatewayInterceptorClient::with_interceptor( Channel::from_static("http://127.0.0.1:1").connect_lazy(), + BearerTokenInterceptor::disabled(), ), }; let result = InterceptorResult { @@ -1068,6 +1075,8 @@ mod tests { labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), }; let bytes = request.encode_to_vec(); @@ -1107,8 +1116,9 @@ mod tests { timeout: DEFAULT_TIMEOUT, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, max_patches: DEFAULT_MAX_PATCHES, - client: GatewayInterceptorClient::new( + client: GatewayInterceptorClient::with_interceptor( Channel::from_static("http://127.0.0.1:1").connect_lazy(), + BearerTokenInterceptor::disabled(), ), }; let operation = json!({ "name": "demo" }); diff --git a/crates/openshell-gateway/Cargo.toml b/crates/openshell-gateway/Cargo.toml new file mode 100644 index 0000000000..f9d02027e0 --- /dev/null +++ b/crates/openshell-gateway/Cargo.toml @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-gateway" +description = "OpenShell gateway binary composition" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-gateway" +path = "src/main.rs" + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } +openshell-server = { path = "../openshell-server", default-features = false } +openshell-otel = { path = "../openshell-otel", optional = true } +async-trait = "0.1" +miette = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(not(target_os = "windows"))'.dependencies] +openshell-driver-docker = { path = "../openshell-driver-docker", optional = true } +openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes", optional = true } +openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } +hyper-util = { workspace = true, optional = true } +nix = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +rustix = { workspace = true, optional = true } +tonic = { workspace = true, optional = true } +tower = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +openshell-driver-mxc = { path = "../openshell-driver-mxc", optional = true } + +[features] +default = ["telemetry", "in-tree-compute-drivers"] +in-tree-compute-drivers = [ + "dep:openshell-driver-docker", + "dep:openshell-driver-kubernetes", + "dep:openshell-driver-podman", + "dep:openshell-otel", + "dep:hyper-util", + "dep:nix", + "dep:serde", + "dep:rustix", + "dep:tonic", + "dep:tower", + "dep:tracing", + "dep:openshell-driver-mxc", +] +telemetry = ["openshell-core/telemetry", "openshell-server/telemetry"] +## Convenience alias: every default feature except `telemetry`. Build a +## telemetry-free gateway with +## `--no-default-features --features defaults-without-telemetry` and stay +## correct as new default features are added. Cargo cannot subtract a single +## default feature, so this alias must be paired with `--no-default-features`; +## enabling it alongside `telemetry` is a compile error rather than a silent +## telemetry-on build. Kept in sync with `default` by +## `rust:verify:defaults-without-telemetry`. +defaults-without-telemetry = ["in-tree-compute-drivers"] +bundled-z3 = ["openshell-server/bundled-z3"] + +[lints] +workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs new file mode 100644 index 0000000000..54ae5e5de3 --- /dev/null +++ b/crates/openshell-gateway/src/lib.rs @@ -0,0 +1,374 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Standard gateway binary composition. +//! +//! The server remains backend-agnostic. This crate is the composition boundary +//! that links first-party compute drivers into the distributed gateway binary. + +// `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 +// a telemetry-on build that reads as telemetry-free. Fail the build instead. +#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] +compile_error!( + "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ + build a telemetry-free gateway with `--no-default-features --features defaults-without-telemetry`" +); + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +mod vm; + +#[cfg(feature = "in-tree-compute-drivers")] +use openshell_core::telemetry::TelemetryComputeDriver; +#[cfg(feature = "in-tree-compute-drivers")] +use openshell_server::ComputeDriverRegistration; +use openshell_server::ComputeDriverRegistry; + +/// Install every first-party compute driver linked into the standard gateway. +#[must_use] +pub fn install_default_compute_drivers() -> ComputeDriverRegistry { + #[allow(unused_mut)] + let mut registry = ComputeDriverRegistry::new(); + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] + install_in_tree_compute_drivers(&mut registry); + #[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] + install_mxc_compute_driver(&mut registry); + registry +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +fn install_mxc_compute_driver(registry: &mut ComputeDriverRegistry) { + let registration = ComputeDriverRegistration::new("mxc", u16::MAX, None, MxcFactory) + .expect("first-party driver name is valid") + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("mxc")) + .with_local_singleplayer(); + registry + .install(registration) + .expect("first-party driver names are unique"); + + for name in ["docker", "kubernetes", "podman", "vm"] { + let registration = ComputeDriverRegistration::new( + name, + u16::MAX, + None, + UnsupportedWindowsFactory { name }, + ) + .expect("first-party driver name is valid"); + registry + .install(registration) + .expect("first-party driver names are unique"); + } +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct UnsupportedWindowsFactory { + name: &'static str, +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for UnsupportedWindowsFactory { + async fn build( + &self, + _context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + Err(unsupported_windows_compute_driver(self.name)) + } +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +fn unsupported_windows_compute_driver(name: &str) -> openshell_core::Error { + openshell_core::Error::config(format!("compute driver '{name}' is unsupported on Windows")) +} + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct MxcFactory; + +#[cfg(all(target_os = "windows", feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for MxcFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + let backend = openshell_driver_mxc::MxcComputeBackend::new(config); + let driver = openshell_driver_mxc::ComputeDriverService::new(backend); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn install_in_tree_compute_drivers(registry: &mut ComputeDriverRegistry) { + for registration in [ + ComputeDriverRegistration::new( + "kubernetes", + 100, + Some(|| std::env::var_os("KUBERNETES_SERVICE_HOST").is_some()), + KubernetesFactory, + ) + .map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("kubernetes")) + .without_mtls_user_auth() + .with_in_process_tracing(openshell_driver_kubernetes::otel_tracing::TRACING) + .with_inherited_config_keys(&[ + "namespace", + "default_image", + "supervisor_image", + "client_tls_secret_name", + "service_account_name", + "host_gateway_ip", + "enable_user_namespaces", + "sa_token_ttl_secs", + ]) + }), + ComputeDriverRegistration::new( + "podman", + 200, + Some(openshell_driver_podman::driver::is_available), + PodmanFactory, + ) + .map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("podman")) + .with_local_singleplayer() + .with_in_process_tracing(openshell_driver_podman::otel_tracing::TRACING) + .with_inherited_config_keys(&[ + "default_image", + "supervisor_image", + "host_gateway_ip", + "guest_tls_ca", + "guest_tls_cert", + "guest_tls_key", + ]) + }), + ComputeDriverRegistration::new( + "docker", + 300, + Some(openshell_driver_docker::is_available), + DockerFactory, + ) + .map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("docker")) + .with_local_singleplayer() + .with_in_process_tracing(openshell_driver_docker::otel_tracing::TRACING) + .with_inherited_config_keys(&[ + "sandbox_namespace", + "default_image", + "supervisor_image", + "host_gateway_ip", + "guest_tls_ca", + "guest_tls_cert", + "guest_tls_key", + ]) + }), + ComputeDriverRegistration::new("vm", u16::MAX, None, VmFactory).map(|registration| { + registration + .with_telemetry_category(TelemetryComputeDriver::anonymous_category("vm")) + .with_local_singleplayer() + .with_inherited_config_keys(&[ + "default_image", + "guest_tls_ca", + "guest_tls_cert", + "guest_tls_key", + ]) + }), + ] { + registry + .install(registration.expect("first-party driver name is valid")) + .expect("first-party driver names are unique"); + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct KubernetesFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for KubernetesFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: openshell_driver_kubernetes::KubernetesComputeConfig = + context.driver_config()?; + if let Ok(size) = std::env::var("OPENSHELL_K8S_WORKSPACE_DEFAULT_STORAGE_SIZE") { + config.workspace_default_storage_size = size; + } + if let Ok(storage_class) = std::env::var("OPENSHELL_K8S_WORKSPACE_STORAGE_CLASS") { + config.workspace_storage_class = storage_class; + } + let driver = openshell_driver_kubernetes::KubernetesComputeDriver::new( + config, + context.shutdown_receiver(), + ) + .await + .map_err(|error| openshell_core::Error::execution(error.to_string()))?; + let driver = openshell_driver_kubernetes::ComputeDriverService::new_in_process(driver); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct DockerFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for DockerFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: openshell_driver_docker::DockerComputeConfig = context.driver_config()?; + apply_guest_tls( + &mut config.guest_tls_ca, + &mut config.guest_tls_cert, + &mut config.guest_tls_key, + context.guest_tls_paths(), + ); + let driver = openshell_driver_docker::DockerComputeDriver::new( + context.gateway_bind_address(), + context.gateway_log_level(), + &config, + ) + .await + .map_err(|error| openshell_core::Error::execution(error.to_string()))?; + let driver = openshell_driver_docker::ComputeDriverService::new_in_process(driver); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct PodmanFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for PodmanFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: openshell_driver_podman::PodmanComputeConfig = context.driver_config()?; + config.gateway_port = context.gateway_port(); + if let Ok(path) = std::env::var("OPENSHELL_PODMAN_SOCKET") { + config.socket_path = Some(path.into()); + } + if let Ok(ip) = std::env::var("OPENSHELL_PODMAN_HOST_GATEWAY_IP") { + config.host_gateway_ip = ip; + } + if let Ok(mode) = std::env::var("OPENSHELL_PODMAN_USERNS") { + config.userns = Some(mode); + } + apply_guest_tls( + &mut config.guest_tls_ca, + &mut config.guest_tls_cert, + &mut config.guest_tls_key, + context.guest_tls_paths(), + ); + let driver = openshell_driver_podman::PodmanComputeDriver::new(config) + .await + .map_err(|error| openshell_core::Error::execution(error.to_string()))?; + let driver = openshell_driver_podman::ComputeDriverService::new_in_process(driver); + Ok(openshell_server::ComputeDriverInstance::InProcess( + std::sync::Arc::new(driver), + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[derive(Clone, Copy)] +struct VmFactory; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverFactory for VmFactory { + async fn build( + &self, + context: openshell_server::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + let mut config: vm::VmComputeConfig = context.driver_config()?; + if config.state_dir.as_os_str().is_empty() { + config.state_dir = vm::VmComputeConfig::default_state_dir(); + } + if config.grpc_endpoint.trim().is_empty() + && (!context.gateway_tls_enabled() || context.guest_tls_paths().is_some()) + { + let scheme = if context.gateway_tls_enabled() { + "https" + } else { + "http" + }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); + } + apply_guest_tls( + &mut config.guest_tls_ca, + &mut config.guest_tls_cert, + &mut config.guest_tls_key, + context.guest_tls_paths(), + ); + let endpoint = vm::spawn( + context.gateway_log_level(), + context.gateway_name(), + &config, + context.otlp_config(), + ) + .await?; + Ok(openshell_server::ComputeDriverInstance::ManagedRemote( + endpoint, + )) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn apply_guest_tls( + ca: &mut Option, + cert: &mut Option, + key: &mut Option, + defaults: Option<(&std::path::Path, &std::path::Path, &std::path::Path)>, +) { + if ca.is_none() + && cert.is_none() + && key.is_none() + && let Some((default_ca, default_cert, default_key)) = defaults + { + *ca = Some(default_ca.to_owned()); + *cert = Some(default_cert.to_owned()); + *key = Some(default_key.to_owned()); + } +} + +#[cfg(all(test, target_os = "windows", feature = "in-tree-compute-drivers"))] +mod windows_tests { + use super::*; + + #[test] + fn windows_builtin_compute_drivers_report_unsupported() { + let registry = install_default_compute_drivers(); + assert_eq!( + registry.installed_driver_names().collect::>(), + ["docker", "kubernetes", "mxc", "podman", "vm"] + ); + + for name in ["docker", "kubernetes", "podman", "vm"] { + let message = unsupported_windows_compute_driver(name).to_string(); + assert!( + message.contains("unsupported on Windows"), + "{name} rejection should be explicit, got: {message}" + ); + } + } +} diff --git a/crates/openshell-gateway/src/main.rs b/crates/openshell-gateway/src/main.rs new file mode 100644 index 0000000000..85d0611867 --- /dev/null +++ b/crates/openshell-gateway/src/main.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[tokio::main] +async fn main() -> miette::Result<()> { + openshell_server::cli::run_cli_with_compute_drivers( + openshell_gateway::install_default_compute_drivers(), + ) + .await +} diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-gateway/src/vm.rs similarity index 78% rename from crates/openshell-server/src/compute/vm.rs rename to crates/openshell-gateway/src/vm.rs index 80b445d201..f52bd9ddfa 100644 --- a/crates/openshell-server/src/compute/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -20,28 +20,24 @@ //! [`openshell_core::Config`] so the shared core stays free of driver-specific //! plumbing. //! -//! TODO(driver-abstraction): this module still assumes the concrete VM driver -//! (argv shape, guest-TLS flags, libkrun-specific settings). Once we land the -//! generalized compute-driver interface, the CLI-arg plumbing below should -//! be replaced with a driver-agnostic launcher that speaks gRPC to -//! configure the driver — and this file should collapse to the types that -//! are genuinely VM-specific (libkrun log level, vCPU / memory shape) plus a -//! trait implementation registering the VM driver against the generic -//! interface. - -use super::AcquiredRemoteDriverEndpoint; -#[cfg(unix)] -use super::ManagedDriverProcess; -use crate::config_file::OtlpConfig; -#[cfg(unix)] -use crate::otel_tracing::TraceContextInterceptor; +//! Process launch remains deliberately VM-specific at this binary composition +//! boundary: it translates gateway configuration into the standalone driver's +//! argv and then connects through the same public compute-driver RPC interface +//! used by operator-managed external drivers. + #[cfg(unix)] use hyper_util::rt::TokioIo; #[cfg(unix)] use openshell_core::proto::compute::v1::{ GetCapabilitiesRequest, compute_driver_client::ComputeDriverClient, }; -use openshell_core::{ComputeDriverKind, Config, Error, Result}; +use openshell_core::{Error, Result}; +#[cfg(unix)] +use openshell_otel::TraceContextInterceptor; +use openshell_server::AcquiredRemoteDriverEndpoint; +#[cfg(unix)] +use openshell_server::ManagedDriverProcess; +use openshell_server::config_file::OtlpConfig; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; #[cfg(unix)] @@ -103,6 +99,34 @@ pub struct VmComputeConfig { /// Host-side private key for the guest's mTLS client bundle. pub guest_tls_key: Option, + + /// Corporate forward proxy URL (`http://host:port` or `https://host:port`) + /// for policy-approved TLS egress from VM sandboxes. + /// + /// Deployment-level configuration, not a per-sandbox setting: it is passed + /// to the driver, which puts it on the guest supervisor's argv. A proxy on + /// this host's loopback is reachable from a guest only through the gvproxy + /// host alias `host.openshell.internal`. + pub https_proxy: Option, + + /// Comma-separated `NO_PROXY` list. Bypasses only the corporate proxy, + /// never `OpenShell` policy evaluation. + pub no_proxy: Option, + + /// Path on this host to a `user:pass` corporate proxy credential file. + pub proxy_auth_file: Option, + + /// Acknowledgement that Basic auth to an `http://` proxy is cleartext. + /// Required alongside `proxy_auth_file` unless the proxy is `https://`. + pub proxy_auth_allow_insecure: Option, + + /// Send hostnames rather than validated IPs in CONNECT requests. Last + /// resort for proxies whose ACLs reject IP CONNECT targets. + pub proxy_connect_by_hostname: Option, + + /// Path on this host to a PEM CA bundle trusted for the corporate proxy + /// and for server certificates a TLS-intercepting proxy re-signs. + pub proxy_ca_bundle: Option, } impl VmComputeConfig { @@ -139,6 +163,29 @@ impl VmComputeConfig { 4096 } + /// Validate the corporate upstream-proxy settings, fail-closed. + /// + /// Runs in the gateway as well as in the driver so an invalid + /// `[openshell.drivers.vm]` table reports the offending key instead of + /// surfacing as an opaque driver-startup timeout. + /// + /// # Errors + /// + /// Returns a [`Error::config`] naming the offending key. + pub fn validate_proxy_config(&self) -> Result<()> { + openshell_core::driver_utils::validate_upstream_proxy_settings( + &openshell_core::driver_utils::UpstreamProxySettings { + url: self.https_proxy.as_deref(), + no_proxy: self.no_proxy.as_deref(), + auth_file: self.proxy_auth_file.as_deref(), + auth_allow_insecure: self.proxy_auth_allow_insecure, + connect_by_hostname: self.proxy_connect_by_hostname, + ca_bundle: self.proxy_ca_bundle.as_deref(), + }, + ) + .map_err(Error::config) + } + #[must_use] fn default_driver_search_dirs(home: Option) -> Vec { let mut dirs = Vec::new(); @@ -167,6 +214,12 @@ impl Default for VmComputeConfig { guest_tls_ca: None, guest_tls_cert: None, guest_tls_key: None, + https_proxy: None, + no_proxy: None, + proxy_auth_file: None, + proxy_auth_allow_insecure: None, + proxy_connect_by_hostname: None, + proxy_ca_bundle: None, } } } @@ -453,7 +506,8 @@ pub fn compute_driver_guest_tls_paths( /// kills the subprocess and removes the socket on drop. #[cfg(unix)] pub async fn spawn( - config: &Config, + gateway_log_level: &str, + gateway_name: &str, vm_config: &VmComputeConfig, otlp_config: Option<&OtlpConfig>, ) -> Result { @@ -463,6 +517,8 @@ pub async fn spawn( )); } + vm_config.validate_proxy_config()?; + let driver_bin = resolve_compute_driver_bin(vm_config)?; let socket_path = compute_driver_socket_path(vm_config); let guest_tls_paths = compute_driver_guest_tls_paths(vm_config)?; @@ -477,8 +533,8 @@ pub async fn spawn( command .arg("--expected-peer-pid") .arg(std::process::id().to_string()); - command.arg("--log-level").arg(&config.log_level); - append_otlp_args(&mut command, otlp_config); + command.arg("--log-level").arg(gateway_log_level); + append_otlp_args(&mut command, otlp_config, gateway_name); command .arg("--openshell-endpoint") .arg(&vm_config.grpc_endpoint); @@ -504,6 +560,7 @@ pub async fn spawn( command.arg("--guest-tls-cert").arg(tls.cert); command.arg("--guest-tls-key").arg(tls.key); } + append_upstream_proxy_args(&mut command, vm_config); let mut child = command.spawn().map_err(|e| { Error::execution(format!( @@ -513,23 +570,55 @@ pub async fn spawn( })?; let channel = wait_for_compute_driver(&socket_path, &mut child).await?; let process = Arc::new(ManagedDriverProcess::new(child, socket_path)); - Ok(AcquiredRemoteDriverEndpoint::managed_builtin( - ComputeDriverKind::Vm, - channel, - process, + Ok(AcquiredRemoteDriverEndpoint::managed( + "vm", channel, process, )) } +/// Forward the operator's corporate proxy settings to the driver subprocess. +/// +/// Only keys the operator actually set are passed, so the driver keeps the +/// same "omitted means no proxy" contract the supervisor enforces. The +/// booleans travel as explicit values rather than presence flags so an +/// explicit `false` still trips the driver's pairing checks. #[cfg(unix)] -fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>) { +fn append_upstream_proxy_args(command: &mut Command, vm_config: &VmComputeConfig) { + if let Some(url) = &vm_config.https_proxy { + command.arg("--https-proxy").arg(url); + } + if let Some(list) = &vm_config.no_proxy { + command.arg("--no-proxy").arg(list); + } + if let Some(path) = &vm_config.proxy_auth_file { + command.arg("--proxy-auth-file").arg(path); + } + if let Some(allow) = vm_config.proxy_auth_allow_insecure { + command + .arg("--proxy-auth-allow-insecure") + .arg(allow.to_string()); + } + if let Some(by_hostname) = vm_config.proxy_connect_by_hostname { + command + .arg("--proxy-connect-by-hostname") + .arg(by_hostname.to_string()); + } + if let Some(path) = &vm_config.proxy_ca_bundle { + command.arg("--proxy-ca-bundle").arg(path); + } +} + +#[cfg(unix)] +fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>, gateway_name: &str) { if let Some(config) = otlp_config { command.arg("--otlp-endpoint").arg(&config.endpoint); + command.arg("--gateway-name").arg(gateway_name); } } #[cfg(not(unix))] pub async fn spawn( - _config: &Config, + _gateway_log_level: &str, + _gateway_name: &str, _vm_config: &VmComputeConfig, _otlp_config: Option<&OtlpConfig>, ) -> Result { @@ -609,19 +698,19 @@ async fn connect_compute_driver(socket_path: &Path) -> Result { #[cfg(all(test, unix))] mod tests { use super::{ - VmComputeConfig, append_otlp_args, compute_driver_guest_tls_paths, - compute_driver_socket_path, current_euid, prepare_compute_driver_socket_path, - prepare_vm_state_dir, resolve_compute_driver_bin, resolve_driver_search_dirs, - wait_for_compute_driver, + VmComputeConfig, append_otlp_args, append_upstream_proxy_args, + compute_driver_guest_tls_paths, compute_driver_socket_path, current_euid, + prepare_compute_driver_socket_path, prepare_vm_state_dir, resolve_compute_driver_bin, + resolve_driver_search_dirs, }; - use crate::config_file::OtlpConfig; + use openshell_server::config_file::OtlpConfig; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener as StdUnixListener; use std::path::PathBuf; use tempfile::tempdir; #[test] - fn vm_driver_command_includes_gateway_otlp_endpoint() { + fn vm_driver_command_includes_gateway_otlp_configuration() { let mut command = tokio::process::Command::new("openshell-driver-vm"); append_otlp_args( &mut command, @@ -629,6 +718,7 @@ mod tests { endpoint: "http://collector.internal:4317".to_string(), service_name: Some("custom-gateway".to_string()), }), + "production-us-west", ); let args = command @@ -636,46 +726,94 @@ mod tests { .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(); - assert_eq!(args, ["--otlp-endpoint", "http://collector.internal:4317"]); + assert_eq!( + args, + [ + "--otlp-endpoint", + "http://collector.internal:4317", + "--gateway-name", + "production-us-west" + ] + ); } - #[tokio::test] - async fn readiness_probe_propagates_the_active_trace() { - use crate::otel_tracing::test_exporter; - use crate::test_support::FakeComputeDriver; - - let dir = tempdir().unwrap(); - let socket_path = dir.path().join("compute-driver.sock"); - let driver = FakeComputeDriver::new(); - let _server = driver.serve_uds(&socket_path).unwrap(); - let mut child = tokio::process::Command::new("sh") - .arg("-c") - .arg("read _") - .stdin(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - .unwrap(); - - let traced = test_exporter::install_traced(); - wait_for_compute_driver(&socket_path, &mut child) - .await - .unwrap(); + #[test] + fn vm_driver_command_forwards_corporate_proxy_settings() { + let mut command = tokio::process::Command::new("openshell-driver-vm"); + append_upstream_proxy_args( + &mut command, + &VmComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + no_proxy: Some("10.0.0.0/8".to_string()), + proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + proxy_auth_allow_insecure: Some(true), + proxy_connect_by_hostname: Some(false), + proxy_ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem".to_string()), + ..VmComputeConfig::default() + }, + ); - let readiness = traced.spans_named("driver.wait_for_ready"); - assert_eq!(readiness.len(), 1, "one readiness operation should finish"); - test_exporter::assert_is_root(&readiness[0]); - let trace_id = readiness[0].span_context.trace_id().to_string(); + let args = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); assert_eq!( - driver.traceparents().len(), - 1, - "the readiness capability probe should carry trace context" - ); - assert!( - driver.traceparents()[0].contains(&trace_id), - "the readiness probe should be part of the active trace" + args, + [ + "--https-proxy", + "http://proxy.corp.com:8080", + "--no-proxy", + "10.0.0.0/8", + "--proxy-auth-file", + "/etc/openshell/secrets/proxy-auth", + "--proxy-auth-allow-insecure", + "true", + // Passed as an explicit value, not a presence flag, so the + // driver still sees the operator's `false`. + "--proxy-connect-by-hostname", + "false", + "--proxy-ca-bundle", + "/etc/openshell/tls/proxy-ca.pem", + ] ); } + #[test] + fn vm_driver_command_omits_unset_corporate_proxy_settings() { + let mut command = tokio::process::Command::new("openshell-driver-vm"); + append_upstream_proxy_args(&mut command, &VmComputeConfig::default()); + assert_eq!(command.as_std().get_args().count(), 0); + } + + #[test] + fn invalid_corporate_proxy_config_is_rejected_before_the_driver_starts() { + // Without this the operator would see an opaque driver-readiness + // timeout instead of an error naming the offending key. + let err = VmComputeConfig { + https_proxy: Some("socks5://proxy.corp.com:1080".to_string()), + ..VmComputeConfig::default() + } + .validate_proxy_config() + .expect_err("only http:// and https:// proxies are supported"); + assert!(err.to_string().contains("https_proxy"), "{err}"); + + let err = VmComputeConfig { + proxy_ca_bundle: Some("/etc/openshell/tls/proxy-ca.pem".to_string()), + ..VmComputeConfig::default() + } + .validate_proxy_config() + .expect_err("a CA bundle without a proxy URL would hide a fail-open state"); + assert!(err.to_string().contains("proxy_ca_bundle"), "{err}"); + + VmComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + ..VmComputeConfig::default() + } + .validate_proxy_config() + .expect("a lone proxy URL is a complete configuration"); + } + #[test] fn resolve_driver_bin_uses_driver_dir_when_binary_present() { let dir = tempdir().unwrap(); diff --git a/crates/openshell-ocsf/BUILD.bazel b/crates/openshell-ocsf/BUILD.bazel deleted file mode 100644 index 4b26a9bab8..0000000000 --- a/crates/openshell-ocsf/BUILD.bazel +++ /dev/null @@ -1,31 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-ocsf", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-ocsf_test", - crate = ":openshell-ocsf", - data = glob(["schemas/**/*"]), - rustc_env = { - "CARGO_MANIFEST_DIR": "crates/openshell-ocsf", - }, - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-ocsf", - ":openshell-ocsf_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-ocsf/Cargo.toml b/crates/openshell-ocsf/Cargo.toml index 14cc93ba31..be91b1547a 100644 --- a/crates/openshell-ocsf/Cargo.toml +++ b/crates/openshell-ocsf/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "openshell-ocsf" -description = "OCSF v1.7.0 event types, formatters, and tracing layers for OpenShell sandbox logging" +description = "OCSF v1.8.0 event types, formatters, and tracing layers for OpenShell sandbox logging" version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/openshell-ocsf/schemas/ocsf/README.md b/crates/openshell-ocsf/schemas/ocsf/README.md index 520325a01a..b15b6511c6 100644 --- a/crates/openshell-ocsf/schemas/ocsf/README.md +++ b/crates/openshell-ocsf/schemas/ocsf/README.md @@ -5,7 +5,7 @@ for offline test validation. ## Version -- **OCSF v1.7.0** — fetched from `https://schema.ocsf.io/api/1.7.0/` +- **OCSF v1.8.0** — fetched from `https://schema.ocsf.io/api/1.8.0/` ## Contents @@ -25,14 +25,18 @@ for offline test validation. - `metadata`, `network_endpoint`, `network_proxy`, `process`, `actor` - `device`, `container`, `product`, `firewall_rule`, `finding_info` - `evidences`, `http_request`, `http_response`, `url`, `attack` -- `remediation`, `connection_info` +- `remediation`, `connection_info`, `ai_model` + +### Profiles (1) + +- `ai_operation` ## Updating To update to a new OCSF version: ```bash -VERSION=1.7.0 +VERSION=1.8.0 for class in network_activity http_activity ssh_activity process_activity \ detection_finding application_lifecycle device_config_state_change base_event; do diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/VERSION b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/VERSION new file mode 100644 index 0000000000..27f9cd322b --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/VERSION @@ -0,0 +1 @@ +1.8.0 diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/api_activity.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/api_activity.json new file mode 100644 index 0000000000..f723fb1046 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/api_activity.json @@ -0,0 +1,964 @@ +{ + "attributes": { + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "resources": { + "type": "object_t", + "description": "Details about resources that were affected by the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "recommended", + "caption": "Resources Array", + "object_type": "resource_details", + "object_name": "Resource Details" + }, + "http_response": { + "type": "object_t", + "description": "Details about the underlying http response.", + "group": "primary", + "requirement": "recommended", + "caption": "HTTP Response", + "object_type": "http_response", + "object_name": "HTTP Response" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "message_context": { + "type": "object_t", + "description": "Communication context for AI system interactions including protocols, roles, clients, and session information for MCP and other AI communication systems.", + "group": "context", + "requirement": "optional", + "caption": "Message Context", + "object_type": "message_context", + "profiles": [ + "ai_operation" + ], + "object_name": "Message Context" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "recommended", + "caption": "Device", + "object_type": "device", + "profiles": [ + "host" + ], + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "600300": { + "caption": "API Activity: Unknown" + }, + "600399": { + "caption": "API Activity: Other" + }, + "600301": { + "description": "The API call in the event pertains to a 'create' activity.", + "caption": "API Activity: Create" + }, + "600302": { + "description": "The API call in the event pertains to a 'read' activity.", + "caption": "API Activity: Read" + }, + "600303": { + "description": "The API call in the event pertains to a 'update' activity.", + "caption": "API Activity: Update" + }, + "600304": { + "description": "The API call in the event pertains to a 'delete' activity.", + "caption": "API Activity: Delete" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "trace": { + "type": "object_t", + "description": "The trace object contains information about distributed traces which are critical to observability and describe how requests move through a system, capturing each step's timing and status.", + "group": "primary", + "requirement": "recommended", + "caption": "Trace", + "object_type": "trace", + "profiles": [ + "trace" + ], + "object_name": "Trace" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "6": { + "description": "Application Activity events report detailed information about the behavior of applications and services.", + "uid": 6, + "caption": "Application Activity" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The API call in the event pertains to a 'update' activity.", + "caption": "Update" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The API call in the event pertains to a 'create' activity.", + "caption": "Create" + }, + "2": { + "description": "The API call in the event pertains to a 'read' activity.", + "caption": "Read" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The API call in the event pertains to a 'delete' activity.", + "caption": "Delete" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Application Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "ai_model": { + "type": "object_t", + "description": "The AI Model object describes the characteristics of an AI/ML model. Examples include language models like GPT-4, embedding models like text-embedding-ada-002, and computer vision models like CLIP.", + "group": "context", + "requirement": "recommended", + "caption": "AI Model", + "object_type": "ai_model", + "profiles": [ + "ai_operation" + ], + "object_name": "AI Model" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: API Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "http_request": { + "type": "object_t", + "description": "Details about the underlying http request.", + "group": "primary", + "requirement": "recommended", + "caption": "HTTP Request", + "object_type": "http_request", + "object_name": "HTTP Request" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "6003": { + "description": "API events describe general CRUD (Create, Read, Update, Delete) API activities, e.g. (AWS Cloudtrail)", + "caption": "API Activity" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "dst_endpoint": { + "type": "object_t", + "description": "The network destination endpoint.", + "group": "primary", + "requirement": "recommended", + "caption": "Destination Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "src_endpoint": { + "type": "object_t", + "description": "Details about the source of the activity.", + "group": "primary", + "requirement": "required", + "caption": "Source Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "primary", + "requirement": "required", + "caption": "Actor", + "object_type": "actor", + "profiles": null, + "object_name": "Actor" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "primary", + "requirement": "required", + "caption": "API Details", + "object_type": "api", + "profiles": null, + "object_name": "API" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "api_activity", + "description": "API events describe general CRUD (Create, Read, Update, Delete) API activities, e.g. (AWS Cloudtrail)", + "uid": 6003, + "extends": "application", + "category": "application", + "caption": "API Activity", + "profiles": [ + "ai_operation", + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "macos/macos_users", + "osint", + "security_control", + "trace" + ], + "category_name": "Application Activity", + "category_uid": 6 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/application_lifecycle.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/application_lifecycle.json new file mode 100644 index 0000000000..7bf04ea1fa --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/application_lifecycle.json @@ -0,0 +1,931 @@ +{ + "attributes": { + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "recommended", + "caption": "Device", + "object_type": "device", + "profiles": [ + "host" + ], + "object_name": "Device" + }, + "app": { + "type": "object_t", + "description": "The application that was affected by the lifecycle event. This also applies to self-updating application systems.", + "group": "primary", + "requirement": "required", + "caption": "Application", + "object_type": "product", + "object_name": "Product" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "600200": { + "caption": "Application Lifecycle: Unknown" + }, + "600299": { + "caption": "Application Lifecycle: Other" + }, + "600201": { + "description": "Install the application.", + "caption": "Application Lifecycle: Install" + }, + "600202": { + "description": "Remove the application.", + "caption": "Application Lifecycle: Remove" + }, + "600203": { + "description": "Start the application.", + "caption": "Application Lifecycle: Start" + }, + "600204": { + "description": "Stop the application.", + "caption": "Application Lifecycle: Stop" + }, + "600205": { + "description": "Restart the application.", + "caption": "Application Lifecycle: Restart" + }, + "600206": { + "description": "Enable the application.", + "caption": "Application Lifecycle: Enable" + }, + "600207": { + "description": "Disable the application.", + "caption": "Application Lifecycle: Disable" + }, + "600208": { + "description": "Update the application.", + "caption": "Application Lifecycle: Update" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "6": { + "description": "Application Activity events report detailed information about the behavior of applications and services.", + "uid": 6, + "caption": "Application Activity" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Start the application.", + "caption": "Start" + }, + "6": { + "description": "Enable the application.", + "caption": "Enable" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Install the application.", + "caption": "Install" + }, + "2": { + "description": "Remove the application.", + "caption": "Remove" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Stop the application.", + "caption": "Stop" + }, + "5": { + "description": "Restart the application.", + "caption": "Restart" + }, + "7": { + "description": "Disable the application.", + "caption": "Disable" + }, + "8": { + "description": "Update the application.", + "caption": "Update" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Application Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: Application Lifecycle.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "6002": { + "description": "Application Lifecycle events report installation, removal, start, stop of an application or service.", + "caption": "Application Lifecycle" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "primary", + "requirement": "optional", + "caption": "Actor", + "object_type": "actor", + "profiles": [ + "host" + ], + "object_name": "Actor" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "application_lifecycle", + "description": "Application Lifecycle events report installation, removal, start, stop of an application or service.", + "uid": 6002, + "extends": "application", + "category": "application", + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:ApplicationEvent", + "url": "https://d3fend.mitre.org/event/d3f:ApplicationEvent/" + } + ], + "caption": "Application Lifecycle", + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "macos/macos_users", + "osint", + "security_control" + ], + "category_name": "Application Activity", + "category_uid": 6 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/base_event.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/base_event.json new file mode 100644 index 0000000000..14eee209b5 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/base_event.json @@ -0,0 +1,848 @@ +{ + "attributes": { + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "recommended", + "caption": "Device", + "object_type": "device", + "profiles": [ + "host" + ], + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "0": { + "caption": "Base Event: Unknown" + }, + "99": { + "caption": "Base Event: Other" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "0": { + "caption": "Uncategorized" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: Base Event.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "0": { + "description": "The base event is a generic and concrete event. It also defines a set of attributes available in most event classes. As a generic event that does not belong to any event category, it could be used to log events that are not otherwise defined by the schema.", + "caption": "Base Event" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "primary", + "requirement": "optional", + "caption": "Actor", + "object_type": "actor", + "profiles": [ + "host" + ], + "object_name": "Actor" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "base_event", + "description": "The base event is a generic and concrete event. It also defines a set of attributes available in most event classes. As a generic event that does not belong to any event category, it could be used to log events that are not otherwise defined by the schema.", + "uid": 0, + "category": "other", + "caption": "Base Event", + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "macos/macos_users", + "osint", + "security_control" + ], + "category_uid": 0 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/detection_finding.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/detection_finding.json new file mode 100644 index 0000000000..f0a1adc9d7 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/detection_finding.json @@ -0,0 +1,1210 @@ +{ + "attributes": { + "verdict": { + "type": "string_t", + "description": "The verdict assigned to an Incident finding.", + "group": "primary", + "requirement": "recommended", + "caption": "Verdict", + "profiles": [ + "incident" + ], + "type_name": "String" + }, + "vendor_attributes": { + "type": "object_t", + "description": "The Vendor Attributes object can be used to represent values of attributes populated by the Vendor/Finding Provider. It can help distinguish between the vendor-provided values and consumer-updated values, of key attributes like severity_id.
The original finding producer should not populate this object. It should be populated by consuming systems that support data mutability.", + "group": "context", + "requirement": "optional", + "caption": "Vendor Attributes", + "object_type": "vendor_attributes", + "object_name": "Vendor Attributes" + }, + "priority_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Critical functionality or network access is interrupted, degraded or unusable, having a severe impact on services availability. No acceptable alternative is possible.", + "caption": "High" + }, + "0": { + "description": "No priority is assigned.", + "caption": "Unknown" + }, + "1": { + "description": "Application or personal procedure is unusable, where a workaround is available or a repair is possible.", + "caption": "Low" + }, + "2": { + "description": "Non-critical function or procedure is unusable or hard to use causing operational disruptions with no direct impact on a service's availability. A workaround is available.", + "caption": "Medium" + }, + "99": { + "description": "The priority is not normalized.", + "caption": "Other" + }, + "4": { + "description": "Interruption making a critical functionality inaccessible or a complete network interruption causing a severe impact on services availability. There is no possible alternative.", + "caption": "Critical" + } + }, + "description": "The normalized priority. Priority identifies the relative importance of the incident or finding. It is a measurement of urgency.", + "group": "context", + "requirement": "recommended", + "caption": "Priority ID", + "sibling": "priority", + "profiles": [ + "incident" + ], + "type_name": "Integer" + }, + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": null, + "type_name": "Integer" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "comment": { + "type": "string_t", + "description": "A user provided comment about the finding.", + "group": "context", + "requirement": "optional", + "caption": "Comment", + "type_name": "String" + }, + "impact_score": { + "type": "integer_t", + "description": "The impact as an integer value of the finding, valid range 0-100.", + "group": "context", + "requirement": "optional", + "caption": "Impact Score", + "profiles": null, + "type_name": "Integer" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "resources": { + "type": "object_t", + "description": "Describes details about resources that were the target of the activity that triggered the finding.", + "group": "context", + "is_array": true, + "requirement": "recommended", + "caption": "Affected Resources", + "object_type": "resource_details", + "object_name": "Resource Details" + }, + "priority": { + "type": "string_t", + "description": "The priority, normalized to the caption of the priority_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Priority", + "profiles": [ + "incident" + ], + "type_name": "String" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The time of the least recent event included in the finding.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "impact": { + "type": "string_t", + "description": "The impact , normalized to the caption of the impact_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Impact", + "profiles": null, + "type_name": "String" + }, + "evidences": { + "type": "object_t", + "description": "Describes various evidence artifacts associated to the activity/activities that triggered a security detection.", + "group": "primary", + "is_array": true, + "requirement": "recommended", + "caption": "Evidence Artifacts", + "object_type": "evidences", + "object_name": "Evidence Artifacts" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The time of the most recent event included in the finding.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": null, + "type_name": "String" + }, + "finding_info": { + "type": "object_t", + "description": "Describes the supporting information about a generated finding.", + "group": "primary", + "requirement": "required", + "caption": "Finding Information", + "object_type": "finding_info", + "object_name": "Finding Information" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about malware scan job that triggered this Detection Finding.", + "group": "context", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": null, + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The time of the most recent event included in the finding.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "verdict_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The incident can be disregarded as it is unimportant, an error or accident.", + "caption": "Disregard" + }, + "6": { + "description": "The incident is a test.", + "caption": "Test" + }, + "0": { + "description": "The type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The incident is a false positive.", + "caption": "False Positive" + }, + "2": { + "description": "The incident is a true positive.", + "caption": "True Positive" + }, + "99": { + "description": "The type is not mapped. See the type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The incident is suspicious.", + "caption": "Suspicious" + }, + "5": { + "description": "The incident is benign.", + "caption": "Benign" + }, + "7": { + "description": "The incident has insufficient data to make a verdict.", + "caption": "Insufficient Data" + }, + "8": { + "description": "The incident is a security risk.", + "caption": "Security Risk" + }, + "9": { + "description": "The incident remediation or required actions are managed externally.", + "caption": "Managed Externally" + }, + "10": { + "description": "The incident is a duplicate.", + "caption": "Duplicate" + } + }, + "description": "The normalized verdict of an Incident.", + "group": "primary", + "requirement": "recommended", + "caption": "Verdict ID", + "sibling": "verdict", + "profiles": [ + "incident" + ], + "type_name": "Integer" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "src_url": { + "type": "url_t", + "description": "A Url link used to access the original incident.", + "group": "primary", + "requirement": "recommended", + "caption": "Source URL", + "profiles": [ + "incident" + ], + "type_name": "URL String" + }, + "device": { + "type": "object_t", + "description": "Describes the affected device/host. If applicable, it can be used in conjunction with Resource(s).

e.g. Specific details about an AWS EC2 instance, that is affected by the Finding.

", + "group": "context", + "requirement": "optional", + "caption": "Device", + "object_type": "device", + "profiles": null, + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": null, + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "200400": { + "caption": "Detection Finding: Unknown" + }, + "200499": { + "caption": "Detection Finding: Other" + }, + "200401": { + "description": "A finding was created.", + "caption": "Detection Finding: Create" + }, + "200402": { + "description": "A finding was updated.", + "caption": "Detection Finding: Update" + }, + "200403": { + "description": "A finding was closed.", + "caption": "Detection Finding: Close" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "ticket": { + "type": "object_t", + "description": "The linked ticket in the ticketing system.", + "group": "context", + "requirement": "optional", + "caption": "Ticket", + "object_type": "ticket", + "profiles": [ + "incident" + ], + "@deprecated": { + "message": "Use tickets instead.", + "since": "1.5.0" + }, + "object_name": "Ticket" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": null, + "type_name": "Integer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "2": { + "description": "Findings events report findings, detections, and possible resolutions of malware, anomalies, or other actions performed by security products.", + "uid": 2, + "caption": "Findings" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A finding was closed.", + "caption": "Close" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A finding was created.", + "caption": "Create" + }, + "2": { + "description": "A finding was updated.", + "caption": "Update" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the finding activity.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "impact_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The magnitude of harm is high.", + "caption": "High" + }, + "0": { + "description": "The normalized impact is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The magnitude of harm is low.", + "caption": "Low" + }, + "2": { + "description": "The magnitude of harm is moderate.", + "caption": "Medium" + }, + "99": { + "description": "The impact is not mapped. See the impact attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The magnitude of harm is high and the scope is widespread.", + "caption": "Critical" + } + }, + "description": "The normalized impact of the incident or finding. Per NIST, this is the magnitude of harm that can be expected to result from the consequences of unauthorized disclosure, modification, destruction, or loss of information or information system availability.", + "group": "context", + "source": "impact value; impact level", + "references": [ + { + "description": "NIST SP 800-172 from FIPS 199", + "url": "https://doi.org/10.6028/NIST.FIPS.199" + }, + { + "description": "NIST Computer Security Resource Center", + "url": "https://doi.org/10.6028/NIST.FIPS.199" + } + ], + "requirement": "optional", + "caption": "Impact ID", + "sibling": "impact", + "profiles": null, + "type_name": "Integer" + }, + "tickets": { + "type": "object_t", + "description": "The associated ticket(s) in the ticketing system. Each ticket contains details like ticket ID, status, etc.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Tickets", + "object_type": "ticket", + "profiles": [ + "incident" + ], + "object_name": "Ticket" + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Findings.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The time of the least recent event included in the finding.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "remediation": { + "type": "object_t", + "description": "Describes the recommended remediation steps to address identified issue(s).", + "group": "context", + "requirement": "optional", + "caption": "Remediation Guidance", + "object_type": "remediation", + "object_name": "Remediation" + }, + "status_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The Finding was reviewed, determined to be benign or a false positive and is now suppressed.", + "caption": "Suppressed" + }, + "6": { + "description": "The Finding was deleted. For example, it might have been created in error.", + "caption": "Deleted" + }, + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The Finding is new and yet to be reviewed.", + "caption": "New" + }, + "2": { + "description": "The Finding is under review.", + "caption": "In Progress" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The Finding was reviewed, remediated and is now considered resolved.", + "caption": "Resolved" + }, + "5": { + "description": "The Finding was archived.", + "caption": "Archived" + } + }, + "description": "The normalized status identifier of the Finding, set by the consumer.", + "group": "context", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: Detection Finding.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "activity_name": { + "type": "string_t", + "description": "The finding activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "2004": { + "description": "A Detection Finding describes detections or alerts generated by security products using correlation engines, detection engines or other methodologies. Note: if the event producer is a security control, the security_control profile should be applied and its attacks information, if present, should be duplicated into the finding_info object.
Note: If the Finding is an incident, i.e. requires incident workflow, also apply the incident profile or aggregate this finding into an Incident Finding.", + "caption": "Detection Finding" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "is_suspected_breach": { + "type": "boolean_t", + "description": "A determination based on analytics as to whether a potential breach was found.", + "group": "context", + "requirement": "optional", + "caption": "Suspected Breach", + "profiles": [ + "incident" + ], + "type_name": "Boolean" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": null, + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. For example, an activity_id of 'Create' could constitute an alertable signal and the value would be true, while 'Close' likely would not and either omit the attribute or set its value to false. Note that other events with the security_control profile may also be deemed alertable signals and may also carry is_alert = true attributes.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": null, + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": null, + "type_name": "String" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "Describes malware reported in a Detection Finding.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": null, + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "primary", + "requirement": "optional", + "caption": "Actor", + "object_type": "actor", + "profiles": [ + "host" + ], + "object_name": "Actor" + }, + "anomaly_analyses": { + "type": "object_t", + "description": "Describes baseline information about normal activity patterns, along with any detected deviations or anomalies that triggered this finding.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Anomaly Analyses", + "object_type": "anomaly_analysis", + "object_name": "Anomaly Analysis" + }, + "assignee_group": { + "type": "object_t", + "description": "The details of the group assigned to an Incident.", + "group": "context", + "requirement": "optional", + "caption": "Assignee Group", + "object_type": "group", + "profiles": [ + "incident" + ], + "object_name": "Group" + }, + "vulnerabilities": { + "type": "object_t", + "description": "Describes vulnerabilities reported in a Detection Finding.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Vulnerabilities", + "object_type": "vulnerability", + "object_name": "Vulnerability Details" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": null, + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The normalized status of the Finding set by the consumer normalized to the caption of the status_id value. In the case of 'Other', it is defined by the source.", + "group": "context", + "requirement": "optional", + "caption": "Status", + "type_name": "String" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "assignee": { + "type": "object_t", + "description": "The details of the user assigned to an Incident.", + "group": "context", + "requirement": "optional", + "caption": "Assignee", + "object_type": "user", + "profiles": [ + "incident" + ], + "object_name": "User" + } + }, + "name": "detection_finding", + "description": "A Detection Finding describes detections or alerts generated by security products using correlation engines, detection engines or other methodologies. Note: if the event producer is a security control, the security_control profile should be applied and its attacks information, if present, should be duplicated into the finding_info object.
Note: If the Finding is an incident, i.e. requires incident workflow, also apply the incident profile or aggregate this finding into an Incident Finding.", + "uid": 2004, + "extends": "finding", + "category": "findings", + "caption": "Detection Finding", + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "incident", + "linux/linux_users", + "macos/macos_users", + "osint", + "security_control" + ], + "category_name": "Findings", + "category_uid": 2 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/device_config_state_change.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/device_config_state_change.json new file mode 100644 index 0000000000..4ad3746fe6 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/device_config_state_change.json @@ -0,0 +1,987 @@ +{ + "attributes": { + "state_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The Config Change state is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Config State Changed to Disabled.", + "caption": "Disabled" + }, + "2": { + "description": "Config State Changed to Enabled.", + "caption": "Enabled" + }, + "99": { + "description": "The Config Change is not mapped. See the state attribute, which contains data source specific values.", + "caption": "Other" + } + }, + "description": "The Config Change State of the managed entity.", + "requirement": "recommended", + "caption": "Config Change State ID", + "sibling": "state", + "type_name": "Integer" + }, + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "state": { + "type": "string_t", + "description": "The Config Change Stat, normalized to the caption of the state_id value. In the case of 'Other', it is defined by the source.", + "requirement": "optional", + "caption": "Config Change State", + "type_name": "String" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "device": { + "type": "object_t", + "description": "The device that is impacted by the state change.", + "group": "primary", + "requirement": "required", + "caption": "Device", + "object_type": "device", + "profiles": null, + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "501900": { + "caption": "Device Config State Change: Unknown" + }, + "501999": { + "caption": "Device Config State Change: Other" + }, + "501901": { + "description": "The discovered information is via a log.", + "caption": "Device Config State Change: Log" + }, + "501902": { + "description": "The discovered information is via a collection process.", + "caption": "Device Config State Change: Collect" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "security_states": { + "type": "object_t", + "description": "The current security states of the device.", + "group": "primary", + "is_array": true, + "requirement": "recommended", + "caption": "Security States", + "object_type": "security_state", + "object_name": "Security State" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "prev_security_states": { + "type": "object_t", + "description": "The previous security states of the device.", + "group": "primary", + "is_array": true, + "requirement": "recommended", + "caption": "Previous Security States", + "object_type": "security_state", + "object_name": "Security State" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "5": { + "description": "Discovery events report the existence and state of devices, files, configurations, processes, registry keys, and other objects.", + "uid": 5, + "caption": "Discovery" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The discovered information is via a log.", + "caption": "Log" + }, + "2": { + "description": "The discovered information is via a collection process.", + "caption": "Collect" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Discovery.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "prev_security_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "Compromised" + }, + "0": { + "caption": "Unknown" + }, + "1": { + "caption": "Secure" + }, + "2": { + "caption": "At Risk" + }, + "99": { + "description": "The security level is not mapped. See the prev_security_level attribute, which contains data source specific values.", + "caption": "Other" + } + }, + "description": "The previous security level of the entity", + "group": "primary", + "requirement": "recommended", + "caption": "Previous Security Level ID", + "sibling": "prev_security_level", + "type_name": "Integer" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: Device Config State Change.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "security_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "Compromised" + }, + "0": { + "caption": "Unknown" + }, + "1": { + "caption": "Secure" + }, + "2": { + "caption": "At Risk" + }, + "99": { + "description": "The security level is not mapped. See the security_level attribute, which contains data source specific values.", + "caption": "Other" + } + }, + "description": "The current security level of the entity", + "group": "primary", + "requirement": "recommended", + "caption": "Security Level ID", + "sibling": "security_level", + "type_name": "Integer" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "5019": { + "description": "Device Config State Change events report state changes that impact the security of the device.", + "caption": "Device Config State Change" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "security_level": { + "type": "string_t", + "description": "The current security level of the entity", + "group": "primary", + "requirement": "recommended", + "caption": "Security Level", + "type_name": "String" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "prev_security_level": { + "type": "string_t", + "description": "The previous security level of the entity", + "group": "primary", + "requirement": "recommended", + "caption": "Previous Security Level", + "type_name": "String" + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "context", + "requirement": "optional", + "caption": "Actor", + "object_type": "actor", + "profiles": null, + "object_name": "Actor" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "device_config_state_change", + "description": "Device Config State Change events report state changes that impact the security of the device.", + "uid": 5019, + "extends": "discovery", + "category": "discovery", + "caption": "Device Config State Change", + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "macos/macos_users", + "osint", + "security_control" + ], + "category_name": "Discovery", + "category_uid": 5 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/http_activity.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/http_activity.json new file mode 100644 index 0000000000..e77b085853 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/http_activity.json @@ -0,0 +1,1243 @@ +{ + "attributes": { + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "proxy_http_response": { + "type": "object_t", + "description": "The HTTP Response from the remote server to the proxy server.", + "group": "context", + "requirement": "optional", + "caption": "Proxy HTTP Response", + "object_type": "http_response", + "profiles": [ + "network_proxy" + ], + "object_name": "HTTP Response" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "app_name": { + "type": "string_t", + "description": "The network application name identified by tools such as NBAR or App ID (e.g., youtube, facebook, webex). This represents a specific network application that uses standard protocols (such as https or quic) to deliver its service.", + "group": "context", + "requirement": "optional", + "caption": "Application Name", + "type_name": "String" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "http_response": { + "type": "object_t", + "description": "The HTTP Response from a web server to a requester.", + "group": "primary", + "requirement": "recommended", + "caption": "HTTP Response", + "object_type": "http_response", + "object_name": "HTTP Response" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "network_observation_point": { + "type": "object_t", + "description": "The network endpoint that observes or inspects network traffic as a third-party system, used when the observer is neither the source nor the destination of the communication (when observation_point_id = 3). Examples include network taps, span ports, inline security devices, or packet capture systems that monitor traffic between other endpoints.", + "group": "context", + "requirement": "optional", + "caption": "Network Observation Point", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "http_status": { + "type": "integer_t", + "description": "The Hypertext Transfer Protocol (HTTP) status code returned to the client.", + "group": "primary", + "requirement": "recommended", + "caption": "HTTP Status", + "type_name": "Integer", + "@deprecated": { + "message": "Use the http_response.code attribute instead.", + "since": "1.1.0" + } + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "proxy_traffic": { + "type": "object_t", + "description": "The network traffic refers to the amount of data moving across a network, from proxy to remote server at a given point of time.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy Traffic", + "object_type": "network_traffic", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Traffic" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "proxy_http_request": { + "type": "object_t", + "description": "The HTTP Request from the proxy server to the remote server.", + "group": "context", + "requirement": "optional", + "caption": "Proxy HTTP Request", + "object_type": "http_request", + "profiles": [ + "network_proxy" + ], + "object_name": "HTTP Request" + }, + "connection_info": { + "type": "object_t", + "description": "The network connection information.", + "group": "primary", + "requirement": "recommended", + "caption": "Connection Info", + "object_type": "network_connection_info", + "object_name": "Network Connection Information" + }, + "proxy_endpoint": { + "type": "object_t", + "description": "The proxy (server) in a network connection.", + "group": "context", + "requirement": "optional", + "caption": "Proxy Endpoint", + "object_type": "network_proxy", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Proxy Endpoint" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "ja4_fingerprint_list": { + "type": "object_t", + "description": "A list of the JA4+ network fingerprints.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "JA4+ Fingerprints", + "object_type": "ja4_fingerprint", + "object_name": "JA4+ Fingerprint" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "http_cookies": { + "type": "object_t", + "description": "The cookies object describes details about HTTP cookies", + "group": "primary", + "is_array": true, + "requirement": "recommended", + "caption": "HTTP Cookies", + "object_type": "http_cookie", + "object_name": "HTTP Cookie" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "recommended", + "caption": "Device", + "object_type": "device", + "profiles": [ + "host" + ], + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "400200": { + "caption": "HTTP Activity: Unknown" + }, + "400299": { + "caption": "HTTP Activity: Other" + }, + "400201": { + "description": "The CONNECT method establishes a tunnel to the server identified by the target resource.", + "caption": "HTTP Activity: Connect" + }, + "400202": { + "description": "The DELETE method deletes the specified resource.", + "caption": "HTTP Activity: Delete" + }, + "400203": { + "description": "The GET method requests a representation of the specified resource. Requests using GET should only retrieve data.", + "caption": "HTTP Activity: Get" + }, + "400204": { + "description": "The HEAD method asks for a response identical to a GET request, but without the response body.", + "caption": "HTTP Activity: Head" + }, + "400205": { + "description": "The OPTIONS method describes the communication options for the target resource.", + "caption": "HTTP Activity: Options" + }, + "400206": { + "description": "The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server.", + "caption": "HTTP Activity: Post" + }, + "400207": { + "description": "The PUT method replaces all current representations of the target resource with the request payload.", + "caption": "HTTP Activity: Put" + }, + "400208": { + "description": "The TRACE method performs a message loop-back test along the path to the target resource.", + "caption": "HTTP Activity: Trace" + }, + "400209": { + "description": "The PATCH method applies partial modifications to a resource.", + "caption": "HTTP Activity: Patch" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cumulative_traffic": { + "type": "object_t", + "description": "The cumulative (running total) network traffic aggregated from the start of a flow or session. Use when reporting: (1) total accumulated bytes/packets since flow initiation, (2) combined aggregation models where both incremental deltas and running totals are reported together (populate both traffic for the delta and this attribute for the cumulative total), or (3) final summary metrics when a long-lived connection closes. This represents the sum of all activity from flow start to the current observation, not a delta or point-in-time value.", + "group": "context", + "requirement": "optional", + "caption": "Cumulative Traffic", + "object_type": "network_traffic", + "object_name": "Network Traffic" + }, + "load_balancer": { + "type": "object_t", + "description": "The Load Balancer object contains information related to the device that is distributing incoming traffic to specified destinations.", + "group": "primary", + "requirement": "recommended", + "caption": "Load Balancer", + "object_type": "load_balancer", + "profiles": [ + "load_balancer" + ], + "object_name": "Load Balancer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "trace": { + "type": "object_t", + "description": "The trace object contains information about distributed traces which are critical to observability and describe how requests move through a system, capturing each step's timing and status.", + "group": "primary", + "requirement": "recommended", + "caption": "Trace", + "object_type": "trace", + "profiles": [ + "trace" + ], + "object_name": "Trace" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "4": { + "description": "Network Activity events.", + "uid": 4, + "caption": "Network Activity" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The GET method requests a representation of the specified resource. Requests using GET should only retrieve data.", + "caption": "Get" + }, + "6": { + "description": "The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server.", + "caption": "Post" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The CONNECT method establishes a tunnel to the server identified by the target resource.", + "caption": "Connect" + }, + "2": { + "description": "The DELETE method deletes the specified resource.", + "caption": "Delete" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The HEAD method asks for a response identical to a GET request, but without the response body.", + "caption": "Head" + }, + "5": { + "description": "The OPTIONS method describes the communication options for the target resource.", + "caption": "Options" + }, + "7": { + "description": "The PUT method replaces all current representations of the target resource with the request payload.", + "caption": "Put" + }, + "8": { + "description": "The TRACE method performs a message loop-back test along the path to the target resource.", + "caption": "Trace" + }, + "9": { + "description": "The PATCH method applies partial modifications to a resource.", + "caption": "Patch" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "tls": { + "type": "object_t", + "description": "The Transport Layer Security (TLS) attributes.", + "group": "context", + "requirement": "optional", + "caption": "TLS", + "object_type": "tls", + "object_name": "Transport Layer Security (TLS)" + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Network Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "app_protocol_name": { + "type": "string_t", + "description": "The application-layer (Layer 7) protocol name identified by deep packet inspection or packet parsing (e.g., https, quic, ssh, dns), expressed as an IANA-registered service name from the IANA Service Name and Transport Protocol Port Number Registry.\n\n

Note: Port numbers alone are not always a reliable indicator of the actual application protocol in use.

", + "group": "context", + "references": [ + { + "description": "IANA Service Name and Transport Protocol Port Number Registry", + "url": "https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml" + } + ], + "requirement": "optional", + "caption": "Application Protocol Name", + "type_name": "String" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "traffic": { + "type": "object_t", + "description": "The network traffic for this observation period. Use when reporting: (1) delta values (bytes/packets transferred since the last observation), (2) instantaneous measurements at a specific point in time, or (3) standalone single-event metrics. This attribute represents a point-in-time measurement or incremental change, not a running total. For accumulated totals across multiple observations or the lifetime of a flow, use cumulative_traffic instead.", + "group": "primary", + "requirement": "recommended", + "caption": "Traffic", + "object_type": "network_traffic", + "object_name": "Network Traffic" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: HTTP Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "packet_list": { + "type": "object_t", + "description": "The list of packet objects describing captured network packets.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Packets", + "object_type": "packet", + "object_name": "Packet" + }, + "http_request": { + "type": "object_t", + "description": "The HTTP Request Object documents attributes of a request made to a web server.", + "group": "primary", + "requirement": "recommended", + "caption": "HTTP Request", + "object_type": "http_request", + "object_name": "HTTP Request" + }, + "proxy_tls": { + "type": "object_t", + "description": "The TLS protocol negotiated between the proxy server and the remote server.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy TLS", + "object_type": "tls", + "profiles": [ + "network_proxy" + ], + "object_name": "Transport Layer Security (TLS)" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "4002": { + "description": "HTTP Activity events report HTTP connection and traffic information.", + "caption": "HTTP Activity" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "observation_point": { + "type": "string_t", + "description": "Indicates whether the source network endpoint, destination network endpoint, or neither served as the observation point for the activity. The value is normalized to the caption of the observation_point_id.", + "requirement": "optional", + "caption": "Observation Point", + "type_name": "String" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "dst_endpoint": { + "type": "object_t", + "description": "The responder (server) in a network connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Destination Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "src_endpoint": { + "type": "object_t", + "description": "The initiator (client) of the network connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Source Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "primary", + "requirement": "optional", + "caption": "Actor", + "object_type": "actor", + "profiles": [ + "host" + ], + "object_name": "Actor" + }, + "proxy_connection_info": { + "type": "object_t", + "description": "The connection information from the proxy server to the remote server.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy Connection Info", + "object_type": "network_connection_info", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Connection Information" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "proxy": { + "type": "object_t", + "description": "The proxy (server) in a network connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Proxy", + "object_type": "network_proxy", + "@deprecated": { + "message": "Use the proxy_endpoint attribute instead.", + "since": "1.1.0" + }, + "object_name": "Network Proxy Endpoint" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "file": { + "type": "object_t", + "description": "The file that is the target of the HTTP activity.", + "group": "context", + "requirement": "optional", + "caption": "File", + "object_type": "file", + "object_name": "File" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "observation_point_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Neither the source nor destination network endpoint is the observation point. Refer to the network_observation_point attribute for details.", + "caption": "Neither" + }, + "0": { + "description": "The observation point is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The source network endpoint is the observation point.", + "caption": "Source" + }, + "2": { + "description": "The destination network endpoint is the observation point.", + "caption": "Destination" + }, + "99": { + "description": "The observation point is not mapped. See the observation_point attribute for a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Both the source and destination network endpoint are the observation point. This typically occurs in localhost or internal communications where the source and destination are the same endpoint, often resulting in a connection_info.direction of Local.", + "caption": "Both" + } + }, + "description": "The normalized identifier of the observation point. The observation point identifier indicates whether the source network endpoint, destination network endpoint, or neither served as the observation point for the activity.", + "requirement": "optional", + "caption": "Observation Point ID", + "sibling": "observation_point", + "type_name": "Integer" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "http_activity", + "description": "HTTP Activity events report HTTP connection and traffic information.", + "uid": 4002, + "extends": "network", + "category": "network", + "constraints": { + "at_least_one": [ + "http_request", + "http_response" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:HTTPEvent", + "url": "https://d3fend.mitre.org/event/d3f:HTTPEvent/" + } + ], + "caption": "HTTP Activity", + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "load_balancer", + "macos/macos_users", + "network_proxy", + "osint", + "security_control", + "trace" + ], + "category_name": "Network Activity", + "category_uid": 4 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/network_activity.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/network_activity.json new file mode 100644 index 0000000000..88cf930542 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/network_activity.json @@ -0,0 +1,1182 @@ +{ + "attributes": { + "is_src_dst_assignment_known": { + "type": "boolean_t", + "description": "true denotes that src_endpoint and dst_endpoint correctly identify the initiator and responder respectively. false denotes that the event source has arbitrarily assigned one peer to src_endpoint and the other to dst_endpoint, in other words that initiator and responder are not being asserted. This can occur, for example, when the event source is a network appliance that has not observed the initiation of a given connection. In the absence of this attribute, interpretation of the initiator and responder is implementation-specific.", + "group": "primary", + "requirement": "recommended", + "caption": "Source/Destination Assignment Known", + "type_name": "Boolean" + }, + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "proxy_http_response": { + "type": "object_t", + "description": "The HTTP Response from the remote server to the proxy server.", + "group": "context", + "requirement": "optional", + "caption": "Proxy HTTP Response", + "object_type": "http_response", + "profiles": [ + "network_proxy" + ], + "object_name": "HTTP Response" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "app_name": { + "type": "string_t", + "description": "The network application name identified by tools such as NBAR or App ID (e.g., youtube, facebook, webex). This represents a specific network application that uses standard protocols (such as https or quic) to deliver its service.", + "group": "context", + "requirement": "optional", + "caption": "Application Name", + "type_name": "String" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "network_observation_point": { + "type": "object_t", + "description": "The network endpoint that observes or inspects network traffic as a third-party system, used when the observer is neither the source nor the destination of the communication (when observation_point_id = 3). Examples include network taps, span ports, inline security devices, or packet capture systems that monitor traffic between other endpoints.", + "group": "context", + "requirement": "optional", + "caption": "Network Observation Point", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "proxy_traffic": { + "type": "object_t", + "description": "The network traffic refers to the amount of data moving across a network, from proxy to remote server at a given point of time.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy Traffic", + "object_type": "network_traffic", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Traffic" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "proxy_http_request": { + "type": "object_t", + "description": "The HTTP Request from the proxy server to the remote server.", + "group": "context", + "requirement": "optional", + "caption": "Proxy HTTP Request", + "object_type": "http_request", + "profiles": [ + "network_proxy" + ], + "object_name": "HTTP Request" + }, + "connection_info": { + "type": "object_t", + "description": "The network connection information.", + "group": "primary", + "requirement": "recommended", + "caption": "Connection Info", + "object_type": "network_connection_info", + "object_name": "Network Connection Information" + }, + "proxy_endpoint": { + "type": "object_t", + "description": "The proxy (server) in a network connection.", + "group": "context", + "requirement": "optional", + "caption": "Proxy Endpoint", + "object_type": "network_proxy", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Proxy Endpoint" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "ja4_fingerprint_list": { + "type": "object_t", + "description": "A list of the JA4+ network fingerprints.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "JA4+ Fingerprints", + "object_type": "ja4_fingerprint", + "object_name": "JA4+ Fingerprint" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "recommended", + "caption": "Device", + "object_type": "device", + "profiles": [ + "host" + ], + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "400100": { + "caption": "Network Activity: Unknown" + }, + "400199": { + "caption": "Network Activity: Other" + }, + "400101": { + "description": "A new network connection was opened.", + "caption": "Network Activity: Open" + }, + "400102": { + "description": "The network connection was closed.", + "caption": "Network Activity: Close" + }, + "400103": { + "description": "The network connection was abnormally terminated or closed by a middle device like firewalls.", + "caption": "Network Activity: Reset" + }, + "400104": { + "description": "The network connection failed. For example a connection timeout or no route to host.", + "caption": "Network Activity: Fail" + }, + "400105": { + "description": "The network connection was refused. For example an attempt to connect to a server port which is not open.", + "caption": "Network Activity: Refuse" + }, + "400106": { + "description": "Network traffic report.", + "caption": "Network Activity: Traffic" + }, + "400107": { + "description": "A network endpoint began listening for new network connections.", + "caption": "Network Activity: Listen" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cumulative_traffic": { + "type": "object_t", + "description": "The cumulative (running total) network traffic aggregated from the start of a flow or session. Use when reporting: (1) total accumulated bytes/packets since flow initiation, (2) combined aggregation models where both incremental deltas and running totals are reported together (populate both traffic for the delta and this attribute for the cumulative total), or (3) final summary metrics when a long-lived connection closes. This represents the sum of all activity from flow start to the current observation, not a delta or point-in-time value.", + "group": "context", + "requirement": "optional", + "caption": "Cumulative Traffic", + "object_type": "network_traffic", + "object_name": "Network Traffic" + }, + "load_balancer": { + "type": "object_t", + "description": "The Load Balancer object contains information related to the device that is distributing incoming traffic to specified destinations.", + "group": "primary", + "requirement": "recommended", + "caption": "Load Balancer", + "object_type": "load_balancer", + "profiles": [ + "load_balancer" + ], + "object_name": "Load Balancer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "4": { + "description": "Network Activity events.", + "uid": 4, + "caption": "Network Activity" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The network connection was abnormally terminated or closed by a middle device like firewalls.", + "caption": "Reset" + }, + "6": { + "description": "Network traffic report.", + "caption": "Traffic" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A new network connection was opened.", + "caption": "Open" + }, + "2": { + "description": "The network connection was closed.", + "caption": "Close" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The network connection failed. For example a connection timeout or no route to host.", + "caption": "Fail" + }, + "5": { + "description": "The network connection was refused. For example an attempt to connect to a server port which is not open.", + "caption": "Refuse" + }, + "7": { + "description": "A network endpoint began listening for new network connections.", + "caption": "Listen" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "tls": { + "type": "object_t", + "description": "The Transport Layer Security (TLS) attributes.", + "group": "context", + "requirement": "optional", + "caption": "TLS", + "object_type": "tls", + "object_name": "Transport Layer Security (TLS)" + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Network Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "app_protocol_name": { + "type": "string_t", + "description": "The application-layer (Layer 7) protocol name identified by deep packet inspection or packet parsing (e.g., https, quic, ssh, dns), expressed as an IANA-registered service name from the IANA Service Name and Transport Protocol Port Number Registry.\n\n

Note: Port numbers alone are not always a reliable indicator of the actual application protocol in use.

", + "group": "context", + "references": [ + { + "description": "IANA Service Name and Transport Protocol Port Number Registry", + "url": "https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml" + } + ], + "requirement": "optional", + "caption": "Application Protocol Name", + "type_name": "String" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "traffic": { + "type": "object_t", + "description": "The network traffic for this observation period. Use when reporting: (1) delta values (bytes/packets transferred since the last observation), (2) instantaneous measurements at a specific point in time, or (3) standalone single-event metrics. This attribute represents a point-in-time measurement or incremental change, not a running total. For accumulated totals across multiple observations or the lifetime of a flow, use cumulative_traffic instead.", + "group": "primary", + "requirement": "recommended", + "caption": "Traffic", + "object_type": "network_traffic", + "object_name": "Network Traffic" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "url": { + "type": "object_t", + "description": "The URL details relevant to the network traffic.", + "group": "primary", + "requirement": "recommended", + "caption": "URL", + "object_type": "url", + "object_name": "Uniform Resource Locator" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: Network Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "packet_list": { + "type": "object_t", + "description": "The list of packet objects describing captured network packets.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Packets", + "object_type": "packet", + "object_name": "Packet" + }, + "proxy_tls": { + "type": "object_t", + "description": "The TLS protocol negotiated between the proxy server and the remote server.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy TLS", + "object_type": "tls", + "profiles": [ + "network_proxy" + ], + "object_name": "Transport Layer Security (TLS)" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "4001": { + "description": "Network Activity events report network connection and traffic activity.", + "caption": "Network Activity" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "observation_point": { + "type": "string_t", + "description": "Indicates whether the source network endpoint, destination network endpoint, or neither served as the observation point for the activity. The value is normalized to the caption of the observation_point_id.", + "requirement": "optional", + "caption": "Observation Point", + "type_name": "String" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "dst_endpoint": { + "type": "object_t", + "description": "The responder of the network connection. In some contexts an event source cannot correctly identify the responder. Refer to is_src_dst_assignment_known for certainty.", + "group": "primary", + "requirement": "recommended", + "caption": "Destination Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "src_endpoint": { + "type": "object_t", + "description": " The initiator of the network connection. In some contexts an event source cannot correctly identify the initiator. Refer to is_src_dst_assignment_known for certainty.", + "group": "primary", + "requirement": "recommended", + "caption": "Source Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "primary", + "requirement": "optional", + "caption": "Actor", + "object_type": "actor", + "profiles": [ + "host" + ], + "object_name": "Actor" + }, + "proxy_connection_info": { + "type": "object_t", + "description": "The connection information from the proxy server to the remote server.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy Connection Info", + "object_type": "network_connection_info", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Connection Information" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "proxy": { + "type": "object_t", + "description": "The proxy (server) in a network connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Proxy", + "object_type": "network_proxy", + "@deprecated": { + "message": "Use the proxy_endpoint attribute instead.", + "since": "1.1.0" + }, + "object_name": "Network Proxy Endpoint" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "observation_point_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Neither the source nor destination network endpoint is the observation point. Refer to the network_observation_point attribute for details.", + "caption": "Neither" + }, + "0": { + "description": "The observation point is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The source network endpoint is the observation point.", + "caption": "Source" + }, + "2": { + "description": "The destination network endpoint is the observation point.", + "caption": "Destination" + }, + "99": { + "description": "The observation point is not mapped. See the observation_point attribute for a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Both the source and destination network endpoint are the observation point. This typically occurs in localhost or internal communications where the source and destination are the same endpoint, often resulting in a connection_info.direction of Local.", + "caption": "Both" + } + }, + "description": "The normalized identifier of the observation point. The observation point identifier indicates whether the source network endpoint, destination network endpoint, or neither served as the observation point for the activity.", + "requirement": "optional", + "caption": "Observation Point ID", + "sibling": "observation_point", + "type_name": "Integer" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "network_activity", + "description": "Network Activity events report network connection and traffic activity.", + "uid": 4001, + "extends": "network", + "category": "network", + "constraints": { + "at_least_one": [ + "dst_endpoint", + "src_endpoint" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:NetworkConnectionEvent", + "url": "https://d3fend.mitre.org/event/d3f:NetworkConnectionEvent/" + } + ], + "caption": "Network Activity", + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "load_balancer", + "macos/macos_users", + "network_proxy", + "osint", + "security_control" + ], + "category_name": "Network Activity", + "category_uid": 4 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/process_activity.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/process_activity.json new file mode 100644 index 0000000000..1758412e7a --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/process_activity.json @@ -0,0 +1,1072 @@ +{ + "attributes": { + "exit_code": { + "type": "integer_t", + "description": "The exit code reported by a process when it terminates. The convention is that zero indicates success and any non-zero exit code indicates that some error occurred.", + "group": "primary", + "requirement": "recommended", + "caption": "Exit Code", + "type_name": "Integer" + }, + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "launch_type_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Denotes that the Launch event represents the \"exec\" step of Unix process creation, where a process replaces its executable image, command line, and environment. WSL1 pico processes on Windows also use the 2-step Unix model.", + "caption": "Exec" + }, + "0": { + "description": "The launch type is unknown or not specified.", + "caption": "Unknown" + }, + "1": { + "description": "Denotes that the Launch event represents atomic creation of a new process on Windows. This launch type ID may also be used to represent both steps of Unix process creation in a single Launch event.", + "caption": "Spawn" + }, + "2": { + "description": "Denotes that the Launch event represents the \"fork\" step of Unix process creation, where a process creates a clone of itself in a parent-child relationship. WSL1 pico processes on Windows also use the 2-step Unix model.", + "caption": "Fork" + }, + "99": { + "description": "The launch type is not mapped. See the launch_type attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier for the specific type of Launch activity.", + "group": "primary", + "references": [ + { + "description": "fork() man page", + "url": "https://www.man7.org/linux/man-pages/man2/fork.2.html" + }, + { + "description": "execve() man page", + "url": "https://www.man7.org/linux/man-pages/man2/execve.2.html" + } + ], + "requirement": "recommended", + "caption": "Launch Type ID", + "sibling": "launch_type", + "type_name": "Integer" + }, + "actual_permissions": { + "type": "integer_t", + "description": "The permissions that were granted to the process in a platform-native format.", + "group": "primary", + "requirement": "recommended", + "caption": "Actual Permissions", + "type_name": "Integer" + }, + "message_context": { + "type": "object_t", + "description": "Communication context for AI system interactions including protocols, roles, clients, and session information for MCP and other AI communication systems.", + "group": "context", + "requirement": "optional", + "caption": "Message Context", + "object_type": "message_context", + "profiles": [ + "ai_operation" + ], + "object_name": "Message Context" + }, + "launch_type": { + "type": "string_t", + "description": "The specific type of Launch activity, normalized to the caption of the launch_type_id value. In the case of Other it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Launch Type", + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "module": { + "type": "object_t", + "description": "The module that was injected by the actor process.", + "group": "primary", + "requirement": "recommended", + "caption": "Module", + "object_type": "module", + "object_name": "Module" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "injection_type_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "Queue APC" + }, + "0": { + "description": "The injection type is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Remote Thread" + }, + "2": { + "caption": "Load Library" + }, + "99": { + "description": "The injection type is not mapped. See the injection_type attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the process injection method.", + "group": "primary", + "requirement": "recommended", + "caption": "Injection Type ID", + "sibling": "injection_type", + "type_name": "Integer" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "required", + "caption": "Device", + "object_type": "device", + "profiles": null, + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "100700": { + "caption": "Process Activity: Unknown" + }, + "100799": { + "caption": "Process Activity: Other" + }, + "100701": { + "description": "A request by the actor to launch another process. Refer to the launch_type_id attribute for details of the specific launch type.", + "caption": "Process Activity: Launch" + }, + "100702": { + "description": "A request by the actor to terminate a process. This activity is most commonly reflexive, this being the case when a process exits at its own initiation. Note too that Windows security products cannot always identify the actor in the case of inter-process termination. In this case, actor.process and process refer to the exiting process, i.e. indistinguishable from the reflexive case.", + "caption": "Process Activity: Terminate" + }, + "100703": { + "description": "A request by the actor to obtain a handle or descriptor to a process with the aim of performing further actions upon that process. The target is usually a different process but this activity can also be reflexive.", + "caption": "Process Activity: Open" + }, + "100704": { + "description": "A request by the actor to execute code within the context of a process. The target is usually a different process but this activity can also be reflexive. Refer to the injection_type_id attribute for details of the specific injection type.", + "references": [ + { + "description": "Guidance on the use of \"Module Activity: Load\" and \"Process Activity: Inject\".", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/faqs/schema-faq.md#when-should-i-use-a-module-activity-load-event-and-when-should-i-use-a-process-activity-inject-event" + } + ], + "caption": "Process Activity: Inject" + }, + "100705": { + "description": "A request by the actor to change its user identity by invoking the setuid() system call. Common programs like su and sudo use this mechanism. Note that the impersonation mechanism on Windows is not directly equivalent because it acts at the thread level.", + "caption": "Process Activity: Set User ID" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "1": { + "description": "System Activity events.", + "uid": 1, + "caption": "System Activity" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A request by the actor to obtain a handle or descriptor to a process with the aim of performing further actions upon that process. The target is usually a different process but this activity can also be reflexive.", + "caption": "Open" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A request by the actor to launch another process. Refer to the launch_type_id attribute for details of the specific launch type.", + "caption": "Launch" + }, + "2": { + "description": "A request by the actor to terminate a process. This activity is most commonly reflexive, this being the case when a process exits at its own initiation. Note too that Windows security products cannot always identify the actor in the case of inter-process termination. In this case, actor.process and process refer to the exiting process, i.e. indistinguishable from the reflexive case.", + "caption": "Terminate" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A request by the actor to execute code within the context of a process. The target is usually a different process but this activity can also be reflexive. Refer to the injection_type_id attribute for details of the specific injection type.", + "references": [ + { + "description": "Guidance on the use of \"Module Activity: Load\" and \"Process Activity: Inject\".", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/faqs/schema-faq.md#when-should-i-use-a-module-activity-load-event-and-when-should-i-use-a-process-activity-inject-event" + } + ], + "caption": "Inject" + }, + "5": { + "description": "A request by the actor to change its user identity by invoking the setuid() system call. Common programs like su and sudo use this mechanism. Note that the impersonation mechanism on Windows is not directly equivalent because it acts at the thread level.", + "caption": "Set User ID" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "references": [ + { + "description": "setuid() man page", + "url": "https://www.man7.org/linux/man-pages/man2/setuid.2.html" + } + ], + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: System Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "ai_model": { + "type": "object_t", + "description": "The AI Model object describes the characteristics of an AI/ML model. Examples include language models like GPT-4, embedding models like text-embedding-ada-002, and computer vision models like CLIP.", + "group": "context", + "requirement": "recommended", + "caption": "AI Model", + "object_type": "ai_model", + "profiles": [ + "ai_operation" + ], + "object_name": "AI Model" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: Process Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "process": { + "type": "object_t", + "description": "The process that was launched, injected into, opened, or terminated.", + "group": "primary", + "requirement": "required", + "caption": "Process", + "object_type": "process", + "object_name": "Process" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "injection_type": { + "type": "string_t", + "description": "The process injection method, normalized to the caption of the injection_type_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Injection Type", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "1007": { + "description": "Process Activity events report when a process launches, injects, opens or terminates another process, successful or otherwise.", + "caption": "Process Activity" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "requested_permissions": { + "type": "integer_t", + "description": "The permissions mask that was requested by the process.", + "group": "primary", + "requirement": "recommended", + "caption": "Requested Permissions", + "type_name": "Integer" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor that performed the activity on the target process. For example, the process that started a new process or injected code into another process.", + "group": "primary", + "requirement": "required", + "caption": "Actor", + "object_type": "actor", + "profiles": null, + "object_name": "Actor" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "process_activity", + "description": "Process Activity events report when a process launches, injects, opens or terminates another process, successful or otherwise.", + "uid": 1007, + "extends": "system", + "category": "system", + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:ProcessEvent", + "url": "https://d3fend.mitre.org/event/d3f:ProcessEvent/" + } + ], + "caption": "Process Activity", + "profiles": [ + "ai_operation", + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "macos/macos_users", + "osint", + "security_control" + ], + "associations": { + "device": [ + "actor.user" + ], + "actor.user": [ + "device" + ] + }, + "category_name": "System Activity", + "category_uid": 1 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/ssh_activity.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/ssh_activity.json new file mode 100644 index 0000000000..6175425740 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/ssh_activity.json @@ -0,0 +1,1251 @@ +{ + "attributes": { + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "proxy_http_response": { + "type": "object_t", + "description": "The HTTP Response from the remote server to the proxy server.", + "group": "context", + "requirement": "optional", + "caption": "Proxy HTTP Response", + "object_type": "http_response", + "profiles": [ + "network_proxy" + ], + "object_name": "HTTP Response" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Risk Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "app_name": { + "type": "string_t", + "description": "The network application name identified by tools such as NBAR or App ID (e.g., youtube, facebook, webex). This represents a specific network application that uses standard protocols (such as https or quic) to deliver its service.", + "group": "context", + "requirement": "optional", + "caption": "Application Name", + "type_name": "String" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "protocol_ver": { + "type": "string_t", + "description": "The Secure Shell Protocol version.", + "group": "context", + "requirement": "recommended", + "caption": "SSH Version", + "type_name": "String" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "caption": "Severity ID", + "sibling": "severity", + "type_name": "Integer" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "caption": "Action", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "server_hassh": { + "type": "object_t", + "description": "The Server HASSH fingerprinting object.", + "group": "primary", + "requirement": "recommended", + "caption": "Server HASSH", + "object_type": "hassh", + "object_name": "HASSH" + }, + "network_observation_point": { + "type": "object_t", + "description": "The network endpoint that observes or inspects network traffic as a third-party system, used when the observer is neither the source nor the destination of the communication (when observation_point_id = 3). Examples include network taps, span ports, inline security devices, or packet capture systems that monitor traffic between other endpoints.", + "group": "context", + "requirement": "optional", + "caption": "Network Observation Point", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "profiles": [ + "security_control" + ], + "object_name": "Authorization Result" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "caption": "OSINT", + "object_type": "osint", + "profiles": [ + "osint" + ], + "object_name": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "caption": "Firewall Rule", + "object_type": "firewall_rule", + "profiles": [ + "security_control" + ], + "object_name": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "caption": "Malware Scan Info", + "object_type": "malware_scan_info", + "profiles": [ + "security_control" + ], + "object_name": "Malware Scan Info" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "proxy_traffic": { + "type": "object_t", + "description": "The network traffic refers to the amount of data moving across a network, from proxy to remote server at a given point of time.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy Traffic", + "object_type": "network_traffic", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Traffic" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "profiles": [ + "security_control" + ], + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "proxy_http_request": { + "type": "object_t", + "description": "The HTTP Request from the proxy server to the remote server.", + "group": "context", + "requirement": "optional", + "caption": "Proxy HTTP Request", + "object_type": "http_request", + "profiles": [ + "network_proxy" + ], + "object_name": "HTTP Request" + }, + "connection_info": { + "type": "object_t", + "description": "The network connection information.", + "group": "primary", + "requirement": "recommended", + "caption": "Connection Info", + "object_type": "network_connection_info", + "object_name": "Network Connection Information" + }, + "proxy_endpoint": { + "type": "object_t", + "description": "The proxy (server) in a network connection.", + "group": "context", + "requirement": "optional", + "caption": "Proxy Endpoint", + "object_type": "network_proxy", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Proxy Endpoint" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "caption": "Event Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "ja4_fingerprint_list": { + "type": "object_t", + "description": "A list of the JA4+ network fingerprints.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "JA4+ Fingerprints", + "object_type": "ja4_fingerprint", + "object_name": "JA4+ Fingerprint" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "caption": "Disposition", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "recommended", + "caption": "Device", + "object_type": "device", + "profiles": [ + "host" + ], + "object_name": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "caption": "Confidence ID", + "sibling": "confidence", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "400700": { + "caption": "SSH Activity: Unknown" + }, + "400799": { + "caption": "SSH Activity: Other" + }, + "400701": { + "description": "A new network connection was opened.", + "caption": "SSH Activity: Open" + }, + "400702": { + "description": "The network connection was closed.", + "caption": "SSH Activity: Close" + }, + "400703": { + "description": "The network connection was abnormally terminated or closed by a middle device like firewalls.", + "caption": "SSH Activity: Reset" + }, + "400704": { + "description": "The network connection failed. For example a connection timeout or no route to host.", + "caption": "SSH Activity: Fail" + }, + "400705": { + "description": "The network connection was refused. For example an attempt to connect to a server port which is not open.", + "caption": "SSH Activity: Refuse" + }, + "400706": { + "description": "Network traffic report.", + "caption": "SSH Activity: Traffic" + }, + "400707": { + "description": "A network endpoint began listening for new network connections.", + "caption": "SSH Activity: Listen" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "caption": "Type ID", + "sibling": "type_name", + "type_name": "Long" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "caption": "Confidence Score", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "cumulative_traffic": { + "type": "object_t", + "description": "The cumulative (running total) network traffic aggregated from the start of a flow or session. Use when reporting: (1) total accumulated bytes/packets since flow initiation, (2) combined aggregation models where both incremental deltas and running totals are reported together (populate both traffic for the delta and this attribute for the cumulative total), or (3) final summary metrics when a long-lived connection closes. This represents the sum of all activity from flow start to the current observation, not a delta or point-in-time value.", + "group": "context", + "requirement": "optional", + "caption": "Cumulative Traffic", + "object_type": "network_traffic", + "object_name": "Network Traffic" + }, + "load_balancer": { + "type": "object_t", + "description": "The Load Balancer object contains information related to the device that is distributing incoming traffic to specified destinations.", + "group": "primary", + "requirement": "recommended", + "caption": "Load Balancer", + "object_type": "load_balancer", + "profiles": [ + "load_balancer" + ], + "object_name": "Load Balancer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "caption": "Cloud", + "object_type": "cloud", + "profiles": [ + "cloud" + ], + "object_name": "Cloud" + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "status_detail": { + "type": "string_t", + "description": "The status detail contains additional information about the event/finding outcome.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "auth_type": { + "type": "string_t", + "description": "The SSH authentication type, normalized to the caption of 'auth_type_id'. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Authentication Type", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "4": { + "description": "Network Activity events.", + "uid": 4, + "caption": "Network Activity" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "caption": "Category ID", + "sibling": "category_name", + "type_name": "Integer" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The network connection was abnormally terminated or closed by a middle device like firewalls.", + "caption": "Reset" + }, + "6": { + "description": "Network traffic report.", + "caption": "Traffic" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A new network connection was opened.", + "caption": "Open" + }, + "2": { + "description": "The network connection was closed.", + "caption": "Close" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The network connection failed. For example a connection timeout or no route to host.", + "caption": "Fail" + }, + "5": { + "description": "The network connection was refused. For example an attempt to connect to a server port which is not open.", + "caption": "Refuse" + }, + "7": { + "description": "A network endpoint began listening for new network connections.", + "caption": "Listen" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "caption": "Activity ID", + "sibling": "activity_name", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "tls": { + "type": "object_t", + "description": "The Transport Layer Security (TLS) attributes.", + "group": "context", + "requirement": "optional", + "caption": "TLS", + "object_type": "tls", + "object_name": "Transport Layer Security (TLS)" + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Network Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "app_protocol_name": { + "type": "string_t", + "description": "The application-layer (Layer 7) protocol name identified by deep packet inspection or packet parsing (e.g., https, quic, ssh, dns), expressed as an IANA-registered service name from the IANA Service Name and Transport Protocol Port Number Registry.\n\n

Note: Port numbers alone are not always a reliable indicator of the actual application protocol in use.

", + "group": "context", + "references": [ + { + "description": "IANA Service Name and Transport Protocol Port Number Registry", + "url": "https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml" + } + ], + "requirement": "optional", + "caption": "Application Protocol Name", + "type_name": "String" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "traffic": { + "type": "object_t", + "description": "The network traffic for this observation period. Use when reporting: (1) delta values (bytes/packets transferred since the last observation), (2) instantaneous measurements at a specific point in time, or (3) standalone single-event metrics. This attribute represents a point-in-time measurement or incremental change, not a running total. For accumulated totals across multiple observations or the lifetime of a flow, use cumulative_traffic instead.", + "group": "primary", + "requirement": "recommended", + "caption": "Traffic", + "object_type": "network_traffic", + "object_name": "Network Traffic" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "caption": "Status ID", + "sibling": "status", + "type_name": "Integer" + }, + "auth_type_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Authentication based on the client host's identity.", + "caption": "Host Based" + }, + "6": { + "description": "Paired public key authentication.", + "caption": "Public Key" + }, + "0": { + "description": "The authentication type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Authentication using digital certificates.", + "caption": "Certificate Based" + }, + "2": { + "description": "GSSAPI for centralized authentication.", + "caption": "GSSAPI" + }, + "99": { + "description": "The authentication type is not mapped. See the auth_type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Multi-step, interactive authentication.", + "caption": "Keyboard Interactive" + }, + "5": { + "description": "Password Authentication.", + "caption": "Password" + } + }, + "description": "The normalized identifier of the SSH authentication type.", + "group": "primary", + "requirement": "recommended", + "caption": "Authentication Type ID", + "sibling": "auth_type", + "type_name": "Integer" + }, + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: SSH Activity.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "packet_list": { + "type": "object_t", + "description": "The list of packet objects describing captured network packets.", + "group": "context", + "is_array": true, + "requirement": "optional", + "caption": "Packets", + "object_type": "packet", + "object_name": "Packet" + }, + "proxy_tls": { + "type": "object_t", + "description": "The TLS protocol negotiated between the proxy server and the remote server.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy TLS", + "object_type": "tls", + "profiles": [ + "network_proxy" + ], + "object_name": "Transport Layer Security (TLS)" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "client_hassh": { + "type": "object_t", + "description": "The Client HASSH fingerprinting object.", + "group": "primary", + "requirement": "recommended", + "caption": "Client HASSH", + "object_type": "hassh", + "object_name": "HASSH" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "caption": "Action ID", + "sibling": "action", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "4007": { + "description": "SSH Activity events report remote client connections to a server using the Secure Shell (SSH) Protocol.", + "caption": "SSH Activity" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "caption": "Class ID", + "sibling": "class_name", + "type_name": "Integer" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + }, + "observation_point": { + "type": "string_t", + "description": "Indicates whether the source network endpoint, destination network endpoint, or neither served as the observation point for the activity. The value is normalized to the caption of the observation_point_id.", + "requirement": "optional", + "caption": "Observation Point", + "type_name": "String" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "profiles": [ + "security_control" + ], + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "dst_endpoint": { + "type": "object_t", + "description": "The responder (server) in a network connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Destination Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "caption": "Alert", + "profiles": [ + "security_control" + ], + "type_name": "Boolean" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "caption": "Policy", + "object_type": "policy", + "profiles": [ + "security_control" + ], + "object_name": "Policy" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "caption": "Risk Details", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "src_endpoint": { + "type": "object_t", + "description": "The initiator (client) of the network connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Source Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "caption": "Malware", + "object_type": "malware", + "profiles": [ + "security_control" + ], + "object_name": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "primary", + "requirement": "optional", + "caption": "Actor", + "object_type": "actor", + "profiles": [ + "host" + ], + "object_name": "Actor" + }, + "proxy_connection_info": { + "type": "object_t", + "description": "The connection information from the proxy server to the remote server.", + "group": "context", + "requirement": "recommended", + "caption": "Proxy Connection Info", + "object_type": "network_connection_info", + "profiles": [ + "network_proxy" + ], + "object_name": "Network Connection Information" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "caption": "Risk Level", + "profiles": [ + "security_control" + ], + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "proxy": { + "type": "object_t", + "description": "The proxy (server) in a network connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Proxy", + "object_type": "network_proxy", + "@deprecated": { + "message": "Use the proxy_endpoint attribute instead.", + "since": "1.1.0" + }, + "object_name": "Network Proxy Endpoint" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "caption": "API Details", + "object_type": "api", + "profiles": [ + "cloud" + ], + "object_name": "API" + }, + "file": { + "type": "object_t", + "description": "The file that is the target of the SSH activity.", + "group": "context", + "requirement": "optional", + "caption": "File", + "object_type": "file", + "object_name": "File" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "observation_point_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Neither the source nor destination network endpoint is the observation point. Refer to the network_observation_point attribute for details.", + "caption": "Neither" + }, + "0": { + "description": "The observation point is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The source network endpoint is the observation point.", + "caption": "Source" + }, + "2": { + "description": "The destination network endpoint is the observation point.", + "caption": "Destination" + }, + "99": { + "description": "The observation point is not mapped. See the observation_point attribute for a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Both the source and destination network endpoint are the observation point. This typically occurs in localhost or internal communications where the source and destination are the same endpoint, often resulting in a connection_info.direction of Local.", + "caption": "Both" + } + }, + "description": "The normalized identifier of the observation point. The observation point identifier indicates whether the source network endpoint, destination network endpoint, or neither served as the observation point for the activity.", + "requirement": "optional", + "caption": "Observation Point ID", + "sibling": "observation_point", + "type_name": "Integer" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "caption": "Disposition ID", + "sibling": "disposition", + "profiles": [ + "security_control" + ], + "type_name": "Integer" + } + }, + "name": "ssh_activity", + "description": "SSH Activity events report remote client connections to a server using the Secure Shell (SSH) Protocol.", + "uid": 4007, + "extends": "network", + "category": "network", + "constraints": { + "at_least_one": [ + "dst_endpoint", + "src_endpoint" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:SSHEvent", + "url": "https://d3fend.mitre.org/event/d3f:SSHEvent/" + } + ], + "caption": "SSH Activity", + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "load_balancer", + "macos/macos_users", + "network_proxy", + "osint", + "security_control" + ], + "category_name": "Network Activity", + "category_uid": 4 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/actor.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/actor.json new file mode 100644 index 0000000000..a03d3af355 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/actor.json @@ -0,0 +1,96 @@ +{ + "attributes": { + "process": { + "type": "object_t", + "description": "The process that initiated the activity.", + "requirement": "recommended", + "caption": "Process", + "object_type": "process", + "object_name": "Process" + }, + "session": { + "type": "object_t", + "description": "The user session from which the activity was initiated.", + "requirement": "optional", + "caption": "Session", + "object_type": "session", + "object_name": "Session" + }, + "user": { + "type": "object_t", + "description": "The user that initiated the activity or the user context from which the activity was initiated.", + "requirement": "recommended", + "caption": "User", + "object_type": "user", + "object_name": "User" + }, + "app_name": { + "type": "string_t", + "description": "The client application or service that initiated the activity. This can be in conjunction with the user if present. Note that app_name is distinct from the process if present.", + "requirement": "optional", + "caption": "Application Name", + "type_name": "String" + }, + "app_uid": { + "type": "string_t", + "description": "The unique identifier of the client application or service that initiated the activity. This can be in conjunction with the user if present. Note that app_name is distinct from the process.pid or process.uid if present.", + "requirement": "optional", + "caption": "Application ID", + "type_name": "String" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "is_array": true, + "requirement": "optional", + "caption": "Authorization Information", + "object_type": "authorization", + "object_name": "Authorization Result" + }, + "idp": { + "type": "object_t", + "description": "This object describes details about the Identity Provider used.", + "requirement": "optional", + "caption": "Identity Provider", + "object_type": "idp", + "object_name": "Identity Provider" + }, + "invoked_by": { + "type": "string_t", + "description": "The name of the service that invoked the activity as described in the event.", + "requirement": "optional", + "caption": "Invoked by", + "type_name": "String", + "@deprecated": { + "message": "Use app_name, app_uid attributes instead.", + "since": "1.2.0" + } + } + }, + "name": "actor", + "description": "The Actor object contains details about the user, role, application, service, or process that initiated or performed a specific activity. Note that Actor is not the threat actor of a campaign but may be part of a campaign.", + "extends": "object", + "constraints": { + "at_least_one": [ + "process", + "user", + "invoked_by", + "session", + "app_name", + "app_uid" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:Agent.", + "url": "https://next.d3fend.mitre.org/agent/d3f:Agent/" + } + ], + "caption": "Actor", + "profiles": [ + "container", + "data_classification", + "linux/linux_users", + "macos/macos_users" + ] +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/ai_model.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/ai_model.json new file mode 100644 index 0000000000..5bc229b0ac --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/ai_model.json @@ -0,0 +1,52 @@ +{ + "attributes": { + "name": { + "type": "string_t", + "description": "Human-readable model name. For example: gpt-4o, claude-3-sonnet, or text-embedding-ada-002.", + "requirement": "required", + "caption": "Name", + "type_name": "String" + }, + "version": { + "type": "string_t", + "description": "Model version identifier. For example: 2024-05-13, v2.1.0, or beta.", + "requirement": "recommended", + "caption": "Version", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the AI model.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String" + }, + "ai_provider": { + "type": "string_t", + "description": "AI service provider or organization name. For example: OpenAI, Anthropic, Google, or Internal.", + "requirement": "required", + "caption": "AI Provider", + "type_name": "String" + } + }, + "name": "ai_model", + "description": "The AI Model object describes the characteristics of an AI/ML model. Examples include language models like GPT-4, embedding models like text-embedding-ada-002, and computer vision models like CLIP.", + "extends": "_entity", + "constraints": { + "at_least_one": [ + "name", + "uid" + ] + }, + "references": [ + { + "description": "AI Model Registry and Documentation Standards", + "url": "https://huggingface.co/docs/hub/models" + }, + { + "description": "OpenAI Model Documentation", + "url": "https://platform.openai.com/docs/models" + } + ], + "caption": "AI Model" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/api.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/api.json new file mode 100644 index 0000000000..2331750e43 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/api.json @@ -0,0 +1,62 @@ +{ + "attributes": { + "version": { + "type": "string_t", + "description": "The version of the API service.", + "requirement": "optional", + "caption": "Version", + "type_name": "String" + }, + "request": { + "type": "object_t", + "description": "Details pertaining to the API request.", + "requirement": "recommended", + "caption": "API Request Details", + "object_type": "request", + "object_name": "Request Elements" + }, + "service": { + "type": "object_t", + "description": "The information pertaining to the API service.", + "requirement": "optional", + "caption": "Service", + "object_type": "service", + "object_name": "Service" + }, + "group": { + "type": "object_t", + "description": "The information pertaining to the API group.", + "requirement": "optional", + "caption": "Group", + "object_type": "group", + "object_name": "Group" + }, + "response": { + "type": "object_t", + "description": "Details pertaining to the API response.", + "requirement": "recommended", + "caption": "API Response Details", + "object_type": "response", + "object_name": "Response Elements" + }, + "operation": { + "type": "string_t", + "description": "Verb/Operation associated with the request", + "requirement": "required", + "caption": "Operation", + "type_name": "String" + }, + "token": { + "type": "object_t", + "description": "The API or client token used to authenticate or authorize the API request. This attribute contains the base token object that represents: (1) IdP-issued client tokens (type_id: 6) such as Okta API tokens or Microsoft Entra ID Application Registration client secrets, or (2) generic API tokens/keys (type_id: 7) used for SaaS application authentication. Use this attribute when the API request was authenticated using a token that should be tracked as part of the API activity event. Note: Protocol-specific authentication tokens (Kerberos, OIDC, SAML) should be represented using authentication_token in authentication events, not in API activity events.", + "requirement": "optional", + "caption": "Token", + "object_type": "token", + "object_name": "Token" + } + }, + "name": "api", + "description": "The API, or Application Programming Interface, object represents information pertaining to an API request and response.", + "extends": "object", + "caption": "API" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/attack.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/attack.json new file mode 100644 index 0000000000..f1e9235898 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/attack.json @@ -0,0 +1,117 @@ +{ + "attributes": { + "version": { + "type": "string_t", + "description": "The ATT&CK\u00ae or ATLAS\u2122 Matrix version.", + "requirement": "recommended", + "caption": "Version", + "type_name": "String" + }, + "tactics": { + "type": "object_t", + "description": "The Tactic object describes the tactic ID and/or tactic name that are associated with the attack technique, as defined by ATT&CK\u00ae Matrix.", + "is_array": true, + "requirement": "optional", + "caption": "Tactics", + "object_type": "tactic", + "@deprecated": { + "message": "Use the tactic attribute instead.", + "since": "1.1.0" + }, + "object_name": "MITRE Tactic" + }, + "technique": { + "type": "object_t", + "description": "The Technique object describes the MITRE ATT&CK\u00ae or ATLAS\u2122 Technique ID and/or name associated to an attack.", + "references": [ + { + "description": "ATT&CK\u00ae Matrix", + "url": "https://attack.mitre.org" + }, + { + "description": "ATLAS\u2122 Matrix", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "recommended", + "caption": "MITRE Technique", + "object_type": "technique", + "object_name": "MITRE Technique" + }, + "mitigation": { + "type": "object_t", + "description": "The Mitigation object describes the MITRE ATT&CK\u00ae or ATLAS\u2122 Mitigation ID and/or name that is associated to an attack.", + "references": [ + { + "description": "ATT&CK\u00ae Matrix", + "url": "https://attack.mitre.org" + }, + { + "description": "ATLAS\u2122 Matrix", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE Mitigation", + "object_type": "mitigation", + "object_name": "MITRE Mitigation" + }, + "sub_technique": { + "type": "object_t", + "description": "The Sub-technique object describes the MITRE ATT&CK\u00ae or ATLAS\u2122 Sub-technique ID and/or name associated to an attack.", + "references": [ + { + "description": "ATT&CK\u00ae Matrix", + "url": "https://attack.mitre.org" + }, + { + "description": "ATLAS\u2122 Matrix", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "recommended", + "caption": "MITRE Sub-technique", + "object_type": "sub_technique", + "object_name": "MITRE Sub-technique" + }, + "tactic": { + "type": "object_t", + "description": "The Tactic object describes the MITRE ATT&CK\u00ae or ATLAS\u2122 Tactic ID and/or name that is associated to an attack.", + "references": [ + { + "description": "ATT&CK\u00ae Matrix", + "url": "https://attack.mitre.org" + }, + { + "description": "ATLAS\u2122 Matrix", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "recommended", + "caption": "MITRE Tactic", + "object_type": "tactic", + "object_name": "MITRE Tactic" + } + }, + "name": "attack", + "description": "The MITRE ATT&CK\u00ae & ATLAS\u2122 object describes the tactic, technique, sub-technique & mitigation associated to an attack.", + "extends": "object", + "constraints": { + "at_least_one": [ + "tactic", + "technique", + "sub_technique" + ] + }, + "references": [ + { + "description": "ATT&CK\u00ae Matrix", + "url": "https://attack.mitre.org" + }, + { + "description": "ATLAS\u2122 Matrix", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "caption": "MITRE ATT&CK\u00ae & ATLAS\u2122" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/connection_info.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/connection_info.json new file mode 100644 index 0000000000..7ad5deccd8 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/connection_info.json @@ -0,0 +1,3 @@ +{ + "error": "Object connection_info not found" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/container.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/container.json new file mode 100644 index 0000000000..38310a308a --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/container.json @@ -0,0 +1,114 @@ +{ + "attributes": { + "name": { + "type": "string_t", + "description": "The container name.", + "requirement": "recommended", + "caption": "Name", + "type_name": "String" + }, + "runtime": { + "type": "string_t", + "description": "The backend running the container, such as containerd or cri-o.", + "requirement": "optional", + "caption": "Runtime", + "type_name": "String" + }, + "size": { + "type": "long_t", + "description": "The size of the container image.", + "requirement": "recommended", + "caption": "Size", + "type_name": "Long" + }, + "tag": { + "type": "string_t", + "description": "The tag used by the container. It can indicate version, format, OS.", + "requirement": "optional", + "caption": "Image Tag", + "type_name": "String", + "@deprecated": { + "message": "Use the labels or tags attribute instead.", + "since": "1.4.0" + } + }, + "uid": { + "type": "string_t", + "description": "The full container unique identifier for this instantiation of the container. For example: ac2ea168264a08f9aaca0dfc82ff3551418dfd22d02b713142a6843caa2f61bf.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String" + }, + "hash": { + "type": "object_t", + "description": "Commit hash of image created for docker or the SHA256 hash of the container. For example: 13550340a8681c84c861aac2e5b440161c2b33a3e4f302ac680ca5b686de48de.", + "requirement": "recommended", + "caption": "Hash", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "image": { + "type": "object_t", + "description": "The container image used as a template to run the container.", + "requirement": "recommended", + "caption": "Image", + "object_type": "image", + "object_name": "Image" + }, + "labels": { + "type": "string_t", + "description": "The list of labels associated to the container.", + "is_array": true, + "requirement": "optional", + "caption": "Labels", + "type_name": "String" + }, + "tags": { + "type": "object_t", + "description": "The list of tags; {key:value} pairs associated to the container.", + "is_array": true, + "requirement": "optional", + "caption": "Tags", + "object_type": "key_value_object", + "object_name": "Key:Value object" + }, + "network_driver": { + "type": "string_t", + "description": "The network driver used by the container. For example, bridge, overlay, host, none, etc.", + "requirement": "optional", + "caption": "Network Driver", + "type_name": "String" + }, + "orchestrator": { + "type": "string_t", + "description": "The orchestrator managing the container, such as ECS, EKS, K8s, or OpenShift.", + "requirement": "optional", + "caption": "Orchestrator", + "type_name": "String" + }, + "pod_uuid": { + "type": "uuid_t", + "description": "The unique identifier of the pod (or equivalent) that the container is executing on.", + "requirement": "optional", + "caption": "Pod UUID", + "type_name": "UUID" + } + }, + "name": "container", + "description": "The Container object describes an instance of a specific container. A container is a prepackaged, portable system image that runs isolated on an existing system using a container runtime like containerd.", + "extends": "object", + "constraints": { + "at_least_one": [ + "uid", + "name" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:ContainerProcess.", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:ContainerProcess/" + } + ], + "caption": "Container", + "observable": 27 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/device.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/device.json new file mode 100644 index 0000000000..f0005e9c33 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/device.json @@ -0,0 +1,642 @@ +{ + "attributes": { + "imei": { + "type": "string_t", + "description": "The International Mobile Equipment Identity that is associated with the device.", + "requirement": "optional", + "caption": "IMEI", + "type_name": "String", + "@deprecated": { + "message": "Use the imei_list attribute instead.", + "since": "1.4.0" + } + }, + "org": { + "type": "object_t", + "description": "Organization and org unit related to the device.", + "requirement": "optional", + "caption": "Organization", + "object_type": "organization", + "object_name": "Organization" + }, + "last_seen_time_dt": { + "type": "datetime_t", + "description": "The most recent discovery time of the device.", + "requirement": "optional", + "caption": "Last Seen", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "vpc_uid": { + "type": "string_t", + "description": "The unique identifier of the Virtual Private Cloud (VPC).", + "requirement": "optional", + "caption": "VPC UID", + "type_name": "String" + }, + "os": { + "type": "object_t", + "description": "The endpoint operating system.", + "requirement": "optional", + "caption": "OS", + "object_type": "os", + "object_name": "Operating System (OS)" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "requirement": "optional", + "caption": "Risk Score", + "type_name": "Integer" + }, + "uid_alt": { + "type": "string_t", + "description": "An alternate unique identifier of the device if any. For example the ActiveDirectory DN.", + "requirement": "optional", + "caption": "Alternate ID", + "type_name": "String" + }, + "type_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A laptop computer.", + "caption": "Laptop" + }, + "6": { + "description": "A virtual machine.", + "caption": "Virtual" + }, + "0": { + "description": "The type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A server.", + "caption": "Server" + }, + "2": { + "description": "A desktop computer.", + "caption": "Desktop" + }, + "99": { + "description": "The type is not mapped. See the type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A tablet computer.", + "caption": "Tablet" + }, + "5": { + "description": "A mobile phone.", + "caption": "Mobile" + }, + "7": { + "description": "An IOT (Internet of Things) device.", + "caption": "IOT" + }, + "8": { + "description": "A web browser.", + "caption": "Browser" + }, + "9": { + "description": "A networking firewall.", + "caption": "Firewall" + }, + "10": { + "description": "A networking switch.", + "caption": "Switch" + }, + "11": { + "description": "A networking hub.", + "caption": "Hub" + }, + "12": { + "description": "A networking router.", + "caption": "Router" + }, + "14": { + "description": "An intrusion prevention system.", + "caption": "IPS" + }, + "15": { + "description": "A Load Balancer device.", + "caption": "Load Balancer" + }, + "13": { + "description": "An intrusion detection system.", + "caption": "IDS" + } + }, + "description": "The device type ID.", + "requirement": "required", + "caption": "Type ID", + "sibling": "type", + "type_name": "Integer" + }, + "is_backed_up": { + "type": "boolean_t", + "description": "Indicates whether the device or resource has a backup enabled, such as an automated snapshot or a cloud backup. For example, this is indicated by the cloudBackupEnabled value within JAMF Pro mobile devices or the registration of an AWS ARN with the AWS Backup service.", + "requirement": "optional", + "caption": "Back Ups Configured", + "type_name": "Boolean" + }, + "vendor_name": { + "type": "string_t", + "description": "The vendor for the device. For example Dell or Lenovo.", + "requirement": "recommended", + "caption": "Vendor Name", + "type_name": "String" + }, + "groups": { + "type": "object_t", + "description": "The group names to which the device belongs. For example: [\"Windows Laptops\", \"Engineering\"].", + "is_array": true, + "requirement": "optional", + "caption": "Groups", + "object_type": "group", + "object_name": "Group" + }, + "subnet_uid": { + "type": "string_t", + "description": "The unique identifier of a virtual subnet.", + "requirement": "optional", + "caption": "Subnet UID", + "type_name": "String" + }, + "created_time": { + "type": "timestamp_t", + "description": "The time when the device was known to have been created.", + "requirement": "optional", + "caption": "Created Time", + "type_name": "Timestamp" + }, + "is_shared": { + "type": "boolean_t", + "description": "The event occurred on a shared device.", + "requirement": "optional", + "caption": "Shared Device", + "type_name": "Boolean" + }, + "mac_vendor": { + "type": "string_t", + "description": "The vendor or manufacturer of the endpoint's network interface controller (NIC), as identified from the MAC address.", + "references": [ + { + "description": "IEEE Registration Authority", + "url": "https://standards.ieee.org/products-programs/regauth/" + } + ], + "requirement": "optional", + "caption": "MAC Vendor", + "type_name": "String" + }, + "owner": { + "type": "object_t", + "description": "The identity of the service or user account that owns the endpoint or was last logged into it.", + "requirement": "recommended", + "caption": "Owner", + "object_type": "user", + "object_name": "User" + }, + "vlan_uid": { + "type": "string_t", + "description": "The Virtual LAN identifier.", + "requirement": "optional", + "caption": "VLAN", + "type_name": "String" + }, + "desc": { + "type": "string_t", + "description": "The description of the device, ordinarily as reported by the operating system.", + "requirement": "optional", + "caption": "Description", + "type_name": "String" + }, + "type": { + "type": "string_t", + "description": "The device type. For example: unknown, server, desktop, laptop, tablet, mobile, virtual, browser, or other.", + "requirement": "recommended", + "caption": "Type", + "type_name": "String" + }, + "first_seen_time": { + "type": "timestamp_t", + "description": "The initial discovery time of the device.", + "requirement": "optional", + "caption": "First Seen", + "type_name": "Timestamp" + }, + "image": { + "type": "object_t", + "description": "The image used as a template to run the virtual machine.", + "requirement": "optional", + "caption": "Image", + "object_type": "image", + "object_name": "Image" + }, + "domain": { + "type": "string_t", + "description": "The network domain where the device resides. For example: work.example.com.", + "requirement": "optional", + "caption": "Domain", + "type_name": "String" + }, + "pool": { + "type": "object_t", + "description": "The pool of desktops or virtual machines to which the endpoint belongs.", + "requirement": "optional", + "caption": "Pool", + "object_type": "group", + "object_name": "Group" + }, + "location": { + "type": "object_t", + "description": "The geographical location of the device.", + "requirement": "optional", + "caption": "Geo Location", + "object_type": "location", + "object_name": "Geo Location" + }, + "interface_uid": { + "type": "string_t", + "description": "The unique identifier of the network interface.", + "requirement": "recommended", + "caption": "Network Interface ID", + "type_name": "String" + }, + "model": { + "type": "string_t", + "description": "The model of the device. For example ThinkPad X1 Carbon.", + "requirement": "optional", + "caption": "Model", + "type_name": "String" + }, + "modified_time_dt": { + "type": "datetime_t", + "description": "The time when the device was last known to have been modified.", + "requirement": "optional", + "caption": "Modified Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "eid": { + "type": "string_t", + "description": "An Embedded Identity Document, is a unique serial number that identifies an eSIM-enabled device.", + "requirement": "optional", + "caption": "EID", + "type_name": "String" + }, + "iccid": { + "type": "string_t", + "description": "The Integrated Circuit Card Identification of a mobile device. Typically it is a unique 18 to 22 digit number that identifies a SIM card.", + "requirement": "optional", + "caption": "ICCID", + "type_name": "String" + }, + "name": { + "type": "string_t", + "description": "The alternate device name, ordinarily as assigned by an administrator.

Note: The Name could be any other string that helps to identify the device, such as a phone number; for example 310-555-1234.

", + "requirement": "optional", + "caption": "Name", + "type_name": "String" + }, + "namespace_pid": { + "type": "integer_t", + "description": "If running under a process namespace (such as in a container), the process identifier within that process namespace.", + "group": "context", + "requirement": "recommended", + "caption": "Namespace PID", + "profiles": [ + "container" + ], + "type_name": "Integer" + }, + "is_trusted": { + "type": "boolean_t", + "description": "The event occurred on a trusted device.", + "requirement": "optional", + "caption": "Trusted Device", + "type_name": "Boolean" + }, + "os_machine_uuid": { + "type": "uuid_t", + "description": "The operating system assigned Machine ID. In Windows, this is the value stored at the registry path: HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography\\MachineGuid. In Linux, this is stored in the file: /etc/machine-id.", + "requirement": "optional", + "caption": "OS Machine UUID", + "type_name": "UUID" + }, + "instance_uid": { + "type": "string_t", + "description": "The unique identifier of a VM instance.", + "requirement": "recommended", + "caption": "Instance ID", + "type_name": "String" + }, + "boot_uid": { + "type": "string_t", + "description": "A unique identifier of the device that changes after every reboot. For example, the value of /proc/sys/kernel/random/boot_id from Linux's procfs.", + "references": [ + { + "description": "Linux kernel's documentation", + "url": "https://docs.kernel.org/admin-guide/sysctl/kernel.html#random" + } + ], + "requirement": "optional", + "caption": "Boot UID", + "type_name": "String" + }, + "container": { + "type": "object_t", + "description": "The information describing an instance of a container. A container is a prepackaged, portable system image that runs isolated on an existing system using a container runtime like containerd.", + "group": "context", + "requirement": "recommended", + "caption": "Container", + "object_type": "container", + "profiles": [ + "container" + ], + "object_name": "Container" + }, + "mac": { + "type": "mac_t", + "description": "The Media Access Control (MAC) address of the endpoint.", + "requirement": "optional", + "caption": "MAC Address", + "type_name": "MAC Address" + }, + "imei_list": { + "type": "string_t", + "description": "The International Mobile Equipment Identity values that are associated with the device.", + "is_array": true, + "requirement": "optional", + "caption": "IMEI List", + "type_name": "String" + }, + "hostname": { + "type": "hostname_t", + "description": "The device hostname.", + "requirement": "recommended", + "caption": "Hostname", + "type_name": "Hostname" + }, + "created_time_dt": { + "type": "datetime_t", + "description": "The time when the device was known to have been created.", + "requirement": "optional", + "caption": "Created Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "is_personal": { + "type": "boolean_t", + "description": "The event occurred on a personal device.", + "requirement": "optional", + "caption": "Personal Device", + "type_name": "Boolean" + }, + "interface_name": { + "type": "string_t", + "description": "The name of the network interface (e.g. eth2).", + "requirement": "recommended", + "caption": "Network Interface Name", + "type_name": "String" + }, + "is_supervised": { + "type": "boolean_t", + "description": "The event occurred on a supervised device. Devices that are supervised are typically mobile devices managed by a Mobile Device Management solution and are restricted from specific behaviors such as Apple AirDrop.", + "requirement": "optional", + "caption": "Supervised Device", + "type_name": "Boolean" + }, + "autoscale_uid": { + "type": "string_t", + "description": "The unique identifier of the cloud autoscale configuration.", + "requirement": "optional", + "caption": "Autoscale UID", + "type_name": "String" + }, + "is_managed": { + "type": "boolean_t", + "description": "The event occurred on a managed device.", + "requirement": "optional", + "caption": "Managed Device", + "type_name": "Boolean" + }, + "modified_time": { + "type": "timestamp_t", + "description": "The time when the device was last known to have been modified.", + "requirement": "optional", + "caption": "Modified Time", + "type_name": "Timestamp" + }, + "region": { + "type": "string_t", + "description": "The region where the virtual machine is located. For example, an AWS Region.", + "requirement": "recommended", + "caption": "Region", + "type_name": "String" + }, + "zone": { + "type": "string_t", + "description": "The network zone or LAN segment.", + "requirement": "optional", + "caption": "Network Zone", + "type_name": "String" + }, + "is_mobile_account_active": { + "type": "boolean_t", + "description": "Indicates whether the device has an active mobile account. For example, this is indicated by the itunesStoreAccountActive value within JAMF Pro mobile devices.", + "requirement": "optional", + "caption": "Mobile Account Active", + "type_name": "Boolean" + }, + "boot_time": { + "type": "timestamp_t", + "description": "The time the system was booted.", + "requirement": "optional", + "caption": "Boot Time", + "type_name": "Timestamp" + }, + "subnet": { + "type": "subnet_t", + "description": "The subnet mask.", + "requirement": "optional", + "caption": "Subnet", + "type_name": "Subnet" + }, + "udid": { + "type": "string_t", + "description": "The Apple assigned Unique Device Identifier (UDID). For iOS, iPadOS, tvOS, watchOS and visionOS devices, this is the UDID. For macOS devices, it is the Provisioning UDID. For example: 00008020-008D4548007B4F26", + "references": [ + { + "description": "Apple Wiki", + "url": "https://theapplewiki.com/wiki/UDID" + } + ], + "requirement": "optional", + "caption": "Unique Device Identifier", + "type_name": "String" + }, + "first_seen_time_dt": { + "type": "datetime_t", + "description": "The initial discovery time of the device.", + "requirement": "optional", + "caption": "First Seen", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "meid": { + "type": "string_t", + "description": "The Mobile Equipment Identifier. It's a unique number that identifies a Code Division Multiple Access (CDMA) mobile device.", + "requirement": "optional", + "caption": "MEID", + "type_name": "String" + }, + "is_compliant": { + "type": "boolean_t", + "description": "The event occurred on a compliant device.", + "requirement": "optional", + "caption": "Compliant Device", + "type_name": "Boolean" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the device. For example the Windows TargetSID or AWS EC2 ARN.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String", + "observable": 47 + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "requirement": "optional", + "caption": "Risk Level ID", + "sibling": "risk_level", + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "hw_info": { + "type": "object_t", + "description": "The endpoint hardware information.", + "requirement": "optional", + "caption": "Hardware Info", + "object_type": "device_hw_info", + "object_name": "Device Hardware Info" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "requirement": "optional", + "caption": "Risk Level", + "type_name": "String" + }, + "ip": { + "type": "ip_t", + "description": "The device IP address, in either IPv4 or IPv6 format.", + "requirement": "optional", + "caption": "IP Address", + "type_name": "IP Address" + }, + "network_interfaces": { + "type": "object_t", + "description": "The physical or virtual network interfaces that are associated with the device, one for each unique MAC address/IP address/hostname/name combination.

Note: The first element of the array is the network information that pertains to the event.

", + "is_array": true, + "requirement": "optional", + "caption": "Network Interfaces", + "object_type": "network_interface", + "object_name": "Network Interface" + }, + "last_seen_time": { + "type": "timestamp_t", + "description": "The most recent discovery time of the device.", + "requirement": "optional", + "caption": "Last Seen", + "type_name": "Timestamp" + }, + "boot_time_dt": { + "type": "datetime_t", + "description": "The time the system was booted.", + "requirement": "optional", + "caption": "Boot Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "agent_list": { + "type": "object_t", + "description": "A list of agent objects associated with a device, endpoint, or resource.", + "is_array": true, + "requirement": "optional", + "caption": "Agent List", + "object_type": "agent", + "object_name": "Agent" + }, + "hypervisor": { + "type": "string_t", + "description": "The name of the hypervisor running on the device. For example, Xen, VMware, Hyper-V, VirtualBox, etc.", + "requirement": "optional", + "caption": "Hypervisor", + "type_name": "String" + } + }, + "name": "device", + "description": "The Device object represents an addressable computer system or host, which is typically connected to a computer network and participates in the transmission or processing of data within the computer network.", + "extends": "endpoint", + "constraints": { + "at_least_one": [ + "ip", + "uid", + "name", + "hostname", + "instance_uid", + "interface_uid", + "interface_name" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:Host.", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:Host/" + } + ], + "caption": "Device", + "profiles": [ + "container", + "datetime" + ], + "observable": 20 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/evidences.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/evidences.json new file mode 100644 index 0000000000..30d372507f --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/evidences.json @@ -0,0 +1,333 @@ +{ + "attributes": { + "data": { + "type": "json_t", + "description": "Additional evidence data that is not accounted for in the specific evidence attributes. Use only when absolutely necessary.", + "requirement": "optional", + "caption": "Data", + "type_name": "JSON" + }, + "http_response": { + "type": "object_t", + "description": "Describes details about the http response associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "HTTP Response", + "object_type": "http_response", + "object_name": "HTTP Response" + }, + "http_request": { + "type": "object_t", + "description": "Describes details about the http request associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "HTTP Request", + "object_type": "http_request", + "object_name": "HTTP Request" + }, + "name": { + "type": "string_t", + "description": "The naming convention or type identifier of the evidence associated with the security detection. For example, the @odata.type from Microsoft Graph Alerts V2 or display_name from CrowdStrike Falcon Incident Behaviors.", + "requirement": "optional", + "caption": "Name", + "type_name": "String" + }, + "process": { + "type": "object_t", + "description": "Describes details about the process associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Process", + "object_type": "process", + "object_name": "Process" + }, + "file": { + "type": "object_t", + "description": "Describes details about the file associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "File", + "object_type": "file", + "object_name": "File" + }, + "user": { + "type": "object_t", + "description": "Describes details about the user that was the target or somehow else associated with the activity that triggered the detection.", + "requirement": "recommended", + "caption": "User", + "object_type": "user", + "object_name": "User" + }, + "script": { + "type": "object_t", + "description": "Describes details about the script that was associated with the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Script", + "object_type": "script", + "object_name": "Script" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Device", + "object_type": "device", + "object_name": "Device" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the evidence associated with the security detection. For example, the activity_id from CrowdStrike Falcon Alerts or behavior_id from CrowdStrike Falcon Incident Behaviors.", + "requirement": "optional", + "caption": "Unique ID", + "type_name": "String" + }, + "query": { + "type": "object_t", + "description": "Describes details about the DNS query associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "DNS Query", + "object_type": "dns_query", + "object_name": "DNS Query" + }, + "connection_info": { + "type": "object_t", + "description": "Describes details about the network connection associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Connection Info", + "object_type": "network_connection_info", + "object_name": "Network Connection Information" + }, + "url": { + "type": "object_t", + "description": "The URL object that pertains to the event or object associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "URL", + "object_type": "url", + "object_name": "Uniform Resource Locator" + }, + "email": { + "type": "object_t", + "description": "The email object associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Email", + "object_type": "email", + "object_name": "Email" + }, + "tls": { + "type": "object_t", + "description": "Describes details about the Transport Layer Security (TLS) activity that triggered the detection.", + "requirement": "recommended", + "caption": "TLS", + "object_type": "tls", + "object_name": "Transport Layer Security (TLS)" + }, + "api": { + "type": "object_t", + "description": "Describes details about the API call associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "API Details", + "object_type": "api", + "object_name": "API" + }, + "resources": { + "type": "object_t", + "description": "Describes details about the cloud resources directly related to activity that triggered the detection. For resources impacted by the detection, use Affected Resources at the top-level of the finding.", + "is_array": true, + "requirement": "recommended", + "caption": "Cloud Resources", + "object_type": "resource_details", + "object_name": "Resource Details" + }, + "actor": { + "type": "object_t", + "description": "Describes details about the user/role/process that was the source of the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Actor", + "object_type": "actor", + "object_name": "Actor" + }, + "container": { + "type": "object_t", + "description": "Describes details about the container associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Container", + "object_type": "container", + "object_name": "Container" + }, + "database": { + "type": "object_t", + "description": "Describes details about the database associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Database", + "object_type": "database", + "object_name": "Database" + }, + "databucket": { + "type": "object_t", + "description": "Describes details about the databucket associated to the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Databucket", + "object_type": "databucket", + "object_name": "Databucket" + }, + "dst_endpoint": { + "type": "object_t", + "description": "Describes details about the destination of the network activity that triggered the detection.", + "requirement": "recommended", + "caption": "Destination Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "ja4_fingerprint_list": { + "type": "object_t", + "description": "Describes details about the JA4+ fingerprints that triggered the detection.", + "is_array": true, + "requirement": "recommended", + "caption": "JA4+ Fingerprints", + "object_type": "ja4_fingerprint", + "object_name": "JA4+ Fingerprint" + }, + "job": { + "type": "object_t", + "description": "Describes details about the scheduled job that was associated with the activity that triggered the detection.", + "requirement": "recommended", + "caption": "Job", + "object_type": "job", + "object_name": "Job" + }, + "src_endpoint": { + "type": "object_t", + "description": "Describes details about the source of the network activity that triggered the detection.", + "requirement": "recommended", + "caption": "Source Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + }, + "verdict": { + "type": "string_t", + "description": "The normalized verdict of the evidence associated with the security detection. ", + "requirement": "optional", + "caption": "Verdict", + "type_name": "String" + }, + "verdict_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The verdict for the evidence is that is should be Disregarded.", + "caption": "Disregard" + }, + "6": { + "description": "The evidence is part of a Test, or other sanctioned behavior(s).", + "caption": "Test" + }, + "0": { + "description": "The type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "The verdict for the evidence has been identified as a False Positive.", + "caption": "False Positive" + }, + "2": { + "description": "The verdict for the evidence has been identified as a True Positive.", + "caption": "True Positive" + }, + "99": { + "description": "The type is not mapped. See the type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The verdict for the evidence is that the behavior has been identified as Suspicious.", + "caption": "Suspicious" + }, + "5": { + "description": "The verdict for the evidence is that the behavior has been identified as Benign.", + "caption": "Benign" + }, + "7": { + "description": "There is insufficient data to render a verdict on the evidence.", + "caption": "Insufficient Data" + }, + "8": { + "description": "The verdict for the evidence is that the behavior has been identified as a Security Risk.", + "caption": "Security Risk" + }, + "9": { + "description": "The verdict for the evidence is Managed Externally, such as in a case management tool.", + "caption": "Managed Externally" + }, + "10": { + "description": "This evidence duplicates existing evidence related to this finding.", + "caption": "Duplicate" + } + }, + "description": "The normalized verdict (or status) ID of the evidence associated with the security detection. For example, Microsoft Graph Security Alerts contain a verdict enumeration for each type of evidence associated with the Alert. This is typically set by an automated investigation process or an analyst/investigator assigned to the finding.", + "requirement": "optional", + "caption": "Verdict ID", + "sibling": "verdict", + "type_name": "Integer" + }, + "reg_key": { + "type": "object_t", + "description": "Describes details about the registry key that triggered the detection.", + "extension": "win", + "requirement": "recommended", + "extension_id": 2, + "caption": "Registry Key", + "object_type": "win/reg_key", + "object_name": "Registry Key" + }, + "win_service": { + "type": "object_t", + "description": "Describes details about the Windows service that triggered the detection.", + "extension": "win", + "requirement": "recommended", + "extension_id": 2, + "caption": "Windows Service", + "object_type": "win/win_service", + "object_name": "Windows Service" + }, + "reg_value": { + "type": "object_t", + "description": "Describes details about the registry value that triggered the detection.", + "extension": "win", + "requirement": "recommended", + "extension_id": 2, + "caption": "Registry Value", + "object_type": "win/reg_value", + "object_name": "Registry Value" + } + }, + "name": "evidences", + "description": "A collection of evidence artifacts associated to the activity/activities that triggered a security detection.", + "extends": "_entity", + "constraints": { + "at_least_one": [ + "actor", + "api", + "connection_info", + "data", + "database", + "databucket", + "device", + "dst_endpoint", + "email", + "file", + "process", + "query", + "src_endpoint", + "url", + "user", + "job", + "script", + "reg_key", + "reg_value", + "win_service" + ] + }, + "caption": "Evidence Artifacts", + "profiles": [ + "cloud", + "container", + "data_classification", + "linux/linux_users", + "macos/macos_users" + ] +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/finding_info.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/finding_info.json new file mode 100644 index 0000000000..f04f27c999 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/finding_info.json @@ -0,0 +1,248 @@ +{ + "attributes": { + "title": { + "type": "string_t", + "description": "A title or a brief phrase summarizing the reported finding.", + "requirement": "recommended", + "caption": "Title", + "type_name": "String" + }, + "product": { + "type": "object_t", + "description": "Details about the product that reported the finding.", + "requirement": "optional", + "caption": "Product", + "object_type": "product", + "object_name": "Product" + }, + "desc": { + "type": "string_t", + "description": "The description of the reported finding.", + "requirement": "optional", + "caption": "Description", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the reported finding.", + "requirement": "required", + "caption": "Unique ID", + "type_name": "String" + }, + "types": { + "type": "string_t", + "description": "One or more types of the reported finding.", + "is_array": true, + "requirement": "optional", + "caption": "Types", + "type_name": "String" + }, + "tags": { + "type": "object_t", + "description": "The list of tags; {key:value} pairs associated with the finding.", + "is_array": true, + "requirement": "optional", + "caption": "Tags", + "object_type": "key_value_object", + "object_name": "Key:Value object" + }, + "attacks": { + "type": "object_t", + "description": "The MITRE ATT&CK\u00ae technique and associated tactics related to the finding.", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "object_type": "attack", + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122" + }, + "analytic": { + "type": "object_t", + "description": "The analytic technique used to analyze and derive insights from the data or information that led to the finding or conclusion.", + "requirement": "recommended", + "caption": "Analytic", + "object_type": "analytic", + "object_name": "Analytic" + }, + "attack_graph": { + "type": "object_t", + "description": "An Attack Graph describes possible routes an attacker could take through an environment. It describes relationships between resources and their findings, such as malware detections, vulnerabilities, misconfigurations, and other security actions.", + "group": "context", + "references": [ + { + "description": "MS Defender description of Attack Path", + "url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/how-to-manage-attack-path" + }, + { + "description": "SentinelOne Attack Path documentation", + "url": "https://www.sentinelone.com/cybersecurity-101/cybersecurity/attack-path-analysis/" + } + ], + "requirement": "optional", + "caption": "Attack Graph", + "object_type": "graph", + "object_name": "Graph" + }, + "created_time": { + "type": "timestamp_t", + "description": "The time when the finding was created.", + "requirement": "optional", + "caption": "Created Time", + "type_name": "Timestamp" + }, + "data_sources": { + "type": "string_t", + "description": "A list of data sources utilized in generation of the finding.", + "is_array": true, + "requirement": "optional", + "caption": "Data Sources", + "type_name": "String" + }, + "first_seen_time": { + "type": "timestamp_t", + "description": "The time when the finding was first observed. e.g. The time when a vulnerability was first observed.

It can differ from the created_time timestamp, which reflects the time this finding was created.

", + "requirement": "optional", + "caption": "First Seen", + "type_name": "Timestamp" + }, + "kill_chain": { + "type": "object_t", + "description": "The Cyber Kill Chain\u00ae provides a detailed description of each phase and its associated activities within the broader context of a cyber attack.", + "is_array": true, + "requirement": "optional", + "caption": "Kill Chain", + "object_type": "kill_chain_phase", + "object_name": "Kill Chain Phase" + }, + "last_seen_time": { + "type": "timestamp_t", + "description": "The time when the finding was most recently observed. e.g. The time when a vulnerability was most recently observed.

It can differ from the modified_time timestamp, which reflects the time this finding was last modified.

", + "requirement": "optional", + "caption": "Last Seen", + "type_name": "Timestamp" + }, + "modified_time": { + "type": "timestamp_t", + "description": "The time when the finding was last modified.", + "requirement": "optional", + "caption": "Modified Time", + "type_name": "Timestamp" + }, + "product_uid": { + "type": "string_t", + "description": "The unique identifier of the product that reported the finding.", + "requirement": "optional", + "caption": "Product Identifier", + "type_name": "String", + "@deprecated": { + "message": "Use the uid attribute in the product object instead. See specific usage.", + "since": "1.4.0" + } + }, + "related_analytics": { + "type": "object_t", + "description": "Other analytics related to this finding.", + "is_array": true, + "requirement": "optional", + "caption": "Related Analytics", + "object_type": "analytic", + "object_name": "Analytic" + }, + "related_events": { + "type": "object_t", + "description": "Describes events and/or other findings related to the finding as identified by the security product. Note that these events may or may not be in OCSF.", + "is_array": true, + "requirement": "optional", + "caption": "Related Events/Findings", + "object_type": "related_event", + "object_name": "Related Event/Finding" + }, + "related_events_count": { + "type": "integer_t", + "description": "Number of related events or findings.", + "requirement": "optional", + "caption": "Related Events/Findings Count", + "type_name": "Integer" + }, + "src_url": { + "type": "url_t", + "description": "The URL pointing to the source of the finding.", + "requirement": "optional", + "caption": "Source URL", + "type_name": "URL String" + }, + "traits": { + "type": "object_t", + "description": "The list of key traits or characteristics extracted from the finding.", + "is_array": true, + "requirement": "optional", + "caption": "Traits", + "object_type": "trait", + "object_name": "Trait" + }, + "uid_alt": { + "type": "string_t", + "description": "The alternative unique identifier of the reported finding.", + "requirement": "optional", + "caption": "Alternate ID", + "type_name": "String" + }, + "created_time_dt": { + "type": "datetime_t", + "description": "The time when the finding was created.", + "requirement": "optional", + "caption": "Created Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "first_seen_time_dt": { + "type": "datetime_t", + "description": "The time when the finding was first observed. e.g. The time when a vulnerability was first observed.

It can differ from the created_time timestamp, which reflects the time this finding was created.

", + "requirement": "optional", + "caption": "First Seen", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "last_seen_time_dt": { + "type": "datetime_t", + "description": "The time when the finding was most recently observed. e.g. The time when a vulnerability was most recently observed.

It can differ from the modified_time timestamp, which reflects the time this finding was last modified.

", + "requirement": "optional", + "caption": "Last Seen", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "modified_time_dt": { + "type": "datetime_t", + "description": "The time when the finding was last modified.", + "requirement": "optional", + "caption": "Modified Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + } + }, + "name": "finding_info", + "description": "The Finding Information object describes metadata related to a security finding generated by a security tool or system.", + "extends": "object", + "caption": "Finding Information", + "profiles": [ + "data_classification", + "datetime" + ] +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/firewall_rule.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/firewall_rule.json new file mode 100644 index 0000000000..c3bd49b621 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/firewall_rule.json @@ -0,0 +1,99 @@ +{ + "attributes": { + "name": { + "type": "string_t", + "description": "The name of the rule that generated the event.", + "requirement": "recommended", + "caption": "Name", + "type_name": "String" + }, + "type": { + "type": "string_t", + "description": "The rule type.", + "requirement": "optional", + "caption": "Type", + "type_name": "String" + }, + "version": { + "type": "string_t", + "description": "The rule version. For example: 1.1.", + "requirement": "optional", + "caption": "Version", + "type_name": "String" + }, + "desc": { + "type": "string_t", + "description": "The description of the rule that generated the event.", + "requirement": "optional", + "caption": "Description", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the rule that generated the event.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String" + }, + "category": { + "type": "string_t", + "description": "The rule category.", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "duration": { + "type": "long_t", + "description": "The rule response time duration, usually used for challenge completion time.", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "condition": { + "type": "string_t", + "description": "The rule trigger condition for the rule. For example: SQL_INJECTION.", + "requirement": "optional", + "caption": "Condition", + "type_name": "String" + }, + "match_details": { + "type": "string_t", + "description": "The data in a request that rule matched. For example: '[\"10\",\"and\",\"1\"]'.", + "is_array": true, + "requirement": "optional", + "caption": "Match Details", + "type_name": "String" + }, + "match_location": { + "type": "string_t", + "description": "The location of the matched data in the source which resulted in the triggered firewall rule. For example: HEADER.", + "requirement": "optional", + "caption": "Match Location", + "type_name": "String" + }, + "rate_limit": { + "type": "integer_t", + "description": "The rate limit for a rate-based rule.", + "requirement": "optional", + "caption": "Rate Limit", + "type_name": "Integer" + }, + "sensitivity": { + "type": "string_t", + "description": "The sensitivity of the firewall rule in the matched event. For example: HIGH.", + "requirement": "optional", + "caption": "Sensitivity", + "type_name": "String" + } + }, + "name": "firewall_rule", + "description": "The Firewall Rule object represents a specific rule within a firewall policy or event. It contains information about a rule's configuration, properties, and associated actions that define how network traffic is handled by the firewall.", + "extends": "rule", + "constraints": { + "at_least_one": [ + "name", + "uid" + ] + }, + "caption": "Firewall Rule" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/http_request.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/http_request.json new file mode 100644 index 0000000000..36baedb432 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/http_request.json @@ -0,0 +1,134 @@ +{ + "attributes": { + "args": { + "type": "string_t", + "description": "The arguments sent along with the HTTP request.", + "requirement": "optional", + "caption": "HTTP Arguments", + "type_name": "String" + }, + "version": { + "type": "string_t", + "description": "The Hypertext Transfer Protocol (HTTP) version.", + "requirement": "recommended", + "caption": "HTTP Version", + "type_name": "String" + }, + "length": { + "type": "integer_t", + "description": "The length of the entire HTTP request, in number of bytes.", + "requirement": "optional", + "caption": "Request Length", + "type_name": "Integer" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the http request.", + "requirement": "optional", + "caption": "Unique ID", + "type_name": "String" + }, + "url": { + "type": "object_t", + "description": "The URL object that pertains to the request.", + "requirement": "recommended", + "caption": "URL", + "object_type": "url", + "object_name": "Uniform Resource Locator" + }, + "x_forwarded_for": { + "type": "ip_t", + "description": "The X-Forwarded-For header identifying the originating IP address(es) of a client connecting to a web server through an HTTP proxy or a load balancer.", + "is_array": true, + "requirement": "optional", + "caption": "X-Forwarded-For", + "type_name": "IP Address" + }, + "body_length": { + "type": "integer_t", + "description": "The actual length of the HTTP request body, in number of bytes, independent of a potentially existing Content-Length header.", + "requirement": "optional", + "caption": "Request Body Length", + "type_name": "Integer" + }, + "user_agent": { + "type": "string_t", + "description": "The request header that identifies the operating system and web browser.", + "requirement": "recommended", + "caption": "HTTP User-Agent", + "type_name": "String", + "observable": 16 + }, + "http_headers": { + "type": "object_t", + "description": "Additional HTTP headers of an HTTP request or response.", + "is_array": true, + "requirement": "recommended", + "caption": "HTTP Headers", + "object_type": "http_header", + "object_name": "HTTP Header" + }, + "http_method": { + "type": "string_t", + "enum": { + "OPTIONS": { + "description": "The OPTIONS method describes the communication options for the target resource.", + "caption": "Options" + }, + "GET": { + "description": "The GET method requests a representation of the specified resource. Requests using GET should only retrieve data.", + "caption": "Get" + }, + "HEAD": { + "description": "The HEAD method asks for a response identical to a GET request, but without the response body.", + "caption": "Head" + }, + "POST": { + "description": "The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server.", + "caption": "Post" + }, + "PUT": { + "description": "The PUT method replaces all current representations of the target resource with the request payload.", + "caption": "Put" + }, + "DELETE": { + "description": "The DELETE method deletes the specified resource.", + "caption": "Delete" + }, + "TRACE": { + "description": "The TRACE method performs a message loop-back test along the path to the target resource.", + "caption": "Trace" + }, + "CONNECT": { + "description": "The CONNECT method establishes a tunnel to the server identified by the target resource.", + "caption": "Connect" + }, + "PATCH": { + "description": "The PATCH method applies partial modifications to a resource.", + "caption": "Patch" + } + }, + "description": "The HTTP request method indicates the desired action to be performed for a given resource.", + "requirement": "recommended", + "caption": "HTTP Method", + "type_name": "String" + }, + "referrer": { + "type": "string_t", + "description": "The request header that identifies the address of the previous web page, which is linked to the current web page or resource being requested.", + "requirement": "optional", + "caption": "HTTP Referrer", + "type_name": "String" + } + }, + "name": "http_request", + "description": "The HTTP Request object represents the attributes of a request made to a web server. It encapsulates the details and metadata associated with an HTTP request, including the request method, headers, URL, query parameters, body content, and other relevant information.", + "extends": "object", + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:OutboundInternetNetworkTraffic.", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:OutboundInternetNetworkTraffic/" + } + ], + "caption": "HTTP Request" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/http_response.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/http_response.json new file mode 100644 index 0000000000..498bf2d2c5 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/http_response.json @@ -0,0 +1,72 @@ +{ + "attributes": { + "code": { + "type": "integer_t", + "description": "The Hypertext Transfer Protocol (HTTP) status code returned from the web server to the client. For example, 200.", + "requirement": "required", + "caption": "Response Code", + "type_name": "Integer" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "requirement": "optional", + "caption": "Message", + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The response status. For example: A successful HTTP status of 'OK' which corresponds to a code of 200.", + "requirement": "optional", + "caption": "Status", + "type_name": "String" + }, + "length": { + "type": "integer_t", + "description": "The length of the entire HTTP response, in number of bytes.", + "requirement": "optional", + "caption": "Response Length", + "type_name": "Integer" + }, + "content_type": { + "type": "string_t", + "description": "The request header that identifies the original media type of the resource (prior to any content encoding applied for sending).", + "requirement": "optional", + "caption": "HTTP Content Type", + "type_name": "String" + }, + "body_length": { + "type": "integer_t", + "description": "The actual length of the HTTP response body, in number of bytes, independent of a potentially existing Content-Length header.", + "requirement": "optional", + "caption": "Response Body Length", + "type_name": "Integer" + }, + "http_headers": { + "type": "object_t", + "description": "Additional HTTP headers of an HTTP request or response.", + "is_array": true, + "requirement": "recommended", + "caption": "HTTP Headers", + "object_type": "http_header", + "object_name": "HTTP Header" + }, + "latency": { + "type": "integer_t", + "description": "The HTTP response latency measured in milliseconds.", + "requirement": "optional", + "caption": "Latency", + "type_name": "Integer" + } + }, + "name": "http_response", + "description": "The HTTP Response object contains detailed information about the response sent from a web server to the requester. It encompasses attributes and metadata that describe the response status, headers, body content, and other relevant information.", + "extends": "object", + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:InboundInternetNetworkTraffic.", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:InboundInternetNetworkTraffic/" + } + ], + "caption": "HTTP Response" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/metadata.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/metadata.json new file mode 100644 index 0000000000..b99a736684 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/metadata.json @@ -0,0 +1,338 @@ +{ + "attributes": { + "tenant_uid": { + "type": "string_t", + "description": "The unique tenant identifier.", + "requirement": "recommended", + "caption": "Tenant UID", + "type_name": "String" + }, + "log_version": { + "type": "string_t", + "description": "The event log schema version of the original event. For example the syslog version or the Cisco Log Schema version", + "requirement": "optional", + "caption": "Log Version", + "type_name": "String" + }, + "labels": { + "type": "string_t", + "description": "The list of labels attached to the event. For example: [\"sample\", \"dev\"]", + "is_array": true, + "requirement": "optional", + "caption": "Labels", + "type_name": "String" + }, + "original_time": { + "type": "string_t", + "description": "The original event time as reported by the event source. For example, the time in the original format from system event log such as Syslog on Unix/Linux and the System event file on Windows. Omit if event is generated instead of collected via logs.", + "requirement": "recommended", + "caption": "Original Time", + "type_name": "String" + }, + "log_level": { + "type": "string_t", + "description": "The level at which an event was logged. This can be log provider specific. For example the audit level.", + "requirement": "optional", + "caption": "Log Level", + "type_name": "String" + }, + "transmit_time_dt": { + "type": "datetime_t", + "description": "The time when the event was transmitted from the logging device to it's next destination.", + "requirement": "optional", + "caption": "Transmission Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "is_truncated": { + "type": "boolean_t", + "description": "Indicates whether the OCSF event data has been truncated due to size limitations. When true, some event data may have been omitted to fit within system constraints.", + "requirement": "optional", + "caption": "Is Truncated", + "type_name": "Boolean" + }, + "processed_time": { + "type": "timestamp_t", + "description": "The event processed time, such as an ETL operation.", + "requirement": "optional", + "caption": "Processed Time", + "type_name": "Timestamp" + }, + "type": { + "type": "string_t", + "description": "The type of the event or finding as a subset of the source of the event. This can be any distinguishing characteristic of the data. For example 'Management Events' or 'Device Penetration Test'.", + "requirement": "optional", + "caption": "Type", + "type_name": "String" + }, + "extensions": { + "type": "object_t", + "description": "The schema extensions used to create the event.", + "is_array": true, + "requirement": "optional", + "caption": "Schema Extensions", + "object_type": "extension", + "object_name": "Schema Extension" + }, + "log_format": { + "type": "string_t", + "description": "The format of data in the log where the data originated. For example CSV, XML, Windows Multiline, JSON, syslog or Cisco Log Schema.", + "requirement": "optional", + "caption": "Log Source Format", + "type_name": "String" + }, + "untruncated_size": { + "type": "integer_t", + "description": "The original size of the OCSF event data in kilobytes before any truncation occurred. This field is typically populated when is_truncated is true to indicate the full size of the original event.", + "requirement": "optional", + "caption": "Untruncated Size", + "type_name": "Integer" + }, + "processed_time_dt": { + "type": "datetime_t", + "description": "The event processed time, such as an ETL operation.", + "requirement": "optional", + "caption": "Processed Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "modified_time_dt": { + "type": "datetime_t", + "description": "The time when the event was last modified or enriched.", + "requirement": "optional", + "caption": "Modified Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "original_event_uid": { + "type": "string_t", + "description": "The unique identifier assigned to the event in its original logging system before transformation to OCSF format. This field preserves the source system's native event identifier, enabling traceability back to the raw log entry. For example, a Windows Event Record ID, a syslog message ID, a Splunk _cd value, or a database transaction log sequence number.", + "requirement": "optional", + "caption": "Original Event ID", + "type_name": "String" + }, + "log_provider": { + "type": "string_t", + "description": "The logging provider or logging service that logged the event. For example AWS CloudWatch or Splunk.", + "requirement": "optional", + "caption": "Log Provider", + "type_name": "String" + }, + "loggers": { + "type": "object_t", + "description": "An array of Logger objects that describe the pipeline of devices and logging products between the event source and its eventual destination. Note, this attribute can be used when there is a complex end-to-end path of event flow and/or to track the chain of custody of the data.", + "is_array": true, + "requirement": "optional", + "caption": "Loggers", + "object_type": "logger", + "object_name": "Logger" + }, + "log_source": { + "type": "string_t", + "description": "The log system or component where the data originated. For example, a file path, syslog server name or a Windows hostname and logging subsystem such as Security.", + "requirement": "optional", + "caption": "Log Source", + "type_name": "String" + }, + "log_name": { + "type": "string_t", + "description": "The event log name, typically for the consumer of the event. For example, the storage bucket name, SIEM repository index name, etc.", + "requirement": "recommended", + "caption": "Log Name", + "type_name": "String" + }, + "product": { + "type": "object_t", + "description": "The product that reported the event.", + "requirement": "required", + "caption": "Product", + "object_type": "product", + "object_name": "Product" + }, + "tags": { + "type": "object_t", + "description": "The list of tags; {key:value} pairs associated to the event.", + "is_array": true, + "requirement": "optional", + "caption": "Tags", + "object_type": "key_value_object", + "object_name": "Key:Value object" + }, + "profiles": { + "type": "string_t", + "description": "The list of profiles used to create the event. Profiles should be referenced by their name attribute for core profiles, or extension/name for profiles from extensions.", + "is_array": true, + "requirement": "optional", + "caption": "Profiles", + "type_name": "String" + }, + "extension": { + "type": "object_t", + "description": "The schema extension used to create the event.", + "requirement": "optional", + "caption": "Schema Extension", + "object_type": "extension", + "@deprecated": { + "message": "Use the extensions attribute instead.", + "since": "1.1.0" + }, + "object_name": "Schema Extension" + }, + "logged_time_dt": { + "type": "datetime_t", + "description": "

The time when the logging system collected and logged the event.

This attribute is distinct from the event time in that event time typically contain the time extracted from the original event. Most of the time, these two times will be different.", + "requirement": "optional", + "caption": "Logged Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "event_code": { + "type": "string_t", + "description": "The identifier of the original event. For example the numerical Windows Event Code or Cisco syslog code.", + "requirement": "optional", + "caption": "Event Code", + "type_name": "String" + }, + "reporter": { + "type": "object_t", + "description": "The entity from which the event or finding was first reported.", + "requirement": "recommended", + "caption": "Reporter", + "object_type": "reporter", + "object_name": "Reporter" + }, + "transformation_info_list": { + "type": "object_t", + "description": "An array of transformation info that describes the mappings or transforms applied to the data.", + "is_array": true, + "requirement": "optional", + "caption": "Transformation Info", + "object_type": "transformation_info", + "object_name": "Transformation Info" + }, + "version": { + "type": "string_t", + "description": "The version of the OCSF schema, using Semantic Versioning Specification (SemVer). For example: 1.0.0. Event consumers use the version to determine the available event attributes.", + "requirement": "required", + "caption": "Version", + "type_name": "String" + }, + "modified_time": { + "type": "timestamp_t", + "description": "The time when the event was last modified or enriched.", + "requirement": "optional", + "caption": "Modified Time", + "type_name": "Timestamp" + }, + "total_queued_duration": { + "type": "object_t", + "description": "The amount of time an event spent in a queue awaiting processing. In this case, the value is the difference between processed_time and logged_time. This duration is inclusive of all queues between the originator of the event and the intended long-term storage destination of the event.", + "requirement": "optional", + "caption": "Total Queued Duration", + "object_type": "timespan", + "object_name": "Time Span" + }, + "correlation_uid": { + "type": "string_t", + "description": "A unique identifier used to correlate this OCSF event with other related OCSF events, distinct from the event's uid value. This enables linking multiple OCSF events that are part of the same activity, transaction, or security incident across different systems or time periods.", + "requirement": "optional", + "caption": "Correlation UID", + "type_name": "String" + }, + "debug": { + "type": "string_t", + "description": "Debug information about non-fatal issues with this OCSF event. Each issue is a line in this string array.", + "is_array": true, + "requirement": "optional", + "caption": "Debug Information", + "type_name": "String" + }, + "data_classification": { + "type": "object_t", + "description": "The Data Classification object includes information about data classification levels and data category types.", + "group": "context", + "requirement": "recommended", + "caption": "Data Classification", + "object_type": "data_classification", + "profiles": [ + "data_classification" + ], + "@deprecated": { + "message": "Use the attribute data_classifications instead", + "since": "1.4.0" + }, + "object_name": "Data Classification" + }, + "source": { + "type": "string_t", + "description": "The source of the event or finding. This can be any distinguishing name for the logical origin of the data \u2014 for example, 'CloudTrail Events', or a use case like 'Attack Simulations' or 'Vulnerability Scans'.", + "requirement": "optional", + "caption": "Source", + "type_name": "String" + }, + "logged_time": { + "type": "timestamp_t", + "description": "

The time when the logging system collected and logged the event.

This attribute is distinct from the event time in that event time typically contain the time extracted from the original event. Most of the time, these two times will be different.", + "requirement": "optional", + "caption": "Logged Time", + "type_name": "Timestamp" + }, + "uid": { + "type": "string_t", + "description": "A unique identifier assigned to the OCSF event. This ID is specific to the OCSF event itself and is distinct from the original event identifier in the source system (see original_event_uid).", + "requirement": "optional", + "caption": "Event UID", + "type_name": "String" + }, + "sequence": { + "type": "integer_t", + "description": "Sequence number of the event. The sequence number is a value available in some events, to make the exact ordering of events unambiguous, regardless of the event time precision.", + "requirement": "optional", + "caption": "Sequence Number", + "type_name": "Integer" + }, + "data_classifications": { + "type": "object_t", + "description": "A list of Data Classification objects, that include information about data classification levels and data category types, identified by a classifier.", + "group": "context", + "is_array": true, + "requirement": "recommended", + "caption": "Data Classification", + "object_type": "data_classification", + "profiles": [ + "data_classification" + ], + "object_name": "Data Classification" + }, + "transmit_time": { + "type": "timestamp_t", + "description": "The time when the event was transmitted from the logging device to it's next destination.", + "requirement": "optional", + "caption": "Transmission Time", + "type_name": "Timestamp" + } + }, + "name": "metadata", + "description": "The Metadata object describes the metadata associated with the event.", + "extends": "object", + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:Metadata", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:Metadata/" + } + ], + "caption": "Metadata", + "profiles": [ + "container", + "data_classification", + "datetime" + ] +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/network_endpoint.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/network_endpoint.json new file mode 100644 index 0000000000..5a510e4f02 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/network_endpoint.json @@ -0,0 +1,387 @@ +{ + "attributes": { + "vpc_uid": { + "type": "string_t", + "description": "The unique identifier of the Virtual Private Cloud (VPC).", + "requirement": "optional", + "caption": "VPC UID", + "type_name": "String" + }, + "os": { + "type": "object_t", + "description": "The endpoint operating system.", + "requirement": "optional", + "caption": "OS", + "object_type": "os", + "object_name": "Operating System (OS)" + }, + "type_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A laptop computer.", + "caption": "Laptop" + }, + "6": { + "description": "A virtual machine.", + "caption": "Virtual" + }, + "0": { + "description": "The type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A server.", + "caption": "Server" + }, + "2": { + "description": "A desktop computer.", + "caption": "Desktop" + }, + "99": { + "description": "The type is not mapped. See the type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A tablet computer.", + "caption": "Tablet" + }, + "5": { + "description": "A mobile phone.", + "caption": "Mobile" + }, + "7": { + "description": "An IOT (Internet of Things) device.", + "caption": "IOT" + }, + "8": { + "description": "A web browser.", + "caption": "Browser" + }, + "9": { + "description": "A networking firewall.", + "caption": "Firewall" + }, + "10": { + "description": "A networking switch.", + "caption": "Switch" + }, + "11": { + "description": "A networking hub.", + "caption": "Hub" + }, + "12": { + "description": "A networking router.", + "caption": "Router" + }, + "14": { + "description": "An intrusion prevention system.", + "caption": "IPS" + }, + "15": { + "description": "A Load Balancer device.", + "caption": "Load Balancer" + }, + "13": { + "description": "An intrusion detection system.", + "caption": "IDS" + } + }, + "description": "The network endpoint type ID.", + "requirement": "recommended", + "caption": "Type ID", + "sibling": "type", + "type_name": "Integer" + }, + "subnet_uid": { + "type": "string_t", + "description": "The unique identifier of a virtual subnet.", + "requirement": "optional", + "caption": "Subnet UID", + "type_name": "String" + }, + "network_scope_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "Unknown whether this endpoint resides within the customer\u2019s network.", + "caption": "Unknown" + }, + "1": { + "description": "The endpoint resides inside the customer\u2019s network.", + "caption": "Internal" + }, + "2": { + "description": "The endpoint is on the Internet or otherwise external to the customer\u2019s network.", + "caption": "External" + }, + "99": { + "description": "The network scope is not mapped. See the network_scope attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the endpoint\u2019s network scope. The normalized network scope identifier indicates whether the endpoint resides inside the customer\u2019s network, outside on the Internet, or if its location relative to the customer\u2019s network cannot be determined.", + "requirement": "optional", + "caption": "Network Scope ID", + "sibling": "network_scope", + "type_name": "Integer" + }, + "port": { + "type": "port_t", + "description": "The port used for communication within the network connection.", + "requirement": "recommended", + "caption": "Port", + "type_name": "Port" + }, + "mac_vendor": { + "type": "string_t", + "description": "The vendor or manufacturer of the endpoint's network interface controller (NIC), as identified from the MAC address.", + "references": [ + { + "description": "IEEE Registration Authority", + "url": "https://standards.ieee.org/products-programs/regauth/" + } + ], + "requirement": "optional", + "caption": "MAC Vendor", + "type_name": "String" + }, + "owner": { + "type": "object_t", + "description": "The identity of the service or user account that owns the endpoint or was last logged into it.", + "requirement": "recommended", + "caption": "Owner", + "object_type": "user", + "object_name": "User" + }, + "vlan_uid": { + "type": "string_t", + "description": "The Virtual LAN identifier.", + "requirement": "optional", + "caption": "VLAN", + "type_name": "String" + }, + "type": { + "type": "string_t", + "description": "The network endpoint type. For example: unknown, server, desktop, laptop, tablet, mobile, virtual, browser, or other.", + "requirement": "optional", + "caption": "Type", + "type_name": "String" + }, + "domain": { + "type": "string_t", + "description": "The name of the domain that the endpoint belongs to or that corresponds to the endpoint.", + "requirement": "optional", + "caption": "Domain", + "type_name": "String" + }, + "pool": { + "type": "object_t", + "description": "The pool of desktops or virtual machines to which the endpoint belongs.", + "requirement": "optional", + "caption": "Pool", + "object_type": "group", + "object_name": "Group" + }, + "location": { + "type": "object_t", + "description": "The geographical location of the endpoint.", + "requirement": "optional", + "caption": "Geo Location", + "object_type": "location", + "object_name": "Geo Location" + }, + "interface_uid": { + "type": "string_t", + "description": "The unique identifier of the network interface.", + "requirement": "recommended", + "caption": "Network Interface ID", + "type_name": "String" + }, + "network_scope": { + "type": "string_t", + "description": "Indicates whether the endpoint resides inside the customer\u2019s network, outside on the Internet, or if its location relative to the customer\u2019s network cannot be determined. The value is normalized to the caption of the network_scope_id.", + "requirement": "optional", + "caption": "Network Scope", + "type_name": "String" + }, + "proxy_endpoint": { + "type": "object_t", + "description": "The network proxy information pertaining to a specific endpoint. This can be used to describe information pertaining to network address translation (NAT).", + "requirement": "optional", + "caption": "Proxy Endpoint", + "object_type": "network_proxy", + "object_name": "Network Proxy Endpoint" + }, + "isp": { + "type": "string_t", + "description": "The name of the Internet Service Provider (ISP).", + "requirement": "optional", + "caption": "ISP Name", + "type_name": "String" + }, + "name": { + "type": "string_t", + "description": "The short name of the endpoint.", + "requirement": "recommended", + "caption": "Name", + "type_name": "String" + }, + "namespace_pid": { + "type": "integer_t", + "description": "If running under a process namespace (such as in a container), the process identifier within that process namespace.", + "group": "context", + "requirement": "recommended", + "caption": "Namespace PID", + "profiles": [ + "container" + ], + "type_name": "Integer" + }, + "autonomous_system": { + "type": "object_t", + "description": "The Autonomous System details associated with an IP address.", + "requirement": "optional", + "caption": "Autonomous System", + "object_type": "autonomous_system", + "object_name": "Autonomous System" + }, + "intermediate_ips": { + "type": "ip_t", + "description": "The intermediate IP Addresses. For example, the IP addresses in the HTTP X-Forwarded-For header.", + "is_array": true, + "requirement": "optional", + "caption": "Intermediate IP Addresses", + "type_name": "IP Address" + }, + "instance_uid": { + "type": "string_t", + "description": "The unique identifier of a VM instance.", + "requirement": "recommended", + "caption": "Instance ID", + "type_name": "String" + }, + "container": { + "type": "object_t", + "description": "The information describing an instance of a container. A container is a prepackaged, portable system image that runs isolated on an existing system using a container runtime like containerd.", + "group": "context", + "requirement": "recommended", + "caption": "Container", + "object_type": "container", + "profiles": [ + "container" + ], + "object_name": "Container" + }, + "mac": { + "type": "mac_t", + "description": "The Media Access Control (MAC) address of the endpoint.", + "requirement": "optional", + "caption": "MAC Address", + "type_name": "MAC Address" + }, + "hostname": { + "type": "hostname_t", + "description": "The fully qualified name of the endpoint.", + "requirement": "recommended", + "caption": "Hostname", + "type_name": "Hostname" + }, + "interface_name": { + "type": "string_t", + "description": "The name of the network interface (e.g. eth2).", + "requirement": "recommended", + "caption": "Network Interface Name", + "type_name": "String" + }, + "zone": { + "type": "string_t", + "description": "The network zone or LAN segment.", + "requirement": "optional", + "caption": "Network Zone", + "type_name": "String" + }, + "svc_name": { + "type": "string_t", + "description": "The service name in service-to-service connections. For example, AWS VPC logs the pkt-src-aws-service and pkt-dst-aws-service fields identify the connection is coming from or going to an AWS service.", + "requirement": "recommended", + "caption": "Service Name", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the endpoint.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String", + "observable": 48 + }, + "isp_org": { + "type": "string_t", + "description": "The organization name of the Internet Service Provider (ISP). This represents the parent organization or company that owns/operates the ISP. For example, Comcast Corporation would be the ISP org for Xfinity internet service. This attribute helps identify the ultimate provider when ISPs operate under different brand names.", + "requirement": "optional", + "caption": "ISP Org", + "type_name": "String" + }, + "hw_info": { + "type": "object_t", + "description": "The endpoint hardware information.", + "requirement": "optional", + "caption": "Hardware Info", + "object_type": "device_hw_info", + "object_name": "Device Hardware Info" + }, + "ip": { + "type": "ip_t", + "description": "The IP address of the endpoint, in either IPv4 or IPv6 format.", + "requirement": "recommended", + "caption": "IP Address", + "type_name": "IP Address" + }, + "fingerprints": { + "type": "object_t", + "description": "Fingerprints that identify the specific application implementation on this endpoint, such as Cisco NPF or HASSH.", + "is_array": true, + "requirement": "optional", + "caption": "Fingerprints", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "agent_list": { + "type": "object_t", + "description": "A list of agent objects associated with a device, endpoint, or resource.", + "is_array": true, + "requirement": "optional", + "caption": "Agent List", + "object_type": "agent", + "object_name": "Agent" + } + }, + "name": "network_endpoint", + "description": "The Network Endpoint object describes characteristics of a network endpoint. These can be a source or destination of a network connection.", + "extends": "endpoint", + "constraints": { + "at_least_one": [ + "ip", + "uid", + "name", + "hostname", + "svc_name", + "instance_uid", + "interface_uid", + "interface_name", + "domain" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:ComputerNetworkNode.", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:ComputerNetworkNode/" + } + ], + "caption": "Network Endpoint", + "profiles": [ + "container" + ], + "observable": 20 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/network_proxy.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/network_proxy.json new file mode 100644 index 0000000000..cf116e421b --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/network_proxy.json @@ -0,0 +1,387 @@ +{ + "attributes": { + "vpc_uid": { + "type": "string_t", + "description": "The unique identifier of the Virtual Private Cloud (VPC).", + "requirement": "optional", + "caption": "VPC UID", + "type_name": "String" + }, + "os": { + "type": "object_t", + "description": "The endpoint operating system.", + "requirement": "optional", + "caption": "OS", + "object_type": "os", + "object_name": "Operating System (OS)" + }, + "type_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A laptop computer.", + "caption": "Laptop" + }, + "6": { + "description": "A virtual machine.", + "caption": "Virtual" + }, + "0": { + "description": "The type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A server.", + "caption": "Server" + }, + "2": { + "description": "A desktop computer.", + "caption": "Desktop" + }, + "99": { + "description": "The type is not mapped. See the type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A tablet computer.", + "caption": "Tablet" + }, + "5": { + "description": "A mobile phone.", + "caption": "Mobile" + }, + "7": { + "description": "An IOT (Internet of Things) device.", + "caption": "IOT" + }, + "8": { + "description": "A web browser.", + "caption": "Browser" + }, + "9": { + "description": "A networking firewall.", + "caption": "Firewall" + }, + "10": { + "description": "A networking switch.", + "caption": "Switch" + }, + "11": { + "description": "A networking hub.", + "caption": "Hub" + }, + "12": { + "description": "A networking router.", + "caption": "Router" + }, + "14": { + "description": "An intrusion prevention system.", + "caption": "IPS" + }, + "15": { + "description": "A Load Balancer device.", + "caption": "Load Balancer" + }, + "13": { + "description": "An intrusion detection system.", + "caption": "IDS" + } + }, + "description": "The network endpoint type ID.", + "requirement": "recommended", + "caption": "Type ID", + "sibling": "type", + "type_name": "Integer" + }, + "subnet_uid": { + "type": "string_t", + "description": "The unique identifier of a virtual subnet.", + "requirement": "optional", + "caption": "Subnet UID", + "type_name": "String" + }, + "network_scope_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "Unknown whether this endpoint resides within the customer\u2019s network.", + "caption": "Unknown" + }, + "1": { + "description": "The endpoint resides inside the customer\u2019s network.", + "caption": "Internal" + }, + "2": { + "description": "The endpoint is on the Internet or otherwise external to the customer\u2019s network.", + "caption": "External" + }, + "99": { + "description": "The network scope is not mapped. See the network_scope attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the endpoint\u2019s network scope. The normalized network scope identifier indicates whether the endpoint resides inside the customer\u2019s network, outside on the Internet, or if its location relative to the customer\u2019s network cannot be determined.", + "requirement": "optional", + "caption": "Network Scope ID", + "sibling": "network_scope", + "type_name": "Integer" + }, + "port": { + "type": "port_t", + "description": "The port used for communication within the network connection.", + "requirement": "recommended", + "caption": "Port", + "type_name": "Port" + }, + "mac_vendor": { + "type": "string_t", + "description": "The vendor or manufacturer of the endpoint's network interface controller (NIC), as identified from the MAC address.", + "references": [ + { + "description": "IEEE Registration Authority", + "url": "https://standards.ieee.org/products-programs/regauth/" + } + ], + "requirement": "optional", + "caption": "MAC Vendor", + "type_name": "String" + }, + "owner": { + "type": "object_t", + "description": "The identity of the service or user account that owns the endpoint or was last logged into it.", + "requirement": "recommended", + "caption": "Owner", + "object_type": "user", + "object_name": "User" + }, + "vlan_uid": { + "type": "string_t", + "description": "The Virtual LAN identifier.", + "requirement": "optional", + "caption": "VLAN", + "type_name": "String" + }, + "type": { + "type": "string_t", + "description": "The network endpoint type. For example: unknown, server, desktop, laptop, tablet, mobile, virtual, browser, or other.", + "requirement": "optional", + "caption": "Type", + "type_name": "String" + }, + "domain": { + "type": "string_t", + "description": "The name of the domain that the endpoint belongs to or that corresponds to the endpoint.", + "requirement": "optional", + "caption": "Domain", + "type_name": "String" + }, + "pool": { + "type": "object_t", + "description": "The pool of desktops or virtual machines to which the endpoint belongs.", + "requirement": "optional", + "caption": "Pool", + "object_type": "group", + "object_name": "Group" + }, + "location": { + "type": "object_t", + "description": "The geographical location of the endpoint.", + "requirement": "optional", + "caption": "Geo Location", + "object_type": "location", + "object_name": "Geo Location" + }, + "interface_uid": { + "type": "string_t", + "description": "The unique identifier of the network interface.", + "requirement": "recommended", + "caption": "Network Interface ID", + "type_name": "String" + }, + "network_scope": { + "type": "string_t", + "description": "Indicates whether the endpoint resides inside the customer\u2019s network, outside on the Internet, or if its location relative to the customer\u2019s network cannot be determined. The value is normalized to the caption of the network_scope_id.", + "requirement": "optional", + "caption": "Network Scope", + "type_name": "String" + }, + "proxy_endpoint": { + "type": "object_t", + "description": "The network proxy information pertaining to a specific endpoint. This can be used to describe information pertaining to network address translation (NAT).", + "requirement": "optional", + "caption": "Proxy Endpoint", + "object_type": "network_proxy", + "object_name": "Network Proxy Endpoint" + }, + "isp": { + "type": "string_t", + "description": "The name of the Internet Service Provider (ISP).", + "requirement": "optional", + "caption": "ISP Name", + "type_name": "String" + }, + "name": { + "type": "string_t", + "description": "The short name of the endpoint.", + "requirement": "recommended", + "caption": "Name", + "type_name": "String" + }, + "namespace_pid": { + "type": "integer_t", + "description": "If running under a process namespace (such as in a container), the process identifier within that process namespace.", + "group": "context", + "requirement": "recommended", + "caption": "Namespace PID", + "profiles": [ + "container" + ], + "type_name": "Integer" + }, + "autonomous_system": { + "type": "object_t", + "description": "The Autonomous System details associated with an IP address.", + "requirement": "optional", + "caption": "Autonomous System", + "object_type": "autonomous_system", + "object_name": "Autonomous System" + }, + "intermediate_ips": { + "type": "ip_t", + "description": "The intermediate IP Addresses. For example, the IP addresses in the HTTP X-Forwarded-For header.", + "is_array": true, + "requirement": "optional", + "caption": "Intermediate IP Addresses", + "type_name": "IP Address" + }, + "instance_uid": { + "type": "string_t", + "description": "The unique identifier of a VM instance.", + "requirement": "recommended", + "caption": "Instance ID", + "type_name": "String" + }, + "container": { + "type": "object_t", + "description": "The information describing an instance of a container. A container is a prepackaged, portable system image that runs isolated on an existing system using a container runtime like containerd.", + "group": "context", + "requirement": "recommended", + "caption": "Container", + "object_type": "container", + "profiles": [ + "container" + ], + "object_name": "Container" + }, + "mac": { + "type": "mac_t", + "description": "The Media Access Control (MAC) address of the endpoint.", + "requirement": "optional", + "caption": "MAC Address", + "type_name": "MAC Address" + }, + "hostname": { + "type": "hostname_t", + "description": "The fully qualified name of the endpoint.", + "requirement": "recommended", + "caption": "Hostname", + "type_name": "Hostname" + }, + "interface_name": { + "type": "string_t", + "description": "The name of the network interface (e.g. eth2).", + "requirement": "recommended", + "caption": "Network Interface Name", + "type_name": "String" + }, + "zone": { + "type": "string_t", + "description": "The network zone or LAN segment.", + "requirement": "optional", + "caption": "Network Zone", + "type_name": "String" + }, + "svc_name": { + "type": "string_t", + "description": "The service name in service-to-service connections. For example, AWS VPC logs the pkt-src-aws-service and pkt-dst-aws-service fields identify the connection is coming from or going to an AWS service.", + "requirement": "recommended", + "caption": "Service Name", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the endpoint.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String", + "observable": 48 + }, + "isp_org": { + "type": "string_t", + "description": "The organization name of the Internet Service Provider (ISP). This represents the parent organization or company that owns/operates the ISP. For example, Comcast Corporation would be the ISP org for Xfinity internet service. This attribute helps identify the ultimate provider when ISPs operate under different brand names.", + "requirement": "optional", + "caption": "ISP Org", + "type_name": "String" + }, + "hw_info": { + "type": "object_t", + "description": "The endpoint hardware information.", + "requirement": "optional", + "caption": "Hardware Info", + "object_type": "device_hw_info", + "object_name": "Device Hardware Info" + }, + "ip": { + "type": "ip_t", + "description": "The IP address of the endpoint, in either IPv4 or IPv6 format.", + "requirement": "recommended", + "caption": "IP Address", + "type_name": "IP Address" + }, + "fingerprints": { + "type": "object_t", + "description": "Fingerprints that identify the specific application implementation on this endpoint, such as Cisco NPF or HASSH.", + "is_array": true, + "requirement": "optional", + "caption": "Fingerprints", + "object_type": "fingerprint", + "object_name": "Fingerprint" + }, + "agent_list": { + "type": "object_t", + "description": "A list of agent objects associated with a device, endpoint, or resource.", + "is_array": true, + "requirement": "optional", + "caption": "Agent List", + "object_type": "agent", + "object_name": "Agent" + } + }, + "name": "network_proxy", + "description": "The network proxy endpoint object describes a proxy server, which acts as an intermediary between a client requesting a resource and the server providing that resource.", + "extends": "network_endpoint", + "constraints": { + "at_least_one": [ + "ip", + "uid", + "name", + "hostname", + "svc_name", + "instance_uid", + "interface_uid", + "interface_name", + "domain" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:ProxyServer", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:ProxyServer/" + } + ], + "caption": "Network Proxy Endpoint", + "profiles": [ + "container" + ], + "observable": 20 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/process.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/process.json new file mode 100644 index 0000000000..e08dbe80ab --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/process.json @@ -0,0 +1,357 @@ +{ + "attributes": { + "name": { + "type": "process_name_t", + "description": "The friendly name of the process, for example: Notepad++.", + "requirement": "recommended", + "caption": "Name", + "type_name": "Process Name" + }, + "pid": { + "type": "integer_t", + "description": "The process identifier, as reported by the operating system. Process ID (PID) is a number used by the operating system to uniquely identify an active process.", + "requirement": "recommended", + "caption": "Process ID", + "type_name": "Integer", + "observable": 15 + }, + "session": { + "type": "object_t", + "description": "The user session under which this process is running.", + "requirement": "optional", + "caption": "Session", + "object_type": "session", + "object_name": "Session" + }, + "file": { + "type": "object_t", + "description": "The process file object.", + "requirement": "recommended", + "caption": "File", + "object_type": "file", + "object_name": "File" + }, + "user": { + "type": "object_t", + "description": "The user under which this process is running.", + "requirement": "recommended", + "caption": "User", + "object_type": "user", + "object_name": "User" + }, + "path": { + "type": "string_t", + "description": "The process file path.", + "requirement": "optional", + "caption": "Path", + "type_name": "String" + }, + "group": { + "type": "object_t", + "description": "The group under which this process is running.", + "requirement": "recommended", + "caption": "Group", + "object_type": "group", + "profiles": [ + "linux/linux_users" + ], + "object_name": "Group" + }, + "tid": { + "type": "integer_t", + "description": "The identifier of the thread associated with the event, as returned by the operating system.", + "requirement": "optional", + "caption": "Thread ID", + "type_name": "Integer", + "@deprecated": { + "message": "tid is deprecated in favor of ptid. ptid has type long_t which can accommodate the thread identifiers returned by all platforms (e.g. 64-bit on MacOS).", + "since": "1.6.0" + } + }, + "uid": { + "type": "string_t", + "description": "A unique identifier for this process assigned by the producer (tool). Facilitates correlation of a process event with other events for that process.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String", + "observable": 39 + }, + "loaded_modules": { + "type": "string_t", + "description": "The list of loaded module names.", + "is_array": true, + "requirement": "optional", + "caption": "Loaded Modules", + "type_name": "String" + }, + "ancestry": { + "type": "object_t", + "description": "An array of Process Entities describing the extended parentage of this process object. Direct parent information should be expressed through the parent_process attribute. The first array element is the direct parent of this process object. Subsequent list elements go up the process parentage hierarchy. That is, the array is sorted from newest to oldest process. It is recommended to only populate this field for the top-level process object.", + "is_array": true, + "references": [ + { + "description": "Guidance on Representing Process Parentage", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/representing-process-parentage.md" + } + ], + "requirement": "optional", + "caption": "Ancestry", + "object_type": "process_entity", + "object_name": "Process Entity" + }, + "auid": { + "type": "integer_t", + "description": "The audit user assigned at login by the audit subsystem.", + "requirement": "optional", + "caption": "Audit User ID", + "profiles": [ + "linux/linux_users" + ], + "type_name": "Integer" + }, + "cmd_line": { + "type": "string_t", + "description": "The full command line used to launch an application, service, process, or job. For example: ssh user@10.0.0.10. If the command line is unavailable or missing, the empty string '' is to be used.", + "requirement": "recommended", + "caption": "Command Line", + "type_name": "String", + "observable": 13 + }, + "container": { + "type": "object_t", + "description": "The information describing an instance of a container. A container is a prepackaged, portable system image that runs isolated on an existing system using a container runtime like containerd.", + "group": "context", + "requirement": "recommended", + "caption": "Container", + "object_type": "container", + "profiles": [ + "container" + ], + "object_name": "Container" + }, + "cpid": { + "type": "uuid_t", + "description": "A unique process identifier that can be assigned deterministically by multiple system data producers.", + "source": "cpid", + "references": [ + { + "description": "OCSF Common Process Identifier (CPID) Specification", + "url": "https://github.com/ocsf/common-process-id" + } + ], + "requirement": "recommended", + "caption": "Common Process Identifier", + "type_name": "UUID" + }, + "created_time": { + "type": "timestamp_t", + "description": "The time when the process was created/started.", + "requirement": "recommended", + "caption": "Created Time", + "type_name": "Timestamp" + }, + "egid": { + "type": "integer_t", + "description": "The effective group under which this process is running.", + "requirement": "optional", + "caption": "Effective Group ID", + "profiles": [ + "linux/linux_users", + "macos/macos_users" + ], + "type_name": "Integer" + }, + "environment_variables": { + "type": "object_t", + "description": "Environment variables associated with the process.", + "is_array": true, + "requirement": "optional", + "caption": "Environment Variables", + "object_type": "environment_variable", + "object_name": "Environment Variable" + }, + "euid": { + "type": "integer_t", + "description": "The effective user under which this process is running.", + "requirement": "optional", + "caption": "Effective User ID", + "profiles": [ + "linux/linux_users", + "macos/macos_users" + ], + "type_name": "Integer" + }, + "integrity": { + "type": "string_t", + "description": "The process integrity level, normalized to the caption of the integrity_id value. In the case of 'Other', it is defined by the event source (Windows only).", + "requirement": "optional", + "caption": "Integrity", + "type_name": "String" + }, + "integrity_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "Medium" + }, + "6": { + "caption": "Protected" + }, + "0": { + "description": "The integrity level is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Untrusted" + }, + "2": { + "caption": "Low" + }, + "99": { + "description": "The integrity level is not mapped. See the integrity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "High" + }, + "5": { + "caption": "System" + } + }, + "description": "The normalized identifier of the process integrity level (Windows only).", + "requirement": "optional", + "caption": "Integrity Level", + "sibling": "integrity", + "type_name": "Integer" + }, + "lineage": { + "type": "file_path_t", + "description": "The lineage of the process, represented by a list of paths for each ancestor process. For example: ['/usr/sbin/sshd', '/usr/bin/bash', '/usr/bin/whoami'].", + "is_array": true, + "requirement": "optional", + "caption": "Lineage", + "type_name": "File Path", + "@deprecated": { + "message": "Use the ancestry attribute.", + "since": "1.4.0" + } + }, + "namespace_pid": { + "type": "integer_t", + "description": "If running under a process namespace (such as in a container), the process identifier within that process namespace.", + "group": "context", + "requirement": "recommended", + "caption": "Namespace PID", + "profiles": [ + "container" + ], + "type_name": "Integer" + }, + "parent_process": { + "type": "object_t", + "description": "The parent process of this process object. It is recommended to only populate this field for the top-level process object, to prevent deep nesting. Additional ancestry information can be supplied in the ancestry attribute.", + "references": [ + { + "description": "Guidance on Representing Process Parentage", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/representing-process-parentage.md" + } + ], + "requirement": "recommended", + "caption": "Parent Process", + "object_type": "process", + "object_name": "Process" + }, + "ptid": { + "type": "long_t", + "description": "The identifier of the process thread associated with the event, as returned by the operating system.", + "requirement": "optional", + "caption": "Process Thread ID", + "type_name": "Long" + }, + "sandbox": { + "type": "string_t", + "description": "The name of the containment jail (i.e., sandbox). For example, hardened_ps, high_security_ps, oracle_ps, netsvcs_ps, or default_ps.", + "requirement": "optional", + "caption": "Sandbox", + "type_name": "String" + }, + "terminated_time": { + "type": "timestamp_t", + "description": "The time when the process was terminated.", + "requirement": "optional", + "caption": "Terminated Time", + "type_name": "Timestamp" + }, + "working_directory": { + "type": "string_t", + "description": "The working directory of a process.", + "requirement": "optional", + "caption": "Working Directory", + "type_name": "String" + }, + "xattributes": { + "type": "object_t", + "description": "An unordered collection of zero or more name/value pairs that represent a process extended attribute.", + "requirement": "optional", + "caption": "Extended Attributes", + "object_type": "object", + "object_name": "Object" + }, + "created_time_dt": { + "type": "datetime_t", + "description": "The time when the process was created/started.", + "requirement": "optional", + "caption": "Created Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "terminated_time_dt": { + "type": "datetime_t", + "description": "The time when the process was terminated.", + "requirement": "optional", + "caption": "Terminated Time", + "profiles": [ + "datetime" + ], + "type_name": "Datetime" + }, + "hosted_services": { + "type": "object_t", + "description": "The Windows services that this process is hosting.", + "extension": "win", + "is_array": true, + "requirement": "optional", + "extension_id": 2, + "caption": "Hosted Services", + "object_type": "win/win_service", + "object_name": "Windows Service" + } + }, + "name": "process", + "description": "The Process object describes a running instance of a launched program.", + "extends": "process_entity", + "constraints": { + "at_least_one": [ + "pid", + "uid", + "cpid" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:Process", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:Process/" + } + ], + "caption": "Process", + "profiles": [ + "container", + "data_classification", + "datetime", + "linux/linux_users", + "macos/macos_users" + ], + "observable": 25 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/product.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/product.json new file mode 100644 index 0000000000..b97aac76b8 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/product.json @@ -0,0 +1,110 @@ +{ + "attributes": { + "name": { + "type": "string_t", + "description": "The name of the product.", + "requirement": "recommended", + "caption": "Name", + "type_name": "String" + }, + "version": { + "type": "string_t", + "description": "The version of the product, as defined by the event source. For example: 2013.1.3-beta.", + "requirement": "recommended", + "caption": "Version", + "type_name": "String" + }, + "path": { + "type": "string_t", + "description": "The installation path of the product.", + "requirement": "optional", + "caption": "Path", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the product.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String" + }, + "feature": { + "type": "object_t", + "description": "The feature that reported the event.", + "requirement": "optional", + "caption": "Feature", + "object_type": "feature", + "object_name": "Feature" + }, + "lang": { + "type": "string_t", + "description": "The two letter lower case language codes, as defined by ISO 639-1. For example: en (English), de (German), or fr (French).", + "requirement": "optional", + "caption": "Language", + "type_name": "String" + }, + "cpe_name": { + "type": "string_t", + "description": "The Common Platform Enumeration (CPE) name as described by (NIST) For example: cpe:/a:apple:safari:16.2.", + "requirement": "optional", + "caption": "The product CPE identifier", + "type_name": "String" + }, + "data_classification": { + "type": "object_t", + "description": "The Data Classification object includes information about data classification levels and data category types.", + "group": "context", + "requirement": "recommended", + "caption": "Data Classification", + "object_type": "data_classification", + "profiles": [ + "data_classification" + ], + "@deprecated": { + "message": "Use the attribute data_classifications instead", + "since": "1.4.0" + }, + "object_name": "Data Classification" + }, + "data_classifications": { + "type": "object_t", + "description": "A list of Data Classification objects, that include information about data classification levels and data category types, identified by a classifier.", + "group": "context", + "is_array": true, + "requirement": "recommended", + "caption": "Data Classification", + "object_type": "data_classification", + "profiles": [ + "data_classification" + ], + "object_name": "Data Classification" + }, + "url_string": { + "type": "url_t", + "description": "The URL pointing towards the product.", + "requirement": "optional", + "caption": "URL String", + "type_name": "URL String" + }, + "vendor_name": { + "type": "string_t", + "description": "The name of the vendor of the product.", + "requirement": "recommended", + "caption": "Vendor Name", + "type_name": "String" + } + }, + "name": "product", + "description": "The Product object describes characteristics of a software product.", + "extends": "_entity", + "constraints": { + "at_least_one": [ + "name", + "uid" + ] + }, + "caption": "Product", + "profiles": [ + "data_classification" + ] +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/remediation.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/remediation.json new file mode 100644 index 0000000000..ff83d907df --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/remediation.json @@ -0,0 +1,62 @@ +{ + "attributes": { + "desc": { + "type": "string_t", + "description": "The description of the remediation strategy.", + "requirement": "required", + "caption": "Description", + "type_name": "String" + }, + "references": { + "type": "string_t", + "description": "A list of supporting URL/s, references that help describe the remediation strategy.", + "is_array": true, + "requirement": "optional", + "caption": "References", + "type_name": "String" + }, + "cis_controls": { + "type": "object_t", + "description": "An array of Center for Internet Security (CIS) Controls that can be optionally mapped to provide additional remediation details.", + "is_array": true, + "references": [ + { + "description": "Center For Internet Security Controls", + "url": "https://www.cisecurity.org/controls/" + } + ], + "requirement": "optional", + "caption": "CIS Controls", + "object_type": "cis_control", + "object_name": "CIS Control" + }, + "kb_article_list": { + "type": "object_t", + "description": "A list of KB articles or patches related to an endpoint. A KB Article contains metadata that describes the patch or an update.", + "is_array": true, + "requirement": "optional", + "caption": "Knowledgebase Articles", + "object_type": "kb_article", + "object_name": "KB Article" + }, + "kb_articles": { + "type": "string_t", + "description": "The KB article/s related to the entity. A KB Article contains metadata that describes the patch or an update.", + "is_array": true, + "requirement": "optional", + "caption": "Knowledgebase Articles", + "type_name": "String", + "@deprecated": { + "message": "Use the kb_article_list attribute instead.", + "since": "1.1.0" + } + } + }, + "name": "remediation", + "description": "The Remediation object describes the recommended remediation steps to address identified issue(s).", + "extends": "object", + "caption": "Remediation", + "profiles": [ + "data_classification" + ] +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/url.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/url.json new file mode 100644 index 0000000000..63f51d8816 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/url.json @@ -0,0 +1,370 @@ +{ + "attributes": { + "port": { + "type": "port_t", + "description": "The URL port. For example: 80.", + "requirement": "recommended", + "caption": "Port", + "type_name": "Port" + }, + "scheme": { + "type": "string_t", + "description": "The scheme portion of the URL. For example: http, https, ftp, or sftp.", + "requirement": "recommended", + "caption": "Scheme", + "type_name": "String" + }, + "path": { + "type": "string_t", + "description": "The URL path as extracted from the URL. For example: /download/trouble from www.example.com/download/trouble.", + "requirement": "recommended", + "caption": "Path", + "type_name": "String" + }, + "domain": { + "type": "string_t", + "description": "The domain portion of the URL. For example: example.com in https://sub.example.com.", + "requirement": "optional", + "caption": "Domain", + "type_name": "String" + }, + "hostname": { + "type": "hostname_t", + "description": "The URL host as extracted from the URL. For example: www.example.com from www.example.com/download/trouble.", + "requirement": "recommended", + "caption": "Hostname", + "type_name": "Hostname" + }, + "query_string": { + "type": "string_t", + "description": "The query portion of the URL. For example: the query portion of the URL http://www.example.com/search?q=bad&sort=date is q=bad&sort=date.", + "requirement": "recommended", + "caption": "HTTP Query String", + "type_name": "String" + }, + "categories": { + "type": "string_t", + "description": "The Website categorization names, as defined by category_ids enum values.", + "is_array": true, + "requirement": "optional", + "caption": "Website Categorization", + "type_name": "String" + }, + "category_ids": { + "type": "integer_t", + "enum": { + "60": { + "caption": "Real Estate" + }, + "93": { + "caption": "Sexual Expression" + }, + "32": { + "caption": "Brokerage/Trading" + }, + "15": { + "caption": "Weapons" + }, + "37": { + "caption": "Health" + }, + "5": { + "caption": "Intimate Apparel/Swimsuit" + }, + "102": { + "caption": "Potentially Unwanted Software" + }, + "47": { + "caption": "Personals/Dating" + }, + "20": { + "caption": "Entertainment" + }, + "16": { + "caption": "Abortion" + }, + "101": { + "caption": "Spam" + }, + "109": { + "caption": "Internet Connected Devices" + }, + "85": { + "caption": "Office/Business Applications" + }, + "31": { + "caption": "Financial Services" + }, + "25": { + "caption": "Controlled Substances" + }, + "114": { + "caption": "TV/Video Streams" + }, + "36": { + "caption": "Political/Social Advocacy" + }, + "63": { + "caption": "Personal Sites" + }, + "22": { + "caption": "Alternative Spirituality/Belief" + }, + "59": { + "caption": "Auctions" + }, + "34": { + "caption": "Government/Legal" + }, + "27": { + "caption": "Education" + }, + "99": { + "description": "The Domain/URL category is not mapped. See the categories attribute, which contains a data source specific value.", + "caption": "Other" + }, + "118": { + "caption": "Piracy/Copyright Concerns" + }, + "4": { + "caption": "Sex Education" + }, + "45": { + "caption": "Job Search/Careers" + }, + "90": { + "caption": "Uncategorized" + }, + "56": { + "caption": "File Storage/Sharing" + }, + "29": { + "caption": "Charitable Organizations" + }, + "67": { + "caption": "Vehicles" + }, + "65": { + "caption": "Sports/Recreation" + }, + "24": { + "caption": "Tobacco" + }, + "83": { + "caption": "Peer-to-Peer (P2P)" + }, + "1": { + "caption": "Adult/Mature Content" + }, + "66": { + "caption": "Travel" + }, + "57": { + "caption": "Remote Access Tools" + }, + "97": { + "caption": "Content Servers" + }, + "92": { + "caption": "Suspicious" + }, + "38": { + "caption": "Technology/Internet" + }, + "111": { + "caption": "Online Meetings" + }, + "51": { + "caption": "Chat (IM)/SMS" + }, + "52": { + "caption": "Email" + }, + "46": { + "caption": "News/Media" + }, + "23": { + "caption": "Alcohol" + }, + "14": { + "caption": "Violence/Hate/Racism" + }, + "87": { + "caption": "For Kids" + }, + "89": { + "caption": "Web Hosting" + }, + "88": { + "caption": "Web Ads/Analytics" + }, + "107": { + "caption": "Informational" + }, + "61": { + "caption": "Society/Daily Living" + }, + "113": { + "caption": "Radio/Audio Streams" + }, + "103": { + "caption": "Dynamic DNS Host" + }, + "121": { + "caption": "Marijuana" + }, + "86": { + "caption": "Proxy Avoidance" + }, + "55": { + "caption": "Social Networking" + }, + "49": { + "caption": "Reference" + }, + "106": { + "caption": "E-Card/Invitations" + }, + "7": { + "caption": "Extreme" + }, + "98": { + "caption": "Placeholders" + }, + "112": { + "caption": "Media Sharing" + }, + "40": { + "caption": "Search Engines/Portals" + }, + "58": { + "caption": "Shopping" + }, + "17": { + "caption": "Hacking" + }, + "6": { + "caption": "Nudity" + }, + "44": { + "caption": "Malicious Outbound Data/Botnets" + }, + "3": { + "caption": "Pornography" + }, + "110": { + "caption": "Internet Telephony" + }, + "26": { + "caption": "Child Pornography" + }, + "50": { + "caption": "Mixed Content/Potentially Adult" + }, + "30": { + "caption": "Art/Culture" + }, + "95": { + "caption": "Translation" + }, + "18": { + "caption": "Phishing" + }, + "0": { + "description": "The Domain/URL category is unknown.", + "caption": "Unknown" + }, + "71": { + "caption": "Software Downloads" + }, + "53": { + "caption": "Newsgroups/Forums" + }, + "35": { + "caption": "Military" + }, + "54": { + "caption": "Religion" + }, + "43": { + "caption": "Malicious Sources/Malnets" + }, + "84": { + "caption": "Audio/Video Clips" + }, + "64": { + "caption": "Restaurants/Dining/Food" + }, + "21": { + "caption": "Business/Economy" + }, + "11": { + "caption": "Gambling" + }, + "68": { + "caption": "Humor/Jokes" + }, + "108": { + "caption": "Computer/Information Security" + }, + "9": { + "caption": "Scam/Questionable/Illegal" + }, + "96": { + "caption": "Non-Viewable/Infrastructure" + }, + "33": { + "caption": "Games" + } + }, + "description": "The Website categorization identifiers.", + "is_array": true, + "requirement": "recommended", + "caption": "Website Categorization IDs", + "sibling": "categories", + "type_name": "Integer" + }, + "resource_type": { + "type": "string_t", + "description": "The context in which a resource was retrieved in a web request.", + "requirement": "optional", + "caption": "Resource Type", + "type_name": "String" + }, + "subdomain": { + "type": "string_t", + "description": "The subdomain portion of the URL. For example: sub in https://sub.example.com or sub2.sub1 in https://sub2.sub1.example.com.", + "requirement": "optional", + "caption": "Subdomain", + "type_name": "String" + }, + "url_string": { + "type": "url_t", + "description": "The URL string. See RFC 1738. For example: http://www.example.com/download/trouble.exe. Note: The URL path should not populate the URL string.", + "requirement": "recommended", + "caption": "URL String", + "type_name": "URL String" + } + }, + "name": "url", + "description": "The Uniform Resource Locator (URL) object describes the characteristics of a URL.", + "extends": "object", + "constraints": { + "at_least_one": [ + "url_string", + "path" + ] + }, + "references": [ + { + "description": "Defined in RFC 1738", + "url": "https://datatracker.ietf.org/doc/html/rfc1738" + }, + { + "description": "D3FEND\u2122 Ontology d3f:URL", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:URL/" + } + ], + "caption": "Uniform Resource Locator", + "observable": 23 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/profiles/ai_operation.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/profiles/ai_operation.json new file mode 100644 index 0000000000..7cac95ef03 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/profiles/ai_operation.json @@ -0,0 +1,26 @@ +{ + "attributes": { + "ai_model": { + "type": "object_t", + "description": "The AI Model object describes the characteristics of an AI/ML model. Examples include language models like GPT-4, embedding models like text-embedding-ada-002, and computer vision models like CLIP.", + "group": "context", + "requirement": "recommended", + "caption": "AI Model", + "object_type": "ai_model", + "object_name": "AI Model" + }, + "message_context": { + "type": "object_t", + "description": "Communication context for AI system interactions including protocols, roles, clients, and session information for MCP and other AI communication systems.", + "group": "context", + "requirement": "optional", + "caption": "Message Context", + "object_type": "message_context", + "object_name": "Message Context" + } + }, + "meta": "profile", + "name": "ai_operation", + "description": "AI-specific attributes for model operations, retrieval systems, and agent activities. e.g. model_name, total_token_counts etc.", + "caption": "AI Operation" +} diff --git a/crates/openshell-ocsf/src/builders/api_activity.rs b/crates/openshell-ocsf/src/builders/api_activity.rs new file mode 100644 index 0000000000..03c857f082 --- /dev/null +++ b/crates/openshell-ocsf/src/builders/api_activity.rs @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Builder for API Activity [6003] events. + +use crate::builders::SandboxContext; +use crate::enums::{SeverityId, StatusId}; +use crate::events::base_event::BaseEventData; +use crate::events::{ApiActivityEvent, OcsfEvent}; +use crate::objects::{Actor, AiModel, Api, Endpoint, HttpRequest, Process}; + +const MAX_MODEL_LEN: usize = 256; + +fn sanitize_model_name(name: &str) -> String { + let truncated = if name.len() > MAX_MODEL_LEN { + let mut end = MAX_MODEL_LEN; + while end > 0 && !name.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &name[..end]) + } else { + name.to_string() + }; + truncated.replace(['\n', '\r', '\t'], " ") +} + +/// Builder for API Activity [6003] events. +pub struct ApiActivityBuilder<'a> { + ctx: &'a SandboxContext, + severity: SeverityId, + status: Option, + message: Option, + api_operation: String, + http_request: Option, + dst_endpoint: Option, + ai_model: Option, + unmapped: serde_json::Map, +} + +impl<'a> ApiActivityBuilder<'a> { + #[must_use] + pub fn new(ctx: &'a SandboxContext, operation: impl Into) -> Self { + Self { + ctx, + severity: SeverityId::Informational, + status: None, + message: None, + api_operation: operation.into(), + http_request: None, + dst_endpoint: None, + ai_model: None, + unmapped: serde_json::Map::new(), + } + } + + #[must_use] + pub fn severity(mut self, severity: SeverityId) -> Self { + self.severity = severity; + self + } + + #[must_use] + pub fn status(mut self, status: StatusId) -> Self { + self.status = Some(status); + self + } + + #[must_use] + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + + #[must_use] + pub fn http_request(mut self, req: HttpRequest) -> Self { + self.http_request = Some(req); + self + } + + #[must_use] + pub fn dst_endpoint(mut self, ep: Endpoint) -> Self { + self.dst_endpoint = Some(ep); + self + } + + /// Attach the `ai_operation` profile with model identity. + /// Model name and provider are sanitized (newlines stripped, length capped). + #[must_use] + pub fn ai_model(mut self, model: AiModel) -> Self { + let sanitized = AiModel { + name: sanitize_model_name(&model.name), + ai_provider: sanitize_model_name(&model.ai_provider), + version: model.version, + uid: model.uid, + }; + self.ai_model = Some(sanitized); + self + } + + #[must_use] + pub fn unmapped(mut self, key: &str, value: impl Into) -> Self { + self.unmapped.insert(key.to_string(), value.into()); + self + } + + #[must_use] + pub fn build(self) -> OcsfEvent { + let mut profiles: Vec<&str> = vec!["container", "host"]; + if self.ai_model.is_some() { + profiles.push("ai_operation"); + } + let mut base = BaseEventData::new( + 6003, + "API Activity", + 6, + "Application Activity", + 99, + "Other", + self.severity, + self.ctx.metadata(&profiles), + ); + if let Some(ai_model) = self.ai_model { + base.set_ai_model(ai_model); + } + if !self.unmapped.is_empty() { + base.unmapped = Some(serde_json::Value::Object(self.unmapped)); + } + self.ctx + .apply_common_fields(&mut base, self.status, self.message); + + OcsfEvent::ApiActivity(ApiActivityEvent { + base, + api: Api::new(&self.api_operation), + actor: Actor { + process: Process::new("openshell-supervisor", 1), + }, + src_endpoint: self.ctx.proxy_endpoint(), + http_request: self.http_request, + http_response: None, + dst_endpoint: self.dst_endpoint, + action: None, + disposition: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::builders::test_sandbox_context; + use crate::objects::Url; + + #[test] + fn test_api_activity_builder_with_ai_model() { + let ctx = test_sandbox_context(); + let event = ApiActivityBuilder::new(&ctx, "POST /v1/messages") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .ai_model(AiModel::new("claude-3-haiku", "anthropic")) + .http_request(HttpRequest::new( + "POST", + Url::new("https", "inference.local", "/v1/messages", 443), + )) + .dst_endpoint(Endpoint::from_domain("inference.local", 443)) + .unmapped("latency_ms", 701_u64) + .unmapped("input_tokens", 12_u64) + .message("Model call: claude-3-haiku via anthropic") + .build(); + + let json = event.to_json().unwrap(); + assert_eq!(json["class_uid"], 6003); + assert_eq!(json["class_name"], "API Activity"); + assert_eq!(json["activity_id"], 99); + assert_eq!(json["api"]["operation"], "POST /v1/messages"); + assert_eq!(json["actor"]["process"]["name"], "openshell-supervisor"); + assert!(json.get("src_endpoint").is_some()); + assert_eq!(json["ai_model"]["name"], "claude-3-haiku"); + assert_eq!(json["ai_model"]["ai_provider"], "anthropic"); + assert_eq!(json["http_request"]["http_method"], "POST"); + assert_eq!(json["unmapped"]["latency_ms"], 701); + assert!( + json["metadata"]["profiles"] + .as_array() + .unwrap() + .iter() + .any(|p| p == "ai_operation") + ); + assert!(json.get("api_operation").is_none()); + } + + #[test] + fn test_api_activity_builder_without_ai_model() { + let ctx = test_sandbox_context(); + let event = ApiActivityBuilder::new(&ctx, "GET /v1/models") + .severity(SeverityId::Informational) + .build(); + + let json = event.to_json().unwrap(); + assert_eq!(json["class_uid"], 6003); + assert!(json.get("ai_model").is_none()); + assert!( + !json["metadata"]["profiles"] + .as_array() + .unwrap() + .iter() + .any(|p| p == "ai_operation") + ); + } + + #[test] + fn test_model_name_sanitized() { + let ctx = test_sandbox_context(); + let event = ApiActivityBuilder::new(&ctx, "POST /v1/messages") + .ai_model(AiModel::new("evil\nNET:OPEN [INFO] forged", "provider")) + .build(); + + let json = event.to_json().unwrap(); + let name = json["ai_model"]["name"].as_str().unwrap(); + assert!(!name.contains('\n')); + assert!(name.contains("evil NET:OPEN [INFO] forged")); + } + + #[test] + fn test_model_name_truncated() { + let ctx = test_sandbox_context(); + let long_name = "x".repeat(500); + let event = ApiActivityBuilder::new(&ctx, "POST /v1/messages") + .ai_model(AiModel::new(&long_name, "provider")) + .build(); + + let json = event.to_json().unwrap(); + let name = json["ai_model"]["name"].as_str().unwrap(); + assert!(name.len() <= MAX_MODEL_LEN + 3); + assert!(name.ends_with("...")); + } +} diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index cbedad1df6..e63b2be88f 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -148,6 +148,7 @@ macro_rules! impl_firewall_rule_setter { }; } +mod api_activity; mod base; mod config; mod finding; @@ -157,6 +158,7 @@ mod network; mod process; mod ssh; +pub use api_activity::ApiActivityBuilder; pub use base::BaseEventBuilder; pub use config::ConfigStateChangeBuilder; pub use finding::DetectionFindingBuilder; @@ -272,7 +274,7 @@ mod tests { fn test_sandbox_context_metadata() { let ctx = test_sandbox_context(); let meta = ctx.metadata(&["security_control", "container"]); - assert_eq!(meta.version, "1.7.0"); + assert_eq!(meta.version, "1.8.0"); assert_eq!(meta.product.name, "OpenShell Sandbox Supervisor"); assert_eq!(meta.profiles.len(), 2); assert_eq!(meta.uid.as_deref(), Some("sandbox-abc123")); diff --git a/crates/openshell-ocsf/src/enums/http_method.rs b/crates/openshell-ocsf/src/enums/http_method.rs index b7b04e09d6..6ac89533ef 100644 --- a/crates/openshell-ocsf/src/enums/http_method.rs +++ b/crates/openshell-ocsf/src/enums/http_method.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; -/// HTTP method as defined in the OCSF v1.7.0 `http_request` object schema. +/// HTTP method as defined in the OCSF v1.8.0 `http_request` object schema. /// /// The 9 standard methods are typed variants. Non-standard methods use /// `Other(String)`. diff --git a/crates/openshell-ocsf/src/enums/mod.rs b/crates/openshell-ocsf/src/enums/mod.rs index 8ab03eb63f..96567f86d5 100644 --- a/crates/openshell-ocsf/src/enums/mod.rs +++ b/crates/openshell-ocsf/src/enums/mod.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OCSF v1.7.0 enum types. +//! OCSF v1.8.0 enum types. mod action; mod activity; diff --git a/crates/openshell-ocsf/src/events/api_activity.rs b/crates/openshell-ocsf/src/events/api_activity.rs new file mode 100644 index 0000000000..55c2f70d6d --- /dev/null +++ b/crates/openshell-ocsf/src/events/api_activity.rs @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF API Activity [6003] event class. + +use serde::Deserialize; + +use super::base_event::BaseEventData; +use crate::enums::{ActionId, DispositionId}; +use crate::objects::{Actor, Api, Endpoint, HttpRequest, HttpResponse}; + +/// API Activity event (`class_uid` 6003). +/// +/// Represents an API call, such as an inference request to a model provider. +/// Supports the `ai_operation` profile in OCSF v1.8.0+. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ApiActivityEvent { + /// Common base event fields. + #[serde(flatten)] + pub base: BaseEventData, + + /// The API object (required: contains `operation`). + pub api: Api, + + /// Actor (process making the API call, required). + pub actor: Actor, + + /// Source endpoint (caller, required). + pub src_endpoint: Endpoint, + + /// HTTP request details. + #[serde(default)] + pub http_request: Option, + + /// HTTP response details. + #[serde(default)] + pub http_response: Option, + + /// Destination endpoint (API provider). + #[serde(default)] + pub dst_endpoint: Option, + + /// Action taken (allowed, denied, etc.). + #[serde(default)] + pub action: Option, + + /// Disposition. + #[serde(default)] + pub disposition: Option, +} + +impl serde::Serialize for ApiActivityEvent { + fn serialize(&self, serializer: S) -> Result { + use crate::events::serde_helpers::{insert_enum_pair, insert_optional}; + + let mut base_val = serde_json::to_value(&self.base).map_err(serde::ser::Error::custom)?; + let obj = base_val + .as_object_mut() + .ok_or_else(|| serde::ser::Error::custom("expected object"))?; + + obj.insert( + "api".to_string(), + serde_json::to_value(&self.api).map_err(serde::ser::Error::custom)?, + ); + obj.insert( + "actor".to_string(), + serde_json::to_value(&self.actor).map_err(serde::ser::Error::custom)?, + ); + obj.insert( + "src_endpoint".to_string(), + serde_json::to_value(&self.src_endpoint).map_err(serde::ser::Error::custom)?, + ); + insert_optional!(obj, "http_request", self.http_request); + insert_optional!(obj, "http_response", self.http_response); + insert_optional!(obj, "dst_endpoint", self.dst_endpoint); + insert_enum_pair!(obj, "action", self.action); + insert_enum_pair!(obj, "disposition", self.disposition); + + base_val.serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::enums::SeverityId; + use crate::objects::{AiModel, Metadata, Process, Product}; + + fn test_api_activity() -> ApiActivityEvent { + let mut base = BaseEventData::new( + 6003, + "API Activity", + 6, + "Application Activity", + 99, + "Other", + SeverityId::Informational, + Metadata { + version: "1.8.0".to_string(), + product: Product::openshell_sandbox("0.1.0"), + profiles: vec!["ai_operation".to_string()], + uid: None, + log_source: None, + }, + ); + base.set_ai_model(AiModel::new("claude-3-haiku", "anthropic")); + ApiActivityEvent { + base, + api: Api::new("POST /v1/messages"), + actor: Actor { + process: Process::new("supervisor", 1), + }, + src_endpoint: Endpoint::from_domain("inference.local", 443), + http_request: None, + http_response: None, + dst_endpoint: None, + action: None, + disposition: None, + } + } + + #[test] + fn test_api_activity_serialization() { + let event = test_api_activity(); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["class_uid"], 6003); + assert_eq!(json["class_name"], "API Activity"); + assert_eq!(json["activity_id"], 99); + assert_eq!(json["api"]["operation"], "POST /v1/messages"); + assert_eq!(json["actor"]["process"]["name"], "supervisor"); + assert_eq!(json["src_endpoint"]["domain"], "inference.local"); + assert_eq!(json["ai_model"]["name"], "claude-3-haiku"); + } + + #[test] + fn test_api_activity_roundtrip() { + let event = test_api_activity(); + let json = serde_json::to_string(&event).unwrap(); + let deserialized: ApiActivityEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.base.class_uid, 6003); + assert_eq!(deserialized.api.operation, "POST /v1/messages"); + assert!(deserialized.base.ai_model.is_some()); + } +} diff --git a/crates/openshell-ocsf/src/events/app_lifecycle.rs b/crates/openshell-ocsf/src/events/app_lifecycle.rs index 4dc074eb73..103b385e2a 100644 --- a/crates/openshell-ocsf/src/events/app_lifecycle.rs +++ b/crates/openshell-ocsf/src/events/app_lifecycle.rs @@ -38,7 +38,7 @@ mod tests { "Start", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["container".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/events/base_event.rs b/crates/openshell-ocsf/src/events/base_event.rs index 97e7c038b8..f781ff1393 100644 --- a/crates/openshell-ocsf/src/events/base_event.rs +++ b/crates/openshell-ocsf/src/events/base_event.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::enums::{SeverityId, StatusId}; -use crate::objects::{Container, Device, Metadata}; +use crate::objects::{AiModel, Container, Device, Metadata}; /// Common fields shared by all OCSF event classes. /// @@ -67,6 +67,10 @@ pub struct BaseEventData { #[serde(skip_serializing_if = "Option::is_none")] pub container: Option, + /// AI model info (`ai_operation` profile, OCSF v1.8.0+). + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_model: Option, + /// Unmapped fields that don't fit the OCSF schema. #[serde(skip_serializing_if = "Option::is_none")] pub unmapped: Option, @@ -112,6 +116,9 @@ impl Serialize for BaseEventData { if let Some(ref container) = self.container { map.serialize_entry("container", container)?; } + if let Some(ref ai_model) = self.ai_model { + map.serialize_entry("ai_model", ai_model)?; + } if let Some(ref unmapped) = self.unmapped { map.serialize_entry("unmapped", unmapped)?; } @@ -154,6 +161,7 @@ impl BaseEventData { metadata, device: None, container: None, + ai_model: None, unmapped: None, } } @@ -188,6 +196,11 @@ impl BaseEventData { self.container = Some(container); } + /// Set AI model info (`ai_operation` profile). + pub fn set_ai_model(&mut self, ai_model: AiModel) { + self.ai_model = Some(ai_model); + } + /// Add an unmapped field. pub fn add_unmapped(&mut self, key: &str, value: impl Into) { let map = self @@ -214,7 +227,7 @@ mod tests { fn test_metadata() -> Metadata { Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["container".to_string(), "host".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/events/config_state_change.rs b/crates/openshell-ocsf/src/events/config_state_change.rs index a28b437568..69333554a1 100644 --- a/crates/openshell-ocsf/src/events/config_state_change.rs +++ b/crates/openshell-ocsf/src/events/config_state_change.rs @@ -73,7 +73,7 @@ mod tests { "Log", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/events/detection_finding.rs b/crates/openshell-ocsf/src/events/detection_finding.rs index 35ef222c1f..0c928be44c 100644 --- a/crates/openshell-ocsf/src/events/detection_finding.rs +++ b/crates/openshell-ocsf/src/events/detection_finding.rs @@ -103,7 +103,7 @@ mod tests { "Create", SeverityId::High, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/events/http_activity.rs b/crates/openshell-ocsf/src/events/http_activity.rs index fe3b035799..dc5c9f8484 100644 --- a/crates/openshell-ocsf/src/events/http_activity.rs +++ b/crates/openshell-ocsf/src/events/http_activity.rs @@ -114,7 +114,7 @@ mod tests { "Get", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/events/mod.rs b/crates/openshell-ocsf/src/events/mod.rs index 23519e5662..93101bee2f 100644 --- a/crates/openshell-ocsf/src/events/mod.rs +++ b/crates/openshell-ocsf/src/events/mod.rs @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OCSF v1.7.0 event class definitions. +//! OCSF v1.8.0 event class definitions. +mod api_activity; mod app_lifecycle; pub(crate) mod base_event; mod config_state_change; @@ -13,6 +14,7 @@ mod process_activity; pub(crate) mod serde_helpers; mod ssh_activity; +pub use api_activity::ApiActivityEvent; pub use app_lifecycle::ApplicationLifecycleEvent; pub use base_event::{BaseEvent, BaseEventData}; pub use config_state_change::DeviceConfigStateChangeEvent; @@ -45,6 +47,8 @@ pub enum OcsfEvent { ApplicationLifecycle(ApplicationLifecycleEvent), /// Device Config State Change [5019] DeviceConfigStateChange(DeviceConfigStateChangeEvent), + /// API Activity [6003] + ApiActivity(ApiActivityEvent), /// Base Event [0] Base(BaseEvent), } @@ -59,6 +63,7 @@ impl Serialize for OcsfEvent { Self::DetectionFinding(e) => e.serialize(serializer), Self::ApplicationLifecycle(e) => e.serialize(serializer), Self::DeviceConfigStateChange(e) => e.serialize(serializer), + Self::ApiActivity(e) => e.serialize(serializer), Self::Base(e) => e.serialize(serializer), } } @@ -96,6 +101,9 @@ impl<'de> Deserialize<'de> for OcsfEvent { 5019 => serde_json::from_value::(value) .map(Self::DeviceConfigStateChange) .map_err(serde::de::Error::custom), + 6003 => serde_json::from_value::(value) + .map(Self::ApiActivity) + .map_err(serde::de::Error::custom), 0 => serde_json::from_value::(value) .map(Self::Base) .map_err(serde::de::Error::custom), @@ -118,6 +126,7 @@ impl OcsfEvent { Self::DetectionFinding(_) => 2004, Self::ApplicationLifecycle(_) => 6002, Self::DeviceConfigStateChange(_) => 5019, + Self::ApiActivity(_) => 6003, Self::Base(_) => 0, } } @@ -133,6 +142,7 @@ impl OcsfEvent { Self::DetectionFinding(e) => &e.base, Self::ApplicationLifecycle(e) => &e.base, Self::DeviceConfigStateChange(e) => &e.base, + Self::ApiActivity(e) => &e.base, Self::Base(e) => &e.base, } } @@ -142,9 +152,9 @@ impl OcsfEvent { mod tests { use super::*; use crate::builders::{ - AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder, DetectionFindingBuilder, - HttpActivityBuilder, NetworkActivityBuilder, ProcessActivityBuilder, SshActivityBuilder, - test_sandbox_context, + ApiActivityBuilder, AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder, + DetectionFindingBuilder, HttpActivityBuilder, NetworkActivityBuilder, + ProcessActivityBuilder, SshActivityBuilder, test_sandbox_context, }; use crate::enums::*; use crate::objects::*; @@ -277,6 +287,37 @@ mod tests { assert_eq!(deserialized.class_uid(), 0); } + #[test] + fn test_roundtrip_api_activity_with_ai_model() { + let ctx = test_sandbox_context(); + let event = ApiActivityBuilder::new(&ctx, "POST /v1/messages") + .severity(SeverityId::Informational) + .ai_model(AiModel::new("claude-3-haiku", "anthropic")) + .build(); + + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["class_uid"], 6003); + assert_eq!(json["activity_id"], 99); + assert_eq!(json["ai_model"]["name"], "claude-3-haiku"); + assert_eq!(json["ai_model"]["ai_provider"], "anthropic"); + assert_eq!(json["api"]["operation"], "POST /v1/messages"); + assert!(json.get("api_operation").is_none()); + assert!(json.get("actor").is_some()); + assert!(json.get("src_endpoint").is_some()); + assert!( + json["metadata"]["profiles"] + .as_array() + .unwrap() + .iter() + .any(|p| p == "ai_operation") + ); + + let deserialized: OcsfEvent = serde_json::from_value(json).unwrap(); + assert!(matches!(deserialized, OcsfEvent::ApiActivity(_))); + assert_eq!(deserialized.class_uid(), 6003); + assert!(deserialized.base().ai_model.is_some()); + } + #[test] fn test_deserialize_unknown_class_uid_errors() { let json = serde_json::json!({"class_uid": 9999}); diff --git a/crates/openshell-ocsf/src/events/network_activity.rs b/crates/openshell-ocsf/src/events/network_activity.rs index 92450bbe80..86cebaa8e2 100644 --- a/crates/openshell-ocsf/src/events/network_activity.rs +++ b/crates/openshell-ocsf/src/events/network_activity.rs @@ -109,7 +109,7 @@ mod tests { "Open", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["security_control".to_string(), "network_proxy".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/events/process_activity.rs b/crates/openshell-ocsf/src/events/process_activity.rs index 0c5829f92b..ade85a1950 100644 --- a/crates/openshell-ocsf/src/events/process_activity.rs +++ b/crates/openshell-ocsf/src/events/process_activity.rs @@ -16,10 +16,10 @@ pub struct ProcessActivityEvent { #[serde(flatten)] pub base: BaseEventData, - /// The process being acted upon (required in v1.7.0). + /// The process being acted upon (required in v1.8.0). pub process: Process, - /// Actor (parent/supervisor process, required in v1.7.0). + /// Actor (parent/supervisor process, required in v1.8.0). #[serde(skip_serializing_if = "Option::is_none")] pub actor: Option, @@ -86,7 +86,7 @@ mod tests { "Launch", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["container".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/events/ssh_activity.rs b/crates/openshell-ocsf/src/events/ssh_activity.rs index 0248c6593a..29ffdeca38 100644 --- a/crates/openshell-ocsf/src/events/ssh_activity.rs +++ b/crates/openshell-ocsf/src/events/ssh_activity.rs @@ -103,7 +103,7 @@ mod tests { "Open", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/format/jsonl.rs b/crates/openshell-ocsf/src/format/jsonl.rs index 8f3606ba0e..27fa8fb700 100644 --- a/crates/openshell-ocsf/src/format/jsonl.rs +++ b/crates/openshell-ocsf/src/format/jsonl.rs @@ -38,7 +38,7 @@ mod tests { "Other", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["container".to_string()], uid: Some("sandbox-abc123".to_string()), @@ -63,7 +63,7 @@ mod tests { assert_eq!(json["time"], 1_742_054_400_000_i64); assert_eq!(json["severity_id"], 1); assert_eq!(json["severity"], "Informational"); - assert_eq!(json["metadata"]["version"], "1.7.0"); + assert_eq!(json["metadata"]["version"], "1.8.0"); } #[test] diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 60df77639f..77e6751b10 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -240,12 +240,20 @@ impl OcsfEvent { (false, true) => format!(" {action}"), (false, false) => format!(" {action}{arrow}"), }; - let message_ctx = - if detail.is_empty() && rule_ctx.is_empty() && reason_ctx.is_empty() { - message_tag(&e.base) - } else { - String::new() - }; + // Most network messages duplicate the structured action and + // destination shown above. Transparent TCP correlation is the + // exception: its message intentionally carries the logical, + // synthetic, and actual dial targets needed to follow the + // policy-DNS mapping in the human-readable audit log. + let show_correlation_message = + e.base.status_detail.as_deref() == Some("transparent_tcp_allowed"); + let message_ctx = if show_correlation_message + || (detail.is_empty() && rule_ctx.is_empty() && reason_ctx.is_empty()) + { + message_tag(&e.base) + } else { + String::new() + }; format!("NET:{activity} {sev}{detail}{rule_ctx}{reason_ctx}{message_ctx}") } @@ -299,6 +307,50 @@ impl OcsfEvent { format!("HTTP:{method} {sev}{detail}{rule_ctx}{outcome_ctx}{message_ctx}") } + Self::ApiActivity(e) => { + let model_ctx = e + .base + .ai_model + .as_ref() + .map(|m| { + format!( + " {}", + escape_context_field(&truncate_with_ellipsis(&m.name, MAX_REASON_LEN)) + ) + }) + .unwrap_or_default(); + let provider_ctx = e + .base + .ai_model + .as_ref() + .map(|m| { + format!( + " via {}", + escape_context_field(&truncate_with_ellipsis( + &m.ai_provider, + MAX_REASON_LEN + )) + ) + }) + .unwrap_or_default(); + let latency = e + .base + .unmapped + .as_ref() + .and_then(|u| u.get("latency_ms")) + .and_then(serde_json::Value::as_u64) + .map(|ms| format!(" {ms}ms")) + .unwrap_or_default(); + let op = + escape_context_field(&truncate_with_ellipsis(&e.api.operation, MAX_REASON_LEN)); + let status_ctx = e + .base + .status + .map(|s| format!(" {}", s.label())) + .unwrap_or_default(); + format!("API:INFERENCE {sev}{status_ctx}{model_ctx}{provider_ctx}{latency} [{op}]") + } + Self::SshActivity(e) => { let activity = e.base.activity_name.to_uppercase(); let action = e.action.map_or(String::new(), |a| a.label().to_uppercase()); @@ -481,7 +533,7 @@ mod tests { fn test_metadata() -> Metadata { Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index e9d1402a68..345ea57175 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -3,7 +3,7 @@ //! # openshell-ocsf //! -//! OCSF v1.7.0 event types, formatters, and tracing layers for `OpenShell` +//! OCSF v1.8.0 event types, formatters, and tracing layers for `OpenShell` //! sandbox logging. //! //! This crate provides: @@ -22,7 +22,7 @@ //! tracing system /// OCSF schema version this crate implements. -pub const OCSF_VERSION: &str = "1.7.0"; +pub const OCSF_VERSION: &str = "1.8.0"; pub mod builders; pub mod ctx; @@ -37,7 +37,7 @@ pub mod validation; // --- Core event types --- pub use events::{ - ApplicationLifecycleEvent, BaseEvent, BaseEventData, DetectionFindingEvent, + ApiActivityEvent, ApplicationLifecycleEvent, BaseEvent, BaseEventData, DetectionFindingEvent, DeviceConfigStateChangeEvent, HttpActivityEvent, NetworkActivityEvent, OcsfEvent, ProcessActivityEvent, SshActivityEvent, }; @@ -50,16 +50,16 @@ pub use enums::{ // --- Object types --- pub use objects::{ - Actor, Attack, ConnectionInfo, Container, Device, Endpoint, Evidence, FindingInfo, - FirewallRule, HttpRequest, HttpResponse, Image, Metadata, OsInfo, Process, Product, - Remediation, Tactic, Technique, Url, + Actor, AiModel, Api, Attack, ConnectionInfo, Container, Device, Endpoint, Evidence, + FindingInfo, FirewallRule, HttpRequest, HttpResponse, Image, Metadata, OsInfo, Process, + Product, Remediation, Tactic, Technique, Url, }; // --- Builders --- pub use builders::{ - AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder, DetectionFindingBuilder, - HttpActivityBuilder, NetworkActivityBuilder, ProcessActivityBuilder, SandboxContext, - SshActivityBuilder, + ApiActivityBuilder, AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder, + DetectionFindingBuilder, HttpActivityBuilder, NetworkActivityBuilder, ProcessActivityBuilder, + SandboxContext, SshActivityBuilder, }; // --- Tracing layers --- diff --git a/crates/openshell-ocsf/src/objects/ai_model.rs b/crates/openshell-ocsf/src/objects/ai_model.rs new file mode 100644 index 0000000000..8ff6709edb --- /dev/null +++ b/crates/openshell-ocsf/src/objects/ai_model.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF `ai_model` object (introduced with the `ai_operation` profile in v1.8.0). + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiModel { + pub name: String, + pub ai_provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, +} + +impl AiModel { + #[must_use] + pub fn new(name: impl Into, ai_provider: impl Into) -> Self { + Self { + name: name.into(), + ai_provider: ai_provider.into(), + version: None, + uid: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ai_model_serialization() { + let model = AiModel::new("claude-3-haiku", "anthropic"); + let json = serde_json::to_value(&model).unwrap(); + assert_eq!(json["name"], "claude-3-haiku"); + assert_eq!(json["ai_provider"], "anthropic"); + assert!(json.get("version").is_none()); + assert!(json.get("uid").is_none()); + } + + #[test] + fn test_ai_model_roundtrip() { + let model = AiModel { + name: "gpt-4o".to_string(), + ai_provider: "openai".to_string(), + version: Some("2024-05-13".to_string()), + uid: Some("model-123".to_string()), + }; + let json = serde_json::to_string(&model).unwrap(); + let deserialized: AiModel = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "gpt-4o"); + assert_eq!(deserialized.ai_provider, "openai"); + assert_eq!(deserialized.version.as_deref(), Some("2024-05-13")); + assert_eq!(deserialized.uid.as_deref(), Some("model-123")); + } +} diff --git a/crates/openshell-ocsf/src/objects/api.rs b/crates/openshell-ocsf/src/objects/api.rs new file mode 100644 index 0000000000..a4c85b19bc --- /dev/null +++ b/crates/openshell-ocsf/src/objects/api.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF `api` object for API Activity [6003] events. + +use serde::{Deserialize, Serialize}; + +/// The API object describes the API request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Api { + /// The API operation (e.g., "POST /v1/messages"). + pub operation: String, + /// The API version (e.g., "v1"). + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +impl Api { + #[must_use] + pub fn new(operation: impl Into) -> Self { + Self { + operation: operation.into(), + version: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_serialization() { + let api = Api::new("POST /v1/messages"); + let json = serde_json::to_value(&api).unwrap(); + assert_eq!(json["operation"], "POST /v1/messages"); + assert!(json.get("version").is_none()); + } +} diff --git a/crates/openshell-ocsf/src/objects/metadata.rs b/crates/openshell-ocsf/src/objects/metadata.rs index 0ffc8e15c2..060f13ac6a 100644 --- a/crates/openshell-ocsf/src/objects/metadata.rs +++ b/crates/openshell-ocsf/src/objects/metadata.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; /// OCSF Metadata object — event provenance and schema info. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Metadata { - /// OCSF schema version (e.g., "1.7.0"). + /// OCSF schema version (e.g., "1.8.0"). pub version: String, /// The product that generated the event. @@ -60,7 +60,7 @@ mod tests { #[test] fn test_metadata_serialization() { let metadata = Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec!["security_control".to_string(), "container".to_string()], uid: Some("sandbox-abc123".to_string()), @@ -68,7 +68,7 @@ mod tests { }; let json = serde_json::to_value(&metadata).unwrap(); - assert_eq!(json["version"], "1.7.0"); + assert_eq!(json["version"], "1.8.0"); assert_eq!(json["product"]["name"], "OpenShell Sandbox Supervisor"); assert_eq!(json["product"]["vendor_name"], "OpenShell"); assert!(json.get("log_source").is_none()); @@ -77,7 +77,7 @@ mod tests { #[test] fn test_metadata_with_log_source() { let metadata = Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec![], uid: None, diff --git a/crates/openshell-ocsf/src/objects/mod.rs b/crates/openshell-ocsf/src/objects/mod.rs index 8493c19d36..764d27f801 100644 --- a/crates/openshell-ocsf/src/objects/mod.rs +++ b/crates/openshell-ocsf/src/objects/mod.rs @@ -1,8 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OCSF v1.7.0 object types. +//! OCSF v1.8.0 object types. +mod ai_model; +mod api; mod attack; mod connection; mod container; @@ -14,6 +16,8 @@ mod http; mod metadata; mod process; +pub use ai_model::AiModel; +pub use api::Api; pub use attack::{Attack, Tactic, Technique}; pub use connection::ConnectionInfo; pub use container::{Container, Image}; diff --git a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs index caa94ca8d8..c07cd64b53 100644 --- a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs +++ b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs @@ -82,7 +82,7 @@ mod tests { "Other", SeverityId::Informational, Metadata { - version: "1.7.0".to_string(), + version: "1.8.0".to_string(), product: Product::openshell_sandbox("0.1.0"), profiles: vec![], uid: None, diff --git a/crates/openshell-ocsf/src/validation/schema.rs b/crates/openshell-ocsf/src/validation/schema.rs index f1160ecdbf..ee21619ba0 100644 --- a/crates/openshell-ocsf/src/validation/schema.rs +++ b/crates/openshell-ocsf/src/validation/schema.rs @@ -14,7 +14,7 @@ use std::fs; #[must_use] pub fn load_class_schema(class: &str) -> Value { let path = format!( - "{}/schemas/ocsf/v1.7.0/classes/{class}.json", + "{}/schemas/ocsf/v1.8.0/classes/{class}.json", env!("CARGO_MANIFEST_DIR") ); let data = @@ -30,7 +30,7 @@ pub fn load_class_schema(class: &str) -> Value { #[must_use] pub fn load_object_schema(object: &str) -> Value { let path = format!( - "{}/schemas/ocsf/v1.7.0/objects/{object}.json", + "{}/schemas/ocsf/v1.8.0/objects/{object}.json", env!("CARGO_MANIFEST_DIR") ); let data = @@ -43,15 +43,28 @@ pub fn load_object_schema(object: &str) -> Value { /// The OCSF schema stores attributes as an object where each key is a field name /// and the value contains a `requirement` field. pub fn validate_required_fields(event: &Value, schema: &Value) { - if let Some(attrs) = schema.get("attributes").and_then(|a| a.as_object()) { - for (name, def) in attrs { - if def.get("requirement").and_then(|r| r.as_str()) == Some("required") { - assert!( - event.get(name).is_some(), - "Missing required field '{name}' in OCSF event. Event keys: {:?}", - event.as_object().map(|o| o.keys().collect::>()) - ); - } + let attrs = match schema.get("attributes") { + Some(Value::Object(map)) => map + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + Some(Value::Array(arr)) => arr + .iter() + .filter_map(|item| item.as_object()) + .flat_map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone()))) + .collect::>(), + _ => return, + }; + + for (name, def) in &attrs { + let is_required = def.get("requirement").and_then(|r| r.as_str()) == Some("required"); + let is_profile_field = def.get("profile").is_some() || def.get("profiles").is_some(); + if is_required && !is_profile_field { + assert!( + event.get(name).is_some(), + "Missing required field '{name}' in OCSF event. Event keys: {:?}", + event.as_object().map(|o| o.keys().collect::>()) + ); } } } diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs new file mode 100644 index 0000000000..e7d37c9036 --- /dev/null +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `OcsfEvent` JSON round-trip fidelity. +//! +//! `Serialize` is written by hand and `Deserialize` dispatches on `class_uid` +//! into independent per-variant paths, so a field can serialize correctly and +//! still be dropped or rejected on the way back in. + +use std::net::{IpAddr, Ipv4Addr}; + +use openshell_ocsf::{ + ActionId, ActivityId, AiModel, ApiActivityBuilder, AppLifecycleBuilder, Attack, AuthTypeId, + BaseEventBuilder, ConfidenceId, ConfigStateChangeBuilder, ConnectionInfo, + DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpActivityBuilder, HttpMethod, + HttpRequest, HttpResponse, LaunchTypeId, NetworkActivityBuilder, OcsfEvent, Process, + ProcessActivityBuilder, RiskLevelId, SandboxContext, SecurityLevelId, SeverityId, + SshActivityBuilder, StateId, StatusId, Url, +}; + +fn ctx() -> SandboxContext { + SandboxContext { + sandbox_id: "sb-7f3a9c2e14b8".to_string(), + sandbox_name: "agent-workspace-01".to_string(), + container_image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + hostname: "openshell-sb-7f3a9c2e14b8".to_string(), + product_version: "0.42.1".to_string(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 8888, + } +} + +/// Assert an event survives `to_json -> from_value -> to_json` unchanged. +fn assert_round_trips(label: &str, event: &OcsfEvent) { + let json = event.to_json().expect("serialize"); + + let decoded: OcsfEvent = serde_json::from_value(json.clone()) + .unwrap_or_else(|e| panic!("{label}: deserialize failed: {e}\njson: {json}")); + + let reserialized = decoded.to_json().expect("re-serialize"); + assert_eq!( + json, reserialized, + "{label}: JSON changed across round trip" + ); +} + +#[test] +fn network_activity_round_trips() { + let event = NetworkActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .activity_name("Open") + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain("api.example.com", 443)) + .src_endpoint_addr(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 51234) + .actor_process( + Process::new("/usr/bin/curl", 4711) + .with_cmd_line("curl -sS https://api.example.com") + .with_parent(Process::new("/bin/bash", 4700)), + ) + .firewall_rule("default-deny-egress", "opa") + .connection_info(ConnectionInfo::new("tcp")) + .observation_point(3) + .status_detail("blocked by egress policy") + .log_source("/dev/kmsg") + .message("CONNECT denied api.example.com:443") + .unmapped("policy_version", 42) + .unmapped("engine", "opa") + .build(); + + assert_round_trips("network_activity", &event); +} + +#[test] +fn http_activity_round_trips() { + let event = HttpActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::new("https", "api.example.com", "/v1/items", 443)), + }) + .http_response(HttpResponse { code: 201 }) + .src_endpoint(Endpoint::from_ip( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), + 51235, + )) + .dst_endpoint(Endpoint::from_domain("api.example.com", 443)) + .actor_process(Process::new("/usr/bin/node", 4712)) + .firewall_rule("allow-api", "l7") + .status_detail("allowed by L7 rule") + .message("POST /v1/items 201") + .unmapped("l7_decision", "allow") + .build(); + + assert_round_trips("http_activity", &event); +} + +#[test] +fn ssh_activity_round_trips() { + let event = SshActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .auth_type(AuthTypeId::Other, "publickey-nonce") + .protocol_ver("SSH-2.0-OpenSSH_9.6") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .src_endpoint_addr(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 9)), 44321) + .dst_endpoint(Endpoint::from_domain("sandbox.local", 22)) + .message("ssh session accepted") + .build(); + + assert_round_trips("ssh_activity", &event); +} + +#[test] +fn process_activity_round_trips() { + let event = ProcessActivityBuilder::new(&ctx()) + .activity(ActivityId::Open) + .process( + Process::new("/usr/bin/python3", 4713) + .with_cmd_line("python3 -m pytest") + .with_parent(Process::new("/bin/sh", 4701)), + ) + .launch_type(LaunchTypeId::Other) + .exit_code(0) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message("process started") + .build(); + + assert_round_trips("process_activity", &event); +} + +#[test] +fn detection_finding_round_trips() { + let event = DetectionFindingBuilder::new(&ctx()) + .finding_info( + FindingInfo::new("finding-001", "Sandbox bypass attempt") + .with_desc("Process attempted to reach the host network directly"), + ) + .severity(SeverityId::High) + .is_alert(true) + .confidence(ConfidenceId::High) + .risk_level(RiskLevelId::High) + .attack(Attack::mitre( + "T1046", + "Network Service Discovery", + "TA0007", + "Discovery", + )) + .evidence_pairs(&[("dst_host", "169.254.169.254"), ("dst_port", "80")]) + .evidence("binary", "/usr/bin/curl") + .remediation("Tighten the egress policy for this sandbox") + .log_source("/dev/kmsg") + .message("bypass attempt detected") + .unmapped("detector", "bypass_monitor") + .build(); + + assert_round_trips("detection_finding", &event); +} + +#[test] +fn application_lifecycle_round_trips() { + let event = AppLifecycleBuilder::new(&ctx()) + .activity(ActivityId::Open) + .severity(SeverityId::Informational) + .message("supervisor started") + .build(); + + assert_round_trips("application_lifecycle", &event); +} + +#[test] +fn device_config_state_change_round_trips() { + let event = ConfigStateChangeBuilder::new(&ctx()) + .state(StateId::Other, "policy-loaded") + .security_level(SecurityLevelId::Secure) + .prev_security_level(SecurityLevelId::AtRisk) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message("policy reloaded") + .unmapped("policy_version", 7) + .build(); + + assert_round_trips("device_config_state_change", &event); +} + +#[test] +fn api_activity_round_trips() { + let event = ApiActivityBuilder::new(&ctx(), "chat.completions") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::new("https", "inference.local", "/v1/chat", 443)), + }) + .dst_endpoint(Endpoint::from_domain("inference.local", 443)) + .ai_model(AiModel::new("llama-3.1-8b", "nvidia")) + .message("inference request routed") + .unmapped("route", "system") + .build(); + + assert_round_trips("api_activity", &event); +} + +#[test] +fn base_event_round_trips() { + let event = BaseEventBuilder::new(&ctx()) + .activity_name("custom activity") + .severity(SeverityId::Low) + .message("base event") + .unmapped("detail", "value") + .build(); + + assert_round_trips("base_event", &event); +} diff --git a/crates/openshell-otel-test-support/Cargo.toml b/crates/openshell-otel-test-support/Cargo.toml new file mode 100644 index 0000000000..48be77e1ce --- /dev/null +++ b/crates/openshell-otel-test-support/Cargo.toml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-otel-test-support" +description = "Shared OTLP collector fixture for OpenShell tracing tests" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-otel = { path = "../openshell-otel" } +opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic", "trace"] } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tonic = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-otel-test-support/src/lib.rs b/crates/openshell-otel-test-support/src/lib.rs new file mode 100644 index 0000000000..5d6db481a6 --- /dev/null +++ b/crates/openshell-otel-test-support/src/lib.rs @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared OTLP collector fixture for `OpenShell` tracing tests. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use opentelemetry_proto::tonic::collector::trace::v1::{ + ExportTraceServiceRequest, ExportTraceServiceResponse, + trace_service_server::{TraceService, TraceServiceServer}, +}; +use opentelemetry_proto::tonic::trace::v1::Span; + +/// Serialize tracing tests and keep their callsites enabled process-wide. +pub async fn tracing_test_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + static INITIALIZED: std::sync::LazyLock<()> = std::sync::LazyLock::new(|| { + tracing::subscriber::set_global_default(tracing_subscriber::registry()) + .expect("test tracing subscriber installs once"); + }); + + let guard = LOCK.lock().await; + std::sync::LazyLock::force(&INITIALIZED); + guard +} + +/// Verify one compute-driver descriptor exports spans and shared resources. +pub async fn assert_compute_driver_tracing( + descriptor: openshell_otel::ComputeDriverTracing, + service_version: &'static str, + span_name: &'static str, +) { + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = tracing_test_lock().await; + let collector = OtlpTestServer::start().await; + let (provider, error) = descriptor.provider_for( + Some(collector.endpoint()), + service_version, + Some("test-gateway"), + Some(descriptor.compute_driver()), + ); + assert!(error.is_none(), "valid OTLP endpoint should configure"); + let provider = provider.expect("provider"); + let subscriber = tracing_subscriber::registry().with(descriptor.layer(&provider)); + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!("driver.test", otel.name = span_name); + drop(span.enter()); + drop(span); + }); + provider.force_flush().unwrap(); + collector.wait_for_export().await; + provider.shutdown().unwrap(); + let received = collector.shutdown().await; + + assert!(received.spans.iter().any(|span| span.name == span_name)); + assert_eq!(received.gateway_names, ["test-gateway"]); + assert_eq!(received.compute_drivers, [descriptor.compute_driver()]); + assert!( + received + .service_names + .iter() + .any(|name| name == descriptor.service_name()) + ); +} + +#[derive(Clone, Debug, Default)] +pub struct ReceivedTraces { + pub spans: Vec, + pub service_names: Vec, + pub gateway_names: Vec, + pub compute_drivers: Vec, +} + +#[derive(Clone)] +struct Collector { + received: Arc>, + exported: Arc, +} + +#[tonic::async_trait] +impl TraceService for Collector { + async fn export( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + { + let mut received = self + .received + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for resource_span in request.into_inner().resource_spans { + if let Some(resource) = resource_span.resource { + for attribute in resource.attributes { + let Some(value) = attribute.value.and_then(|value| value.value) else { + continue; + }; + let opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue( + value, + ) = value + else { + continue; + }; + match attribute.key.as_str() { + "service.name" => received.service_names.push(value), + "openshell.gateway.name" => received.gateway_names.push(value), + "openshell.gateway.compute_driver" => { + received.compute_drivers.push(value); + } + _ => {} + } + } + } + for scope_span in resource_span.scope_spans { + received.spans.extend(scope_span.spans); + } + } + } + self.exported.notify_one(); + Ok(tonic::Response::new(ExportTraceServiceResponse::default())) + } +} + +/// Loopback OTLP/gRPC server that captures exported spans and resources. +pub struct OtlpTestServer { + endpoint: String, + received: Arc>, + exported: Arc, + shutdown: tokio::sync::oneshot::Sender<()>, + task: tokio::task::JoinHandle>, +} + +impl OtlpTestServer { + pub async fn start() -> Self { + let received = Arc::new(Mutex::new(ReceivedTraces::default())); + let exported = Arc::new(tokio::sync::Notify::new()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("OTLP test collector should bind a loopback listener"); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let collector = Collector { + received: Arc::clone(&received), + exported: Arc::clone(&exported), + }; + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(TraceServiceServer::new(collector)) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::TcpListenerStream::new(listener), + async { + let _ = shutdown_rx.await; + }, + ) + .await + }); + Self { + endpoint, + received, + exported, + shutdown, + task, + } + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub async fn wait_for_export(&self) { + tokio::time::timeout(Duration::from_secs(5), self.exported.notified()) + .await + .expect("OTLP export should complete"); + } + + pub async fn shutdown(self) -> ReceivedTraces { + self.shutdown + .send(()) + .expect("OTLP test collector should still be running"); + tokio::time::timeout(Duration::from_secs(5), self.task) + .await + .expect("OTLP test collector shutdown should not deadlock") + .expect("OTLP test collector task should not panic") + .expect("OTLP test collector should shut down cleanly"); + self.received + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} diff --git a/crates/openshell-otel/BUILD.bazel b/crates/openshell-otel/BUILD.bazel deleted file mode 100644 index a73e0c8934..0000000000 --- a/crates/openshell-otel/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-otel", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-otel_test", - crate = ":openshell-otel", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-otel", - ":openshell-otel_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-otel/Cargo.toml b/crates/openshell-otel/Cargo.toml index b53a716819..e4b5de770d 100644 --- a/crates/openshell-otel/Cargo.toml +++ b/crates/openshell-otel/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +futures = { workspace = true } http = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } @@ -23,6 +24,7 @@ tonic = { workspace = true } tower-http = { workspace = true } [dev-dependencies] +opentelemetry_sdk = { workspace = true, features = ["testing"] } tokio = { workspace = true } [lints] diff --git a/crates/openshell-otel/src/driver.rs b/crates/openshell-otel/src/driver.rs new file mode 100644 index 0000000000..c28f5df55d --- /dev/null +++ b/crates/openshell-otel/src/driver.rs @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared compute-driver tracing setup and in-process RPC instrumentation. + +use std::future::Future; +use std::pin::Pin; + +use futures::Stream; +use tracing::Instrument as _; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; +use tracing_subscriber::registry::LookupSpan; + +use crate::{OtlpTraceConfig, SdkTracerProvider, ServiceName, SetupError}; + +/// Target used by every in-process compute-driver RPC boundary. +pub const IN_PROCESS_COMPUTE_DRIVER_TARGET: &str = "openshell_otel::in_process_compute_driver"; + +/// Define the calling driver crate's [`ComputeDriverTracing`] identity from +/// its Cargo package and crate names. +#[macro_export] +macro_rules! compute_driver_tracing { + () => { + $crate::ComputeDriverTracing::new(env!("CARGO_PKG_NAME"), env!("CARGO_CRATE_NAME")) + }; +} + +/// Tracing identity and layer factory for one compute-driver service. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ComputeDriverTracing { + service_name: &'static str, + crate_target_prefix: &'static str, +} + +impl ComputeDriverTracing { + /// `crate_target_prefix` must be the driver's Rust crate name; it routes + /// that crate's spans to the driver's provider. Prefer + /// [`compute_driver_tracing!`](crate::compute_driver_tracing). + #[must_use] + pub const fn new(service_name: &'static str, crate_target_prefix: &'static str) -> Self { + Self { + service_name, + crate_target_prefix, + } + } + + #[must_use] + pub const fn service_name(self) -> &'static str { + self.service_name + } + + /// Canonical compute-driver name derived from `service.name`. + #[must_use] + pub fn compute_driver(self) -> &'static str { + self.service_name + .strip_prefix("openshell-driver-") + .unwrap_or(self.service_name) + } + + #[must_use] + pub const fn in_process_target(self) -> &'static str { + IN_PROCESS_COMPUTE_DRIVER_TARGET + } + + /// Targets routed to this driver's provider when it runs in-process. + #[must_use] + pub const fn in_process_targets(self) -> [&'static str; 2] { + [self.in_process_target(), self.crate_target_prefix] + } + + #[must_use] + pub fn provider_for( + self, + endpoint: Option<&str>, + service_version: &'static str, + gateway_name: Option<&str>, + compute_driver: Option<&str>, + ) -> (Option, Option) { + crate::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(self.service_name), + service_version: Some(service_version), + resource_attributes: crate::gateway_resource_attributes(gateway_name, compute_driver), + })) + } + + pub fn layer(self, provider: &SdkTracerProvider) -> crate::OtlpLayer + where + S: tracing::Subscriber + for<'span> LookupSpan<'span>, + { + crate::layer(provider, self.service_name) + } + + pub fn in_process_layer(self, provider: &SdkTracerProvider) -> crate::TargetOtlpLayer + where + S: tracing::Subscriber + for<'span> LookupSpan<'span>, + { + crate::layer_for_target_prefixes(provider, self.service_name, self.in_process_targets()) + } +} + +/// Optional in-process server boundary tracing for a compute-driver service. +#[derive(Debug, Clone, Copy)] +pub struct InProcessRpcTracer { + enabled: bool, +} + +impl InProcessRpcTracer { + #[must_use] + pub const fn disabled() -> Self { + Self { enabled: false } + } + + #[must_use] + pub const fn enabled() -> Self { + Self { enabled: true } + } + + fn span(self, rpc: crate::ComputeDriverRpc) -> Option { + self.enabled.then(|| { + tracing::info_span!( + target: IN_PROCESS_COMPUTE_DRIVER_TARGET, + "driver_rpc", + otel.name = rpc.operation, + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.system.name = "grpc", + rpc.method = rpc.operation, + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, + ) + }) + } + + pub async fn trace( + self, + rpc: crate::ComputeDriverRpc, + future: impl Future>, + ) -> Result { + let Some(span) = self.span(rpc) else { + return future.await; + }; + let result = future.instrument(span.clone()).await; + record_result(&span, &result); + result + } + + pub async fn trace_stream( + self, + rpc: crate::ComputeDriverRpc, + future: impl Future, tonic::Status>>, + ) -> Result, tonic::Status> + where + T: 'static, + { + let Some(span) = self.span(rpc) else { + return future.await; + }; + match future.instrument(span.clone()).await { + Ok(stream) => Ok(Box::pin(crate::TracedGrpcStream::new(stream, span))), + Err(status) => { + crate::record_grpc_status(&span, status.code()); + Err(status) + } + } + } +} + +pub type BoxGrpcStream = Pin> + Send + 'static>>; + +fn record_result(span: &tracing::Span, result: &Result) { + crate::record_grpc_status( + span, + result + .as_ref() + .map_or_else(tonic::Status::code, |_| tonic::Code::Ok), + ); +} + +/// Owns and shuts down a standalone compute driver's tracer provider. +pub struct DriverTracingHandle { + provider: Option, +} + +/// Named inputs for standalone compute-driver tracing installation. +#[derive(Debug, Clone, Copy)] +pub struct DriverTracingConfig<'a> { + pub endpoint: Option<&'a str>, + pub gateway_name: Option<&'a str>, + pub service_version: &'static str, + pub log_level: &'a str, +} + +impl Drop for DriverTracingHandle { + fn drop(&mut self) { + if let Some(provider) = &self.provider + && let Err(error) = provider.shutdown() + { + tracing::warn!(%error, "OTLP tracer provider shutdown failed"); + } + } +} + +/// Install standalone compute-driver logging and OTLP tracing. +#[must_use] +pub fn install_driver_tracing( + descriptor: ComputeDriverTracing, + config: DriverTracingConfig<'_>, +) -> DriverTracingHandle { + let (provider, setup_error) = descriptor.provider_for( + config.endpoint, + config.service_version, + config.gateway_name, + Some(descriptor.compute_driver()), + ); + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(config.log_level)), + ) + .with(tracing_subscriber::fmt::layer()) + .with(provider.as_ref().map(|provider| descriptor.layer(provider))) + .init(); + + if let Some(error) = setup_error { + tracing::error!(%error, "OTLP exporting could not be started"); + } else if let Some(endpoint) = config.endpoint { + tracing::info!(endpoint, "OTLP exporting enabled"); + } + + DriverTracingHandle { provider } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracing_macro_derives_identity_from_the_calling_crate() { + const TRACING: ComputeDriverTracing = crate::compute_driver_tracing!(); + + assert_eq!(TRACING.service_name(), "openshell-otel"); + assert_eq!(TRACING.in_process_targets()[1], "openshell_otel"); + } + + #[tokio::test] + async fn disabled_stream_tracing_returns_the_original_box() { + let stream: BoxGrpcStream<()> = Box::pin(futures::stream::empty()); + let original = std::ptr::from_ref(stream.as_ref().get_ref()); + + let returned = InProcessRpcTracer::disabled() + .trace_stream(crate::rpc::WATCH_SANDBOXES, async move { Ok(stream) }) + .await + .unwrap(); + + assert!(std::ptr::eq(original, returned.as_ref().get_ref())); + } +} diff --git a/crates/openshell-otel/src/grpc.rs b/crates/openshell-otel/src/grpc.rs index 9eb7caa488..65d498eccb 100644 --- a/crates/openshell-otel/src/grpc.rs +++ b/crates/openshell-otel/src/grpc.rs @@ -3,9 +3,249 @@ //! Shared gRPC tracing adapters. +use futures::Stream; +use http::Request; +use opentelemetry::propagation::TextMapPropagator as _; +use opentelemetry::trace::TraceContextExt as _; +use opentelemetry_sdk::propagation::TraceContextPropagator; use tower_http::classify::GrpcFailureClass; -use tower_http::trace::OnFailure; +use tower_http::trace::{GrpcMakeClassifier, MakeSpan, OnEos, OnFailure, OnResponse, TraceLayer}; use tracing::Span; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; + +/// Keeps a gRPC server span alive for a response stream and records its outcome. +pub struct TracedGrpcStream { + inner: S, + span: Span, + finished: bool, +} + +impl TracedGrpcStream { + #[must_use] + pub fn new(inner: S, span: Span) -> Self { + Self { + inner, + span, + finished: false, + } + } +} + +impl Stream for TracedGrpcStream +where + S: Stream> + Unpin, +{ + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + let span = this.span.clone(); + let _entered = span.enter(); + let result = std::pin::Pin::new(&mut this.inner).poll_next(cx); + if !this.finished { + match &result { + std::task::Poll::Ready(Some(Err(status))) => { + record_grpc_status(&this.span, status.code()); + this.finished = true; + } + std::task::Poll::Ready(None) => { + record_grpc_status(&this.span, tonic::Code::Ok); + this.finished = true; + } + std::task::Poll::Pending | std::task::Poll::Ready(Some(Ok(_))) => {} + } + } + result + } +} + +pub const COMPUTE_DRIVER_RPC_SERVICE: &str = "openshell.compute.v1.ComputeDriver"; + +/// Low-cardinality semantic-convention identity for a compute-driver RPC. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ComputeDriverRpc { + pub service: &'static str, + pub method: &'static str, + pub operation: &'static str, +} + +impl ComputeDriverRpc { + const fn new(method: &'static str, operation: &'static str) -> Self { + Self { + service: COMPUTE_DRIVER_RPC_SERVICE, + method, + operation, + } + } +} + +/// Typed identities for every RPC in the generated compute-driver service. +pub mod rpc { + use super::ComputeDriverRpc; + + pub const AUTHENTICATE_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( + "AuthenticateSandbox", + "openshell.compute.v1.ComputeDriver/AuthenticateSandbox", + ); + pub const GET_CAPABILITIES: ComputeDriverRpc = ComputeDriverRpc::new( + "GetCapabilities", + "openshell.compute.v1.ComputeDriver/GetCapabilities", + ); + pub const GET_GATEWAY_LISTENER_REQUIREMENTS: ComputeDriverRpc = ComputeDriverRpc::new( + "GetGatewayListenerRequirements", + "openshell.compute.v1.ComputeDriver/GetGatewayListenerRequirements", + ); + pub const VALIDATE_SANDBOX_CREATE: ComputeDriverRpc = ComputeDriverRpc::new( + "ValidateSandboxCreate", + "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate", + ); + pub const CREATE_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( + "CreateSandbox", + "openshell.compute.v1.ComputeDriver/CreateSandbox", + ); + pub const GET_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( + "GetSandbox", + "openshell.compute.v1.ComputeDriver/GetSandbox", + ); + pub const LIST_SANDBOXES: ComputeDriverRpc = ComputeDriverRpc::new( + "ListSandboxes", + "openshell.compute.v1.ComputeDriver/ListSandboxes", + ); + pub const STOP_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( + "StopSandbox", + "openshell.compute.v1.ComputeDriver/StopSandbox", + ); + pub const START_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( + "StartSandbox", + "openshell.compute.v1.ComputeDriver/StartSandbox", + ); + pub const DELETE_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( + "DeleteSandbox", + "openshell.compute.v1.ComputeDriver/DeleteSandbox", + ); + pub const WATCH_SANDBOXES: ComputeDriverRpc = ComputeDriverRpc::new( + "WatchSandboxes", + "openshell.compute.v1.ComputeDriver/WatchSandboxes", + ); + pub const ENSURE_WORKSPACE: ComputeDriverRpc = ComputeDriverRpc::new( + "EnsureWorkspace", + "openshell.compute.v1.ComputeDriver/EnsureWorkspace", + ); + pub const DELETE_WORKSPACE: ComputeDriverRpc = ComputeDriverRpc::new( + "DeleteWorkspace", + "openshell.compute.v1.ComputeDriver/DeleteWorkspace", + ); +} + +/// Trace every inbound compute-driver RPC at the tonic service boundary. +pub fn compute_driver_rpc_layer() -> TraceLayer< + GrpcMakeClassifier, + ComputeDriverRpcSpan, + (), + RecordGrpcStatus, + (), + RecordGrpcStatus, + RecordGrpcFailure, +> { + TraceLayer::new_for_grpc() + .make_span_with(ComputeDriverRpcSpan) + .on_request(()) + .on_response(RecordGrpcStatus) + .on_body_chunk(()) + .on_eos(RecordGrpcStatus) + .on_failure(RecordGrpcFailure) +} + +/// Creates a bounded server span for an inbound compute-driver request. +#[derive(Debug, Clone, Copy)] +pub struct ComputeDriverRpcSpan; + +impl MakeSpan for ComputeDriverRpcSpan { + fn make_span(&mut self, request: &Request) -> Span { + let rpc = compute_driver_rpc_operation(request.uri().path()); + let span = tracing::info_span!( + "driver_rpc", + otel.name = rpc.map_or("grpc", |rpc| rpc.operation), + otel.kind = "server", + otel.status_code = tracing::field::Empty, + rpc.system.name = "grpc", + rpc.method = rpc.map_or("_OTHER", |rpc| rpc.operation), + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, + ); + let parent = TraceContextPropagator::new().extract_with_context( + &opentelemetry::Context::new(), + &crate::HeaderMapExtractor::new(request.headers()), + ); + if parent.span().span_context().is_valid() { + let _ = span.set_parent(parent); + } + span + } +} + +/// Maps the generated compute-driver RPC schema to low-cardinality span names. +pub fn compute_driver_rpc_operation(path: &str) -> Option { + match path.rsplit('/').next() { + Some("AuthenticateSandbox") => Some(rpc::AUTHENTICATE_SANDBOX), + Some("GetCapabilities") => Some(rpc::GET_CAPABILITIES), + Some("GetGatewayListenerRequirements") => Some(rpc::GET_GATEWAY_LISTENER_REQUIREMENTS), + Some("ValidateSandboxCreate") => Some(rpc::VALIDATE_SANDBOX_CREATE), + Some("CreateSandbox") => Some(rpc::CREATE_SANDBOX), + Some("GetSandbox") => Some(rpc::GET_SANDBOX), + Some("ListSandboxes") => Some(rpc::LIST_SANDBOXES), + Some("StopSandbox") => Some(rpc::STOP_SANDBOX), + Some("StartSandbox") => Some(rpc::START_SANDBOX), + Some("DeleteSandbox") => Some(rpc::DELETE_SANDBOX), + Some("WatchSandboxes") => Some(rpc::WATCH_SANDBOXES), + Some("EnsureWorkspace") => Some(rpc::ENSURE_WORKSPACE), + Some("DeleteWorkspace") => Some(rpc::DELETE_WORKSPACE), + _ => None, + } +} + +/// Return the stable OpenTelemetry spelling for a gRPC response status. +#[must_use] +pub const fn grpc_status_code_name(code: tonic::Code) -> &'static str { + match code { + tonic::Code::Ok => "OK", + tonic::Code::Cancelled => "CANCELLED", + tonic::Code::Unknown => "UNKNOWN", + tonic::Code::InvalidArgument => "INVALID_ARGUMENT", + tonic::Code::DeadlineExceeded => "DEADLINE_EXCEEDED", + tonic::Code::NotFound => "NOT_FOUND", + tonic::Code::AlreadyExists => "ALREADY_EXISTS", + tonic::Code::PermissionDenied => "PERMISSION_DENIED", + tonic::Code::ResourceExhausted => "RESOURCE_EXHAUSTED", + tonic::Code::FailedPrecondition => "FAILED_PRECONDITION", + tonic::Code::Aborted => "ABORTED", + tonic::Code::OutOfRange => "OUT_OF_RANGE", + tonic::Code::Unimplemented => "UNIMPLEMENTED", + tonic::Code::Internal => "INTERNAL", + tonic::Code::Unavailable => "UNAVAILABLE", + tonic::Code::DataLoss => "DATA_LOSS", + tonic::Code::Unauthenticated => "UNAUTHENTICATED", + } +} + +/// Records a complete gRPC outcome on `span`. +/// +/// Non-OK codes also set `otel.status_code` to `ERROR` and record the code as +/// `error.type`. OK codes leave the OpenTelemetry span status unset. +/// +/// The span must declare `otel.status_code`, `rpc.response.status_code`, and +/// `error.type` when it is created because `tracing` ignores undeclared fields. +pub fn record_grpc_status(span: &Span, code: tonic::Code) { + let code_name = grpc_status_code_name(code); + span.record("rpc.response.status_code", code_name); + if code != tonic::Code::Ok { + crate::mark_error(span); + span.record("error.type", code_name); + } +} /// Records a non-OK gRPC outcome on the request span. #[derive(Debug, Clone, Copy)] @@ -18,9 +258,242 @@ impl OnFailure for RecordGrpcFailure { _latency: std::time::Duration, span: &Span, ) { - crate::mark_error(span); if let GrpcFailureClass::Code(code) = failure { - span.record("rpc.grpc.status_code", code.get()); + let code = tonic::Code::from_i32(code.get()); + record_grpc_status(span, code); + } else { + crate::mark_error(span); } } } + +/// Records a gRPC status from response headers or trailers. +#[derive(Debug, Clone, Copy)] +pub struct RecordGrpcStatus; + +impl RecordGrpcStatus { + fn record(headers: &http::HeaderMap, span: &Span) { + let Some(code) = headers + .get("grpc-status") + .and_then(|status| status.to_str().ok()) + .and_then(|status| status.parse::().ok()) + else { + return; + }; + let code = tonic::Code::from_i32(code); + record_grpc_status(span, code); + } +} + +impl OnResponse for RecordGrpcStatus { + fn on_response(self, response: &http::Response, _latency: std::time::Duration, span: &Span) { + Self::record(response.headers(), span); + } +} + +impl OnEos for RecordGrpcStatus { + fn on_eos( + self, + trailers: Option<&http::HeaderMap>, + _stream_duration: std::time::Duration, + span: &Span, + ) { + if let Some(trailers) = trailers { + Self::record(trailers, span); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing_subscriber::layer::SubscriberExt as _; + + #[test] + fn compute_driver_rpc_names_are_explicitly_mapped_and_schema_bounded() { + for rpc in [ + rpc::AUTHENTICATE_SANDBOX, + rpc::GET_CAPABILITIES, + rpc::GET_GATEWAY_LISTENER_REQUIREMENTS, + rpc::VALIDATE_SANDBOX_CREATE, + rpc::CREATE_SANDBOX, + rpc::GET_SANDBOX, + rpc::LIST_SANDBOXES, + rpc::STOP_SANDBOX, + rpc::START_SANDBOX, + rpc::DELETE_SANDBOX, + rpc::WATCH_SANDBOXES, + rpc::ENSURE_WORKSPACE, + rpc::DELETE_WORKSPACE, + ] { + assert_eq!(rpc.operation, format!("{}/{}", rpc.service, rpc.method)); + assert_eq!( + compute_driver_rpc_operation(&format!("/{}/{}", rpc.service, rpc.method)), + Some(rpc) + ); + } + assert_eq!( + compute_driver_rpc_operation( + "/openshell.compute.v1.ComputeDriver/AttackerControlled12345" + ), + None + ); + } + + #[test] + fn traced_stream_records_an_observed_cancelled_status() { + let _tracing_lock = crate::test_lock(); + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(crate::layer(&provider, "test")); + + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!( + "watch", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, + ); + let inner = + futures::stream::iter([Err::<(), _>(tonic::Status::cancelled("peer cancelled"))]); + let mut stream = TracedGrpcStream::new(inner, span); + let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref()); + let result = std::pin::Pin::new(&mut stream).poll_next(&mut cx); + assert!(matches!( + result, + std::task::Poll::Ready(Some(Err(status))) + if status.code() == tonic::Code::Cancelled + )); + }); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + assert!(matches!( + spans[0].status, + opentelemetry::trace::Status::Error { .. } + )); + assert!(spans[0].attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "CANCELLED" + })); + provider.shutdown().unwrap(); + } + + #[test] + fn grpc_status_records_and_marks_non_ok_trailer_status() { + let _tracing_lock = crate::test_lock(); + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(crate::layer(&provider, "test")); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", http::HeaderValue::from_static("13")); + + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!( + "rpc", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, + ); + RecordGrpcStatus.on_eos(Some(&trailers), std::time::Duration::ZERO, &span); + }); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + assert!(matches!( + spans[0].status, + opentelemetry::trace::Status::Error { .. } + )); + assert!(spans[0].attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "INTERNAL" + })); + assert!(spans[0].attributes.iter().any(|attribute| { + attribute.key.as_str() == "error.type" && attribute.value.to_string() == "INTERNAL" + })); + provider.shutdown().unwrap(); + } + + #[test] + fn grpc_status_records_ok_without_marking_an_error() { + let _tracing_lock = crate::test_lock(); + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(crate::layer(&provider, "test")); + + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!( + "rpc", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, + ); + record_grpc_status(&span, tonic::Code::Ok); + }); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + assert!(matches!( + spans[0].status, + opentelemetry::trace::Status::Unset + )); + assert!(spans[0].attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "OK" + })); + assert!( + spans[0] + .attributes + .iter() + .all(|attribute| attribute.key.as_str() != "error.type") + ); + provider.shutdown().unwrap(); + } + + #[test] + fn grpc_status_records_header_status_without_eos_overwrite() { + let _tracing_lock = crate::test_lock(); + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(crate::layer(&provider, "test")); + let response = http::Response::builder() + .header("grpc-status", "13") + .body(()) + .unwrap(); + + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!( + "rpc", + otel.status_code = tracing::field::Empty, + rpc.response.status_code = tracing::field::Empty, + ); + RecordGrpcStatus.on_response(&response, std::time::Duration::ZERO, &span); + RecordGrpcStatus.on_eos(None, std::time::Duration::ZERO, &span); + }); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + assert!(matches!( + spans[0].status, + opentelemetry::trace::Status::Error { .. } + )); + assert!(spans[0].attributes.iter().any(|attribute| { + attribute.key.as_str() == "rpc.response.status_code" + && attribute.value.to_string() == "INTERNAL" + })); + provider.shutdown().unwrap(); + } +} diff --git a/crates/openshell-otel/src/lib.rs b/crates/openshell-otel/src/lib.rs index 1536f66f24..7a9162ab92 100644 --- a/crates/openshell-otel/src/lib.rs +++ b/crates/openshell-otel/src/lib.rs @@ -3,11 +3,23 @@ //! Shared OpenTelemetry trace export support for `OpenShell` services. +mod driver; mod grpc; mod propagation; -pub use grpc::RecordGrpcFailure; -pub use propagation::{HeaderMapExtractor, MetadataMapInjector, TraceContextInterceptor}; +pub use driver::{ + BoxGrpcStream, ComputeDriverTracing, DriverTracingConfig, DriverTracingHandle, + IN_PROCESS_COMPUTE_DRIVER_TARGET, InProcessRpcTracer, install_driver_tracing, +}; + +pub use grpc::{ + COMPUTE_DRIVER_RPC_SERVICE, ComputeDriverRpc, ComputeDriverRpcSpan, RecordGrpcFailure, + RecordGrpcStatus, TracedGrpcStream, compute_driver_rpc_layer, compute_driver_rpc_operation, + grpc_status_code_name, record_grpc_status, rpc, +}; +pub use propagation::{ + HeaderMapExtractor, MetadataMapInjector, TraceContextInterceptor, current_trace_context_carrier, +}; use opentelemetry::KeyValue; use opentelemetry::trace::TracerProvider as _; @@ -18,10 +30,32 @@ pub use opentelemetry_sdk::trace::SdkTracerProvider; use tracing::Subscriber; use tracing_opentelemetry::OpenTelemetryLayer; use tracing_subscriber::Layer as _; +use tracing_subscriber::layer::{Context, Filter}; use tracing_subscriber::registry::LookupSpan; const SDK_UNKNOWN_SERVICE_PREFIX: &str = "unknown_service"; +/// Build the resource attributes shared by gateway and compute-driver providers. +pub fn gateway_resource_attributes( + gateway_name: Option<&str>, + compute_driver: Option<&str>, +) -> Vec { + let mut attributes = Vec::new(); + if let Some(name) = gateway_name.map(str::trim).filter(|name| !name.is_empty()) { + attributes.push(KeyValue::new("openshell.gateway.name", name.to_string())); + } + if let Some(driver) = compute_driver + .map(str::trim) + .filter(|driver| !driver.is_empty()) + { + attributes.push(KeyValue::new( + "openshell.gateway.compute_driver", + driver.to_string(), + )); + } + attributes +} + /// Mark `span` as failed. /// /// The field must be declared on the span at creation because `tracing` drops @@ -193,6 +227,27 @@ pub type OtlpLayer = tracing_subscriber::filter::Filtered< S, >; +pub type TargetOtlpLayer = + tracing_subscriber::filter::Filtered, TargetPrefixFilter, S>; + +#[derive(Debug, Clone)] +pub struct TargetPrefixFilter { + prefixes: Vec<&'static str>, + include: bool, +} + +impl Filter for TargetPrefixFilter { + fn enabled(&self, metadata: &tracing::Metadata<'_>, _ctx: &Context<'_, S>) -> bool { + let matches_prefix = self + .prefixes + .iter() + .any(|prefix| metadata.target().starts_with(prefix)); + metadata.is_span() + && !metadata.target().starts_with("opentelemetry") + && (matches_prefix == self.include) + } +} + /// Build a tracing layer that exports spans and excludes exporter callsites. pub fn layer(provider: &SdkTracerProvider, instrumentation_scope: &'static str) -> OtlpLayer where @@ -205,6 +260,75 @@ where })) } +/// Build a tracing layer that exports only spans from `target_prefix`. +pub fn layer_for_target_prefix( + provider: &SdkTracerProvider, + instrumentation_scope: &'static str, + target_prefix: &'static str, +) -> TargetOtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + layer_for_target_prefixes(provider, instrumentation_scope, [target_prefix]) +} + +/// Build a tracing layer that exports only spans from `target_prefixes`. +pub fn layer_for_target_prefixes( + provider: &SdkTracerProvider, + instrumentation_scope: &'static str, + target_prefixes: impl IntoIterator, +) -> TargetOtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + tracing_opentelemetry::layer() + .with_tracer(provider.tracer(instrumentation_scope)) + .with_filter(TargetPrefixFilter { + prefixes: target_prefixes.into_iter().collect(), + include: true, + }) +} + +/// Build a tracing layer that excludes spans from `target_prefix`. +/// +/// `None` excludes no application spans. +pub fn layer_excluding_target_prefix( + provider: &SdkTracerProvider, + instrumentation_scope: &'static str, + target_prefix: Option<&'static str>, +) -> TargetOtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + layer_excluding_target_prefixes(provider, instrumentation_scope, target_prefix) +} + +/// Build a tracing layer that excludes spans from `target_prefixes`. +/// +/// An empty iterator excludes no application spans. +pub fn layer_excluding_target_prefixes( + provider: &SdkTracerProvider, + instrumentation_scope: &'static str, + target_prefixes: impl IntoIterator, +) -> TargetOtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + tracing_opentelemetry::layer() + .with_tracer(provider.tracer(instrumentation_scope)) + .with_filter(TargetPrefixFilter { + prefixes: target_prefixes.into_iter().collect(), + include: false, + }) +} + +#[cfg(test)] +pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { use super::*; @@ -213,6 +337,7 @@ mod tests { fn error_status_guard_marks_only_unfinished_results() { use tracing_subscriber::layer::SubscriberExt as _; + let _tracing_lock = test_lock(); let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); let provider = SdkTracerProvider::builder() .with_simple_exporter(exporter.clone()) @@ -255,6 +380,89 @@ mod tests { assert_eq!(succeeded.status, opentelemetry::trace::Status::Unset); } + #[test] + fn target_scoped_layers_partition_in_process_driver_spans() { + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = test_lock(); + let gateway_exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let gateway_provider = SdkTracerProvider::builder() + .with_simple_exporter(gateway_exporter.clone()) + .build(); + let driver_exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let driver_provider = SdkTracerProvider::builder() + .with_simple_exporter(driver_exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(layer_excluding_target_prefix( + &gateway_provider, + "gateway", + Some("driver_target"), + )) + .with(layer_for_target_prefix( + &driver_provider, + "driver", + "driver_target", + )); + + tracing::subscriber::with_default(subscriber, || { + let gateway = tracing::info_span!(target: "gateway_target", "gateway.span"); + let _entered = gateway.enter(); + drop(tracing::info_span!(target: "driver_target::operation", "driver.span")); + }); + + gateway_provider.force_flush().unwrap(); + driver_provider.force_flush().unwrap(); + let gateway_spans = gateway_exporter.get_finished_spans().unwrap(); + let driver_spans = driver_exporter.get_finished_spans().unwrap(); + assert_eq!( + gateway_spans + .iter() + .map(|span| span.name.as_ref()) + .collect::>(), + ["gateway.span"] + ); + assert_eq!( + driver_spans + .iter() + .map(|span| span.name.as_ref()) + .collect::>(), + ["driver.span"] + ); + assert_eq!( + driver_spans[0].span_context.trace_id(), + gateway_spans[0].span_context.trace_id() + ); + assert_eq!( + driver_spans[0].parent_span_id, + gateway_spans[0].span_context.span_id() + ); + } + + #[test] + fn excluding_no_target_prefix_exports_all_application_spans() { + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = test_lock(); + let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry() + .with(layer_excluding_target_prefix(&provider, "gateway", None)); + + tracing::subscriber::with_default(subscriber, || { + drop(tracing::info_span!(target: "\0driver_target", "nul-prefixed.span")); + drop(tracing::info_span!(target: "gateway_target", "gateway.span")); + }); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 2); + assert!(spans.iter().any(|span| span.name == "nul-prefixed.span")); + assert!(spans.iter().any(|span| span.name == "gateway.span")); + } + #[test] fn resource_uses_fixed_service_identity_and_custom_attributes() { let resource = resource_for(&OtlpTraceConfig { diff --git a/crates/openshell-otel/src/propagation.rs b/crates/openshell-otel/src/propagation.rs index 8deb2ffe20..c6e51a0a6b 100644 --- a/crates/openshell-otel/src/propagation.rs +++ b/crates/openshell-otel/src/propagation.rs @@ -3,6 +3,8 @@ //! W3C trace-context propagation for HTTP and tonic transports. +use std::collections::BTreeMap; + use http::HeaderMap; use opentelemetry::propagation::{Extractor, Injector, TextMapPropagator}; use opentelemetry_sdk::propagation::TraceContextPropagator; @@ -52,6 +54,27 @@ impl Injector for MetadataMapInjector<'_> { } } +#[derive(Debug)] +struct TraceContextMapInjector<'a>(&'a mut BTreeMap); + +impl Injector for TraceContextMapInjector<'_> { + fn set(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } +} + +/// Serialize the active span's W3C propagation fields into a string map. +/// +/// Returns `None` when the current span has no valid OpenTelemetry context. +#[must_use] +pub fn current_trace_context_carrier() -> Option> { + let context = tracing::Span::current().context(); + let mut carrier = BTreeMap::new(); + TraceContextPropagator::new() + .inject_context(&context, &mut TraceContextMapInjector(&mut carrier)); + carrier.contains_key("traceparent").then_some(carrier) +} + /// Injects the active W3C trace context into an outbound tonic request. #[derive(Debug, Clone, Copy)] pub struct TraceContextInterceptor; diff --git a/crates/openshell-policy/BUILD.bazel b/crates/openshell-policy/BUILD.bazel deleted file mode 100644 index e3a2ad14b6..0000000000 --- a/crates/openshell-policy/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-policy", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-policy_test", - crate = ":openshell-policy", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-policy", - ":openshell-policy_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index b69da8d2b5..cb32584186 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +hickory-proto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index 05f8744855..2c0b1b1944 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -163,6 +163,12 @@ fn overlapping_ports(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec Vec { let mut conflicts = Vec::new(); + push_conflict( + &mut conflicts, + "transparent_tcp_eligible", + &is_explicit_tcp(&left.protocol), + &is_explicit_tcp(&right.protocol), + ); push_conflict( &mut conflicts, "tls", @@ -175,22 +181,23 @@ fn connection_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec< &normalized_strings(&left.allowed_ips), &normalized_strings(&right.allowed_ips), ); - push_conflict( - &mut conflicts, - "advisor_proposed", - &left.advisor_proposed, - &right.advisor_proposed, - ); conflicts } +fn is_explicit_tcp(protocol: &str) -> bool { + protocol.eq_ignore_ascii_case("tcp") +} + /// Keep request-pipeline ambiguity checks aligned with Rego's /// `endpoint_has_extended_config` predicate. Plain L4 endpoints authorize a /// destination but do not participate in endpoint-config selection, so they /// cannot compete with the single L7/connection-config endpoint selected for /// that request. fn endpoint_contributes_request_pipeline_metadata(endpoint: &NetworkEndpoint) -> bool { - !endpoint.protocol.is_empty() || !endpoint.allowed_ips.is_empty() || !endpoint.tls.is_empty() + (!endpoint.protocol.is_empty() && !endpoint.protocol.eq_ignore_ascii_case("tcp")) + || !endpoint.allowed_ips.is_empty() + || !endpoint.tls.is_empty() + || endpoint.credential_binding.is_some() } fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec { @@ -198,8 +205,8 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - push_conflict( &mut conflicts, "protocol", - &left.protocol.to_ascii_lowercase(), - &right.protocol.to_ascii_lowercase(), + &normalized_request_protocol(&left.protocol), + &normalized_request_protocol(&right.protocol), ); push_conflict( &mut conflicts, @@ -253,6 +260,20 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - &left.signing_region, &right.signing_region, ); + push_conflict( + &mut conflicts, + "credential_binding.provider", + &left + .credential_binding + .as_ref() + .map(|binding| binding.provider.as_str()) + .unwrap_or_default(), + &right + .credential_binding + .as_ref() + .map(|binding| binding.provider.as_str()) + .unwrap_or_default(), + ); if left.protocol.eq_ignore_ascii_case("graphql") && right.protocol.eq_ignore_ascii_case("graphql") @@ -283,6 +304,14 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - conflicts } +fn normalized_request_protocol(protocol: &str) -> String { + if protocol.eq_ignore_ascii_case("tcp") { + String::new() + } else { + protocol.to_ascii_lowercase() + } +} + fn websocket_graphql_policy(endpoint: &NetworkEndpoint) -> bool { let allow_rule_has_graphql_fields = endpoint.rules.iter().any(|rule| { rule.allow.as_ref().is_some_and(|allow| { @@ -752,6 +781,15 @@ mod tests { assert!(find_endpoint_ambiguities(&policy_with(left, right)).is_empty()); } + #[test] + fn advisor_provenance_does_not_make_endpoints_ambiguous() { + let explicit = endpoint("api.example.com", 443); + let mut proposed = explicit.clone(); + proposed.advisor_proposed = true; + + assert!(find_endpoint_ambiguities(&policy_with(explicit, proposed)).is_empty()); + } + #[test] fn plain_l4_endpoint_does_not_compete_with_l7_endpoint_metadata() { let left = endpoint("api.example.com", 443); @@ -896,6 +934,27 @@ mod tests { ); } + #[test] + fn credential_binding_conflicts_are_rejected_on_same_endpoint() { + let mut left = endpoint("api.example.com", 443); + left.credential_binding = Some(openshell_core::proto::NetworkCredentialBinding { + provider: "provider-a".to_string(), + }); + let mut right = endpoint("api.example.com", 443); + right.credential_binding = Some(openshell_core::proto::NetworkCredentialBinding { + provider: "provider-b".to_string(), + }); + + let ambiguities = find_endpoint_ambiguities(&policy_with(left, right)); + assert_eq!(ambiguities.len(), 1); + assert!( + ambiguities[0] + .conflicts + .iter() + .any(|field| field.contains("credential_binding.provider")) + ); + } + #[test] fn json_rpc_body_limit_is_compared_only_within_the_same_protocol() { let mut json_rpc = endpoint("api.example.com", 443); @@ -989,4 +1048,22 @@ mod tests { 1 ); } + + #[test] + fn explicit_tcp_and_omitted_protocol_are_ambiguous_for_native_tcp_eligibility() { + let mut explicit_tcp = endpoint("api.example.com", 443); + explicit_tcp.protocol = "tcp".to_string(); + explicit_tcp.tls = "skip".to_string(); + let mut omitted = endpoint("api.example.com", 443); + omitted.tls = "skip".to_string(); + + let ambiguities = find_endpoint_ambiguities(&policy_with(explicit_tcp, omitted)); + assert_eq!(ambiguities.len(), 1); + assert!( + ambiguities[0] + .conflicts + .iter() + .any(|conflict| conflict.contains("transparent_tcp_eligible")) + ); + } } diff --git a/crates/openshell-policy/src/l7_validate.rs b/crates/openshell-policy/src/l7_validate.rs index 18580fd2c1..d60491c4bb 100644 --- a/crates/openshell-policy/src/l7_validate.rs +++ b/crates/openshell-policy/src/l7_validate.rs @@ -41,6 +41,34 @@ impl L7Protocol { } } +/// Returns whether the authored protocol explicitly selects L4 TCP handling. +/// +/// `tcp` is intentionally not an [`L7Protocol`]. It is the explicit spelling +/// of the existing L4 behavior and does not enable request inspection. +pub fn is_explicit_tcp_protocol(protocol: &str) -> bool { + protocol.eq_ignore_ascii_case("tcp") +} + +/// Reject additional L7-only fields represented outside +/// [`L7EndpointFields`] by the runtime and provider-profile schemas. +/// +/// Callers pass only authored fields with a non-default value. Keeping the +/// diagnostic construction here ensures both activation paths use the same +/// explicit-TCP contract. +pub fn validate_explicit_tcp_additional_fields( + protocol: &str, + present_fields: &[&str], +) -> Vec { + if !is_explicit_tcp_protocol(protocol) || present_fields.is_empty() { + return Vec::new(); + } + + vec![format!( + "protocol tcp does not support L7-only fields: {}; remove those fields", + present_fields.join(", ") + )] +} + /// Fields extracted from an endpoint definition needed for L7 semantic /// validation. Both profile lint and the runtime validator construct this /// from their own data representation. @@ -78,17 +106,27 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec let mut errors = Vec::new(); let protocol = ep.protocol; let l7_protocol = L7Protocol::parse(protocol); + let explicit_tcp = is_explicit_tcp_protocol(protocol); let jsonrpc_family = l7_protocol.is_some_and(L7Protocol::is_jsonrpc_family); let is_mcp = matches!(l7_protocol, Some(L7Protocol::Mcp)); let is_jsonrpc = matches!(l7_protocol, Some(L7Protocol::JsonRpc)); // 1. Unknown protocol - if !protocol.is_empty() && l7_protocol.is_none() { + if !protocol.is_empty() && l7_protocol.is_none() && !explicit_tcp { errors.push(format!( - "unknown protocol '{protocol}' (expected rest, websocket, graphql, sql, json-rpc, or mcp)" + "unknown protocol '{protocol}' (expected tcp, rest, websocket, graphql, sql, json-rpc, or mcp)" )); } + // Explicit TCP is an L4 marker, not an inspection protocol. Reject L7 + // policy fields instead of silently ignoring them. + if explicit_tcp && (!ep.access.is_empty() || ep.has_rules || ep.has_deny_rules) { + errors.push( + "protocol tcp does not support access, rules, or deny_rules; remove those L7 fields" + .to_string(), + ); + } + // 2. rules + access mutually exclusive if ep.has_rules && !ep.access.is_empty() { errors.push("rules and access are mutually exclusive".to_string()); @@ -119,7 +157,7 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec // 5. Non-MCP, non-JSON-RPC protocol requires rules or access (JSON-RPC's // dedicated message is emitted by rule 4). - if !protocol.is_empty() && !is_mcp && !is_jsonrpc && !ep.has_rules && ep.access.is_empty() { + if l7_protocol.is_some() && !is_mcp && !is_jsonrpc && !ep.has_rules && ep.access.is_empty() { errors.push("protocol requires rules or access to define allowed traffic".to_string()); } @@ -141,7 +179,7 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec } // 8. deny_rules require protocol - if ep.has_deny_rules && protocol.is_empty() { + if ep.has_deny_rules && l7_protocol.is_none() { errors.push("deny_rules require protocol (L7 inspection must be enabled)".to_string()); } @@ -379,6 +417,51 @@ mod tests { assert!(errors.is_empty(), "expected no errors, got: {errors:?}"); } + #[test] + fn explicit_tcp_is_valid_without_l7_fields() { + let ep = L7EndpointFields { + protocol: "tcp", + access: "", + has_rules: false, + has_deny_rules: false, + rules_would_deny_all: false, + allow_all_known_mcp_methods: false, + }; + let errors = validate_l7_endpoint_semantics(&ep); + assert!(errors.is_empty(), "expected no errors, got: {errors:?}"); + assert!(is_explicit_tcp_protocol("TCP")); + assert_eq!(L7Protocol::parse("tcp"), None); + } + + #[test] + fn explicit_tcp_rejects_l7_fields() { + let ep = L7EndpointFields { + protocol: "tcp", + access: "full", + has_rules: false, + has_deny_rules: false, + rules_would_deny_all: false, + allow_all_known_mcp_methods: false, + }; + let errors = validate_l7_endpoint_semantics(&ep); + assert_eq!( + errors, + vec![ + "protocol tcp does not support access, rules, or deny_rules; remove those L7 fields" + ] + ); + } + + #[test] + fn explicit_tcp_rejects_additional_l7_fields() { + let errors = + validate_explicit_tcp_additional_fields("tcp", &["enforcement", "credential_signing"]); + + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("enforcement, credential_signing")); + assert!(validate_explicit_tcp_additional_fields("rest", &["enforcement"]).is_empty()); + } + #[test] fn l7_protocol_parse_known_variants() { assert_eq!(L7Protocol::parse("rest"), Some(L7Protocol::Rest)); diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index bbf7eadd62..5be8173e3e 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -16,12 +16,14 @@ mod middleware; use std::collections::{BTreeMap, HashMap}; use std::fmt; +use std::net::IpAddr; use std::path::Path; mod ambiguity; pub use ambiguity::{EndpointAmbiguity, find_endpoint_ambiguities}; +use hickory_proto::rr::Name; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, @@ -34,10 +36,13 @@ pub use compose::{ PROVIDER_RULE_NAME_PREFIX, ProviderPolicyLayer, compose_effective_policy, is_provider_rule_name, provider_rule_name, strip_provider_rule_names, }; -pub use l7_validate::{L7EndpointFields, L7Protocol, validate_l7_endpoint_semantics}; +pub use l7_validate::{ + L7EndpointFields, L7Protocol, validate_explicit_tcp_additional_fields, + validate_l7_endpoint_semantics, +}; pub use merge::{ - PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name, - merge_policy, policy_covers_rule, + PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, + canonicalize_advisor_add_rule, generated_rule_name, merge_policy, policy_covers_rule, }; pub use middleware::middleware_host_matches; pub use middleware::validate_json as validate_network_middleware_json; @@ -103,6 +108,10 @@ struct NetworkPolicyRuleDef { #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] +#[allow( + clippy::struct_excessive_bools, + reason = "Endpoint DTO mirrors independent policy schema toggles." +)] struct NetworkEndpointDef { #[serde(default, skip_serializing_if = "String::is_empty")] host: String, @@ -144,6 +153,10 @@ struct NetworkEndpointDef { /// placeholders before forwarding upstream. Defaults to false. #[serde(default, skip_serializing_if = "std::ops::Not::not")] request_body_credential_rewrite: bool, + /// Explicitly permits credentials on traffic paths that `OpenShell` cannot + /// inspect or rewrite. Defaults to false. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + allow_uninspected_credentials: bool, #[serde(default, skip_serializing_if = "String::is_empty")] persisted_queries: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -157,11 +170,19 @@ struct NetworkEndpointDef { #[serde(default, skip_serializing_if = "String::is_empty")] signing_region: String, #[serde(default, skip_serializing_if = "Option::is_none")] + credential_binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] json_rpc: Option, #[serde(default, skip_serializing_if = "Option::is_none")] mcp: Option, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkCredentialBindingDef { + provider: String, +} + // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. #[allow(clippy::trivially_copy_pass_by_ref)] fn is_zero(v: &u16) -> bool { @@ -737,6 +758,10 @@ fn to_proto(raw: PolicyFile) -> Result { allow_encoded_slash: e.allow_encoded_slash, websocket_credential_rewrite: e.websocket_credential_rewrite, request_body_credential_rewrite: e.request_body_credential_rewrite, + allow_uninspected_credentials: e.allow_uninspected_credentials, + // Provider credential provenance is derived by the + // gateway and cannot be authored in policy YAML. + provider_credentialed: false, // Advisor provenance is internal runtime state, not // a user-authored policy schema field. advisor_proposed: false, @@ -759,6 +784,11 @@ fn to_proto(raw: PolicyFile) -> Result { credential_signing: e.credential_signing, signing_service: e.signing_service, signing_region: e.signing_region, + credential_binding: e.credential_binding.map(|binding| { + openshell_core::proto::NetworkCredentialBinding { + provider: binding.provider, + } + }), json_rpc_max_body_bytes: json_rpc_max_body_bytes(&e.json_rpc, &e.mcp), mcp: mcp_options(&e.mcp), } @@ -888,6 +918,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { allow_encoded_slash: e.allow_encoded_slash, websocket_credential_rewrite: e.websocket_credential_rewrite, request_body_credential_rewrite: e.request_body_credential_rewrite, + allow_uninspected_credentials: e.allow_uninspected_credentials, persisted_queries: e.persisted_queries.clone(), graphql_persisted_queries: e .graphql_persisted_queries @@ -907,6 +938,11 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { credential_signing: e.credential_signing.clone(), signing_service: e.signing_service.clone(), signing_region: e.signing_region.clone(), + credential_binding: e.credential_binding.as_ref().map(|binding| { + NetworkCredentialBindingDef { + provider: binding.provider.clone(), + } + }), json_rpc, mcp, } @@ -941,13 +977,24 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { // Sandbox UID/GID constants // --------------------------------------------------------------------------- -/// Minimum accepted UID for sandbox process identity. -/// UIDs below this are reserved for system users and are rejected. -pub const MIN_SANDBOX_UID: u32 = 1000; +/// Minimum accepted UID/GID for sandbox workload identity. +/// +/// Linux reserves only identity `0` for root. Non-root system identities are +/// valid workload identities when selected explicitly by the operator. +pub const MIN_SANDBOX_UID: u32 = 1; -/// Maximum accepted UID for sandbox process identity. -/// UIDs above this exceed typical OS limits and are rejected. -pub const MAX_SANDBOX_UID: u32 = 2_000_000_000; +/// Maximum accepted UID/GID for sandbox workload identity. +/// +/// `u32::MAX` represents an invalid or unchanged identity in Linux APIs and +/// POSIX ACLs, so the largest usable workload identity is one less. +pub const MAX_SANDBOX_UID: u32 = u32::MAX - 1; + +/// Minimum UID for the Kubernetes network proxy identity. +/// +/// The proxy UID is exempt from the pod egress fence, so it remains in a +/// dedicated infrastructure range even though workload identities may use +/// non-root system IDs. +pub const MIN_SANDBOX_PROXY_UID: u32 = 1000; /// The literal string value accepted as a valid sandbox user/group name. const SANDBOX_NAME: &str = "sandbox"; @@ -959,8 +1006,8 @@ const SANDBOX_NAME: &str = "sandbox"; /// /// Rejects: /// - The empty string (represents an omitted policy field) -/// - UID 0 or values below `MIN_SANDBOX_UID` -/// - Values above `MAX_SANDBOX_UID` +/// - UID/GID 0 (root) +/// - `u32::MAX`, the invalid identity sentinel /// - Non-numeric strings other than `"sandbox"` (e.g. `"root"`, `"nobody"`) pub fn is_valid_sandbox_identity(value: &str) -> bool { if value == SANDBOX_NAME { @@ -1044,7 +1091,7 @@ pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result SandboxPolicy { "/etc".into(), "/var/log".into(), ], - read_write: vec!["/sandbox".into(), "/tmp".into(), "/dev/null".into()], + read_write: vec!["/tmp".into(), "/dev/null".into()], }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), @@ -1127,6 +1174,26 @@ pub enum PolicyViolation { TooManyPaths { count: usize }, /// A network endpoint uses a TLD wildcard (e.g. `*.com`). TldWildcard { policy_name: String, host: String }, + /// A network endpoint has no hostname. + MissingEndpointHost { policy_name: String }, + /// An explicit TCP endpoint has no DNS hostname. + MissingTcpEndpointHost { policy_name: String }, + /// An explicit TCP endpoint uses an IP literal instead of a DNS hostname. + TcpEndpointIpLiteral { policy_name: String, host: String }, + /// An explicit TCP endpoint has a hostname that policy DNS cannot resolve. + InvalidTcpEndpointHost { + policy_name: String, + host: String, + reason: String, + }, + /// A network endpoint has no effective destination port. + MissingEndpointPort { policy_name: String, host: String }, + /// A network endpoint contains a port outside the TCP/UDP range. + InvalidEndpointPort { + policy_name: String, + host: String, + port: u32, + }, /// A network endpoint uses a wildcard shape that does not match runtime semantics. InvalidHostWildcard { policy_name: String, host: String }, /// `credential_signing` is set but `signing_service` is missing. @@ -1139,6 +1206,12 @@ pub enum PolicyViolation { }, /// `credential_signing` and `request_body_credential_rewrite` are both set. CredentialSigningWithBodyRewrite { policy_name: String, host: String }, + /// An endpoint contains a deterministic L7 semantic error. + InvalidL7Endpoint { + policy_name: String, + endpoint_index: usize, + reason: String, + }, /// A middleware configuration is structurally invalid. InvalidMiddlewareConfig { name: String, reason: String }, /// Too many middleware configurations are attached to one policy. @@ -1196,6 +1269,50 @@ impl fmt::Display for PolicyViolation { use subdomain wildcards like '*.example.com' instead" ) } + Self::MissingEndpointHost { policy_name } => { + write!( + f, + "network policy '{policy_name}': endpoint host must not be empty unless allowed_ips constrains a non-TCP proxy endpoint" + ) + } + Self::MissingTcpEndpointHost { policy_name } => { + write!( + f, + "network policy '{policy_name}': protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy" + ) + } + Self::TcpEndpointIpLiteral { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': protocol tcp endpoint '{host}' must use a DNS hostname, not an IP literal; direct IP connections bypass policy DNS and are blocked" + ) + } + Self::InvalidTcpEndpointHost { + policy_name, + host, + reason, + } => { + write!( + f, + "network policy '{policy_name}': protocol tcp endpoint has invalid DNS host selector '{host}': {reason}" + ) + } + Self::MissingEndpointPort { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' must declare at least one port" + ) + } + Self::InvalidEndpointPort { + policy_name, + host, + port, + } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' has invalid port {port}; expected 1..=65535" + ) + } Self::InvalidHostWildcard { policy_name, host } => { write!( f, @@ -1229,6 +1346,14 @@ impl fmt::Display for PolicyViolation { and request_body_credential_rewrite set; these options are mutually exclusive" ) } + Self::InvalidL7Endpoint { + policy_name, + endpoint_index, + reason, + } => write!( + f, + "network policy '{policy_name}': endpoint {endpoint_index} has invalid L7 configuration: {reason}" + ), Self::InvalidMiddlewareConfig { name, reason } => { write!(f, "middleware config '{name}' is invalid: {reason}") } @@ -1364,7 +1489,50 @@ pub fn validate_sandbox_policy( } else { rule.name.clone() }; - for ep in &rule.endpoints { + for (endpoint_index, ep) in rule.endpoints.iter().enumerate() { + let explicit_tcp = l7_validate::is_explicit_tcp_protocol(&ep.protocol); + if ep.host.trim().is_empty() && explicit_tcp { + violations.push(PolicyViolation::MissingTcpEndpointHost { + policy_name: name.clone(), + }); + } else if ep.host.trim().is_empty() && ep.allowed_ips.is_empty() { + violations.push(PolicyViolation::MissingEndpointHost { + policy_name: name.clone(), + }); + } else if explicit_tcp { + if ep.host.parse::().is_ok() { + violations.push(PolicyViolation::TcpEndpointIpLiteral { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } else if let Err(reason) = validate_tcp_dns_host_selector(&ep.host) { + violations.push(PolicyViolation::InvalidTcpEndpointHost { + policy_name: name.clone(), + host: ep.host.clone(), + reason, + }); + } + } + let effective_ports: Vec = if ep.ports.is_empty() { + (ep.port != 0).then_some(ep.port).into_iter().collect() + } else { + ep.ports.clone() + }; + if effective_ports.is_empty() { + violations.push(PolicyViolation::MissingEndpointPort { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } + for port in effective_ports { + if !(1..=u16::MAX.into()).contains(&port) { + violations.push(PolicyViolation::InvalidEndpointPort { + policy_name: name.clone(), + host: ep.host.clone(), + port, + }); + } + } if ep.host.contains('*') && (ep.host.starts_with("*.") || ep.host.starts_with("**.")) { let label_count = ep.host.split('.').count(); if label_count <= 2 { @@ -1404,6 +1572,127 @@ pub fn validate_sandbox_policy( host: ep.host.clone(), }); } + + let rules_would_deny_all = !ep.rules.is_empty() + && ep.rules.iter().all(|rule| { + rule.allow.as_ref().is_none_or(|allow| { + allow.method.is_empty() + && allow.path.is_empty() + && allow.command.is_empty() + && allow.operation_type.is_empty() + && allow.operation_name.is_empty() + && allow.fields.is_empty() + && allow.params.is_empty() + }) + }); + let fields = L7EndpointFields { + protocol: &ep.protocol, + access: &ep.access, + has_rules: !ep.rules.is_empty(), + has_deny_rules: !ep.deny_rules.is_empty(), + rules_would_deny_all, + allow_all_known_mcp_methods: ep + .mcp + .as_ref() + .and_then(|mcp| mcp.allow_all_known_mcp_methods) + .unwrap_or(false), + }; + let mut l7_errors = validate_l7_endpoint_semantics(&fields); + let mut explicit_tcp_fields = Vec::new(); + if !ep.enforcement.is_empty() { + explicit_tcp_fields.push("enforcement"); + } + if !ep.path.is_empty() { + explicit_tcp_fields.push("path"); + } + if ep.allow_encoded_slash { + explicit_tcp_fields.push("allow_encoded_slash"); + } + if ep.websocket_credential_rewrite { + explicit_tcp_fields.push("websocket_credential_rewrite"); + } + if ep.request_body_credential_rewrite { + explicit_tcp_fields.push("request_body_credential_rewrite"); + } + if !ep.persisted_queries.is_empty() { + explicit_tcp_fields.push("persisted_queries"); + } + if !ep.graphql_persisted_queries.is_empty() { + explicit_tcp_fields.push("graphql_persisted_queries"); + } + if ep.graphql_max_body_bytes > 0 { + explicit_tcp_fields.push("graphql_max_body_bytes"); + } + if ep.json_rpc_max_body_bytes > 0 { + explicit_tcp_fields.push("json_rpc_max_body_bytes"); + } + if ep.mcp.is_some() { + explicit_tcp_fields.push("mcp"); + } + l7_errors.extend(validate_explicit_tcp_additional_fields( + &ep.protocol, + &explicit_tcp_fields, + )); + if !ep.path.is_empty() && !ep.path.starts_with('/') && ep.path != "**" { + l7_errors.push("path must start with '/' or be '**'".to_string()); + } + if !ep.persisted_queries.is_empty() + && !matches!(ep.persisted_queries.as_str(), "deny" | "allow_registered") + { + l7_errors.push(format!( + "persisted_queries must be 'deny' or 'allow_registered', got '{}'", + ep.persisted_queries + )); + } + if ep.protocol == "sql" && ep.enforcement == "enforce" { + l7_errors.push( + "SQL enforcement requires full SQL parsing; use enforcement: audit".to_string(), + ); + } + if ep.mcp.is_some() && ep.protocol != "mcp" { + l7_errors.push("mcp options are only valid for protocol mcp".to_string()); + } + if ep.protocol == "graphql" { + for (rule_index, rule) in ep.rules.iter().enumerate() { + let operation_type = rule + .allow + .as_ref() + .map(|allow| allow.operation_type.as_str()) + .unwrap_or_default(); + if !matches!(operation_type, "query" | "mutation" | "subscription") { + l7_errors.push(format!( + "rules[{rule_index}].allow.operation_type must be query, mutation, or subscription" + )); + } + } + for (rule_index, rule) in ep.deny_rules.iter().enumerate() { + if !matches!( + rule.operation_type.as_str(), + "query" | "mutation" | "subscription" + ) { + l7_errors.push(format!( + "deny_rules[{rule_index}].operation_type must be query, mutation, or subscription" + )); + } + } + for (key, operation) in &ep.graphql_persisted_queries { + if !matches!( + operation.operation_type.as_str(), + "query" | "mutation" | "subscription" + ) { + l7_errors.push(format!( + "graphql_persisted_queries[{key}].operation_type must be query, mutation, or subscription" + )); + } + } + } + violations.extend(l7_errors.into_iter().map(|reason| { + PolicyViolation::InvalidL7Endpoint { + policy_name: name.clone(), + endpoint_index, + reason, + } + })); } } @@ -1435,6 +1724,39 @@ fn host_wildcard_shape_invalid(host: &str) -> bool { .any(|label| label.contains("**") || (label.contains('*') && label != "*")) } +/// Validate that an explicit-TCP host selector can produce names accepted by +/// policy DNS. Wildcards are replaced with a representative DNS label before +/// parsing because the authored selector itself is not a concrete DNS name. +fn validate_tcp_dns_host_selector(host: &str) -> std::result::Result<(), String> { + if host.trim() != host { + return Err("leading or trailing whitespace is not allowed".to_string()); + } + if host.ends_with('.') { + return Err("omit the trailing DNS root dot".to_string()); + } + + openshell_core::host_pattern::HostSelector::new(&[host.to_string()], &[])?; + + let representative = host + .split('.') + .map(|label| { + if label == "**" { + "x".to_string() + } else { + label.replace('*', "x") + } + }) + .collect::>() + .join("."); + let absolute = format!("{representative}."); + let parsed = Name::from_ascii(&absolute) + .map_err(|error| format!("selector cannot represent a valid DNS name: {error}"))?; + if parsed.is_root() { + return Err("DNS root is not a destination hostname".to_string()); + } + Ok(()) +} + /// Truncate a string for safe inclusion in error messages. fn truncate_for_display(s: &str) -> String { if s.len() <= 80 { @@ -1654,8 +1976,8 @@ network_policies: "read_only should contain /usr" ); assert!( - fs.read_write.iter().any(|p| p == "/sandbox"), - "read_write should contain /sandbox" + !fs.read_write.iter().any(|p| p == "/sandbox"), + "the workspace should be granted through include_workdir, not a literal /sandbox path" ); assert!( fs.read_write.iter().any(|p| p == "/tmp"), @@ -2355,6 +2677,209 @@ network_policies: assert!(validate_sandbox_policy(&policy).is_ok()); } + #[test] + fn validate_rejects_yaml_tcp_endpoint_without_host_or_port() { + let policy = parse_sandbox_policy( + r#" +version: 1 +network_policies: + invalid: + endpoints: + - host: "" + protocol: tcp +"#, + ) + .expect("policy syntax should parse before semantic validation"); + + let violations = validate_sandbox_policy(&policy).expect_err("endpoint is incomplete"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingTcpEndpointHost { policy_name } if policy_name == "invalid" + ))); + assert!(violations.iter().any(|violation| { + violation.to_string().contains( + "protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy", + ) + })); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingEndpointPort { policy_name, .. } if policy_name == "invalid" + ))); + } + + #[test] + fn validate_accepts_hostless_allowed_ips_for_non_tcp_proxy_endpoint() { + let policy = parse_sandbox_policy( + r" +version: 1 +network_policies: + legacy-proxy: + endpoints: + - port: 9443 + allowed_ips: + - 10.0.5.0/24 +", + ) + .expect("policy syntax should parse before semantic validation"); + + validate_sandbox_policy(&policy) + .expect("hostless allowed_ips remains valid for non-TCP proxy endpoints"); + } + + #[test] + fn validate_rejects_hostless_allowed_ips_for_explicit_tcp() { + let policy = parse_sandbox_policy( + r" +version: 1 +network_policies: + native-tcp: + endpoints: + - port: 6379 + protocol: tcp + allowed_ips: + - 10.0.5.0/24 +", + ) + .expect("policy syntax should parse before semantic validation"); + + let violations = + validate_sandbox_policy(&policy).expect_err("transparent TCP requires a DNS hostname"); + let violation = violations + .iter() + .find(|violation| matches!(violation, PolicyViolation::MissingTcpEndpointHost { .. })) + .expect("missing TCP hostname violation"); + assert_eq!( + violation.to_string(), + "network policy 'native-tcp': protocol tcp requires a DNS hostname; hostless allowed_ips endpoints are supported only by the forward proxy" + ); + } + + #[test] + fn validate_rejects_ip_literal_hosts_for_explicit_tcp() { + for host in ["192.0.2.10", "2001:db8::10"] { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "native-tcp".into(), + NetworkPolicyRule { + name: "native-tcp".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + port: 6379, + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy) + .expect_err("transparent TCP must reject direct IP destinations"); + let violation = violations + .iter() + .find(|violation| matches!(violation, PolicyViolation::TcpEndpointIpLiteral { .. })) + .expect("TCP IP-literal violation"); + assert!( + violation + .to_string() + .contains("direct IP connections bypass policy DNS and are blocked"), + "unexpected diagnostic: {violation}" + ); + } + } + + #[test] + fn validate_rejects_malformed_dns_selectors_for_explicit_tcp() { + for (host, expected_reason) in [ + (" db.example.com", "leading or trailing whitespace"), + ("db.example.com.", "omit the trailing DNS root dot"), + ("db..example.com", "empty DNS labels"), + ("bad name.example.com", "whitespace"), + ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "cannot represent a valid DNS name", + ), + ] { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "native-tcp".into(), + NetworkPolicyRule { + name: "native-tcp".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + port: 6379, + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy) + .expect_err("malformed transparent TCP hostname must be rejected"); + let violation = violations + .iter() + .find(|violation| { + matches!(violation, PolicyViolation::InvalidTcpEndpointHost { .. }) + }) + .expect("invalid TCP hostname violation"); + assert!( + violation.to_string().contains(expected_reason), + "expected {expected_reason:?} in diagnostic for {host:?}, got {violation}" + ); + } + } + + #[test] + fn validate_rejects_raw_endpoint_zero_and_out_of_range_ports() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "invalid".into(), + NetworkPolicyRule { + name: "invalid".into(), + endpoints: vec![NetworkEndpoint { + host: "database.example.com".into(), + ports: vec![0, u32::from(u16::MAX) + 1], + protocol: "tcp".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("ports are invalid"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::InvalidEndpointPort { port: 0, .. } + ))); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::InvalidEndpointPort { port: 65_536, .. } + ))); + } + + #[test] + fn validate_rejects_raw_endpoint_without_effective_port() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "invalid".into(), + NetworkPolicyRule { + name: "invalid".into(), + endpoints: vec![NetworkEndpoint { + host: "database.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("port is missing"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MissingEndpointPort { policy_name, host } + if policy_name == "invalid" && host == "database.example.com" + ))); + } + #[test] fn validate_accepts_empty_process() { let policy = SandboxPolicy { @@ -2768,7 +3293,11 @@ network_policies: } #[test] - fn valid_identity_accepts_numeric_uid_in_range() { + fn valid_identity_accepts_non_root_numeric_uid() { + assert!(is_valid_sandbox_identity("1")); + assert!(is_valid_sandbox_identity("30")); + assert!(is_valid_sandbox_identity("500")); + assert!(is_valid_sandbox_identity("999")); assert!(is_valid_sandbox_identity("1000")); assert!(is_valid_sandbox_identity("50000")); assert!(is_valid_sandbox_identity("1000660000")); @@ -2786,14 +3315,7 @@ network_policies: } #[test] - fn valid_identity_rejects_system_uids_below_min() { - assert!(!is_valid_sandbox_identity("999")); - assert!(!is_valid_sandbox_identity("100")); - assert!(!is_valid_sandbox_identity("1")); - } - - #[test] - fn valid_identity_rejects_uid_above_max() { + fn valid_identity_rejects_invalid_uid_sentinel() { assert!(!is_valid_sandbox_identity( &MAX_SANDBOX_UID.saturating_add(1).to_string() )); @@ -2846,20 +3368,13 @@ network_policies: } #[test] - fn validate_rejects_uid_out_of_range_low() { + fn validate_accepts_non_root_system_uid() { let mut policy = restrictive_default_policy(); policy.process = Some(ProcessPolicy { run_as_user: "500".into(), - run_as_group: "sandbox".into(), + run_as_group: "30".into(), }); - let violations = validate_sandbox_policy(&policy).unwrap_err(); - assert!(violations.iter().any(|v| matches!( - v, - PolicyViolation::InvalidProcessIdentity { - field: "run_as_user", - .. - } - ))); + assert!(validate_sandbox_policy(&policy).is_ok()); } #[test] @@ -3004,6 +3519,37 @@ network_policies: assert_eq!(ep1.path, ep2.path); } + #[test] + fn round_trip_preserves_endpoint_credential_binding() { + let yaml = r" +version: 1 +network_policies: + gcp_storage: + endpoints: + - host: storage.googleapis.com + port: 443 + protocol: rest + credential_binding: + provider: work-gcp +"; + + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let endpoint = &proto1.network_policies["gcp_storage"].endpoints[0]; + assert_eq!( + endpoint + .credential_binding + .as_ref() + .map(|binding| binding.provider.as_str()), + Some("work-gcp") + ); + + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + assert_eq!(proto1, proto2); + assert!(yaml_out.contains("credential_binding:")); + assert!(yaml_out.contains("provider: work-gcp")); + } + #[test] fn round_trip_preserves_multi_port() { let yaml = r" @@ -3437,6 +3983,32 @@ network_policies: assert!(yaml_out.contains("request_body_credential_rewrite: true")); } + #[test] + fn round_trip_preserves_allow_uninspected_credentials() { + let yaml = r" +version: 1 +network_policies: + vendor_api: + endpoints: + - host: api.vendor.example + port: 443 + tls: skip + allow_uninspected_credentials: true +"; + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + + let ep = &proto2.network_policies["vendor_api"].endpoints[0]; + assert!(ep.allow_uninspected_credentials); + assert!( + !ep.provider_credentialed, + "provider provenance must not be authorable from policy YAML" + ); + assert!(yaml_out.contains("allow_uninspected_credentials: true")); + assert!(!yaml_out.contains("provider_credentialed")); + } + #[test] fn websocket_credential_rewrite_defaults_false() { let yaml = r" @@ -3455,6 +4027,8 @@ network_policies: let ep = &proto.network_policies["gateway"].endpoints[0]; assert!(!ep.websocket_credential_rewrite); assert!(!ep.request_body_credential_rewrite); + assert!(!ep.allow_uninspected_credentials); + assert!(!ep.provider_credentialed); } #[test] diff --git a/crates/openshell-policy/src/merge.rs b/crates/openshell-policy/src/merge.rs index 04f390198d..24846bb407 100644 --- a/crates/openshell-policy/src/merge.rs +++ b/crates/openshell-policy/src/merge.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use openshell_core::proto::{ L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, @@ -9,6 +9,108 @@ use openshell_core::proto::{ use crate::is_provider_rule_name; +const DEFAULT_JSON_RPC_MAX_BODY_BYTES: u32 = 64 * 1024; + +/// Rewrite an observation-only advisor rule against the live effective policy. +/// +/// A denial reports only `(host, port, binary)`. If that destination already +/// has one unambiguous endpoint contract, proposing a second generic L4 +/// endpoint loses inspection metadata and can make the effective policy +/// ambiguous. Preserve the existing contract instead. Sandbox-owned rules are +/// expanded in place; provider-owned rules remain immutable and are mirrored +/// into the requested sandbox-owned overlay. +pub fn canonicalize_advisor_add_rule( + base_policy: &SandboxPolicy, + effective_policy: &SandboxPolicy, + requested_rule_name: &str, + incoming_rule: &NetworkPolicyRule, +) -> Result<(String, NetworkPolicyRule), String> { + if incoming_rule.endpoints.len() != 1 || incoming_rule.binaries.is_empty() { + return Ok((requested_rule_name.to_string(), incoming_rule.clone())); + } + + let incoming_endpoint = &incoming_rule.endpoints[0]; + let incoming_ports = canonical_ports(incoming_endpoint); + if incoming_endpoint.host.trim().is_empty() || incoming_ports.len() != 1 { + return Ok((requested_rule_name.to_string(), incoming_rule.clone())); + } + let port = incoming_ports[0]; + let contracts = effective_policy + .network_policies + .values() + .flat_map(|rule| &rule.endpoints) + .filter(|endpoint| { + endpoint.host.eq_ignore_ascii_case(&incoming_endpoint.host) + && canonical_ports(endpoint).contains(&port) + }) + .cloned() + .map(|mut endpoint| { + // Provenance does not change the endpoint contract. The gateway + // derives the credential marker, and the advisor marker records + // where a persisted endpoint came from. + endpoint.provider_credentialed = false; + endpoint.advisor_proposed = false; + // A denial observes one binary-to-port authorization. Preserve the + // existing inspection contract, but never copy sibling ports from + // a multi-port endpoint into the proposal. + endpoint.port = port; + endpoint.ports = vec![port]; + normalize_endpoint(&mut endpoint); + endpoint + }) + .collect::>(); + let mut unique_contracts = Vec::new(); + for contract in contracts { + if !unique_contracts.contains(&contract) { + unique_contracts.push(contract); + } + } + + let Some(contract) = unique_contracts.first().cloned() else { + return Ok((requested_rule_name.to_string(), incoming_rule.clone())); + }; + if unique_contracts.len() != 1 { + return Err(format!( + "cannot infer one existing endpoint contract for {}:{}", + incoming_endpoint.host, port + )); + } + + let mut sandbox_owners = base_policy + .network_policies + .iter() + .filter(|(name, _)| !is_provider_rule_name(name)) + .filter_map(|(name, rule)| { + rule.endpoints + .iter() + .any(|endpoint| { + let mut normalized = endpoint.clone(); + normalized.provider_credentialed = false; + normalized.advisor_proposed = false; + normalize_endpoint(&mut normalized); + normalized == contract + }) + .then_some(name.clone()) + }) + .collect::>(); + sandbox_owners.sort(); + + let mut contract = contract; + if sandbox_owners.is_empty() { + // A provider-owned contract is mirrored into a new sandbox-owned + // advisor overlay, so retain the incoming proposal provenance. + contract.advisor_proposed = incoming_endpoint.advisor_proposed; + } + let target_name = sandbox_owners + .first() + .cloned() + .unwrap_or_else(|| requested_rule_name.to_string()); + let mut canonical = incoming_rule.clone(); + canonical.name.clone_from(&target_name); + canonical.endpoints = vec![contract]; + Ok((target_name, canonical)) +} + #[derive(Debug, Clone, PartialEq)] pub enum PolicyMergeOp { AddRule { @@ -75,6 +177,23 @@ pub enum PolicyMergeWarning { port: u32, incoming: String, }, + /// An `AddRule` named binaries for a rule that already authorizes any + /// binary, so the wider existing scope was kept rather than narrowed. + ExistingAnyBinaryScopeRetained { + rule_name: String, + /// Binary paths the operation named. + incoming: Vec, + }, + /// An `AddRule` operation that would normally fold into an overlapping rule + /// stayed on its requested rule name, because folding would have granted a + /// binary-to-endpoint pair the operation never declared. + KeptRequestedRuleNameToAvoidWidening { + rule_name: String, + /// Rule the endpoint-overlap fallback would otherwise have folded into. + overlapping_rule_name: String, + /// Rendered inheritance conflict that folding would have caused. + reason: String, + }, } impl std::fmt::Display for PolicyMergeWarning { @@ -128,13 +247,102 @@ impl std::fmt::Display for PolicyMergeWarning { f, "endpoint {host}:{port} already uses explicit rules; incoming access preset '{incoming}' was ignored" ), + Self::ExistingAnyBinaryScopeRetained { + rule_name, + incoming, + } => write!( + f, + "rule '{rule_name}' already authorizes any binary; kept that scope instead of narrowing it to {}", + incoming.join(", ") + ), + Self::KeptRequestedRuleNameToAvoidWidening { + rule_name, + overlapping_rule_name, + reason, + } => write!( + f, + "kept add-rule '{rule_name}' on its own rule instead of folding it into overlapping rule '{overlapping_rule_name}': {reason}" + ), } } } +/// Rendered name for the any-binary scope in operator-facing merge errors. An +/// empty binary list on a rule authorizes every binary, which has no path to +/// print. +pub const ANY_BINARY_SCOPE: &str = "any binary"; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PolicyMergeError { MissingRuleNameForAddRule, + /// An `AddRule` operation has no endpoint authorization to merge. + EmptyAddRuleEndpoints { + operation_index: usize, + rule_name: String, + }, + /// Overlapping endpoints have different effective MCP inspection contracts. + McpContractConflict { + operation_index: usize, + host: String, + port: u32, + /// Rendered effective contract already established at this endpoint. + existing: String, + /// Rendered effective contract the operation asked for. + incoming: String, + }, + /// Newly added binary scope would inherit an existing endpoint + /// authorization that the incoming rule did not declare. + NewBinaryWouldInheritAuthorization { + operation_index: usize, + rule_name: String, + /// Rendered binary scope the operation adds, either a concrete path or + /// [`ANY_BINARY_SCOPE`]. + binary_scope: String, + host: String, + ports: Vec, + }, + /// Existing binaries would inherit a new or changed endpoint authorization, + /// but the incoming rule did not declare the existing binary scope. + ExistingBinariesWouldInheritAuthorization { + operation_index: usize, + rule_name: String, + host: String, + ports: Vec, + /// Existing binary scope the operation must also declare to proceed. + undeclared_binaries: Vec, + }, + /// A widened field would land on a port the operation never named, because + /// the field merges into an endpoint carrying more ports than were declared. + UndeclaredPortWouldChange { + operation_index: usize, + rule_name: String, + host: String, + ports: Vec, + }, + /// One host and port would carry more than one effective L7 inspection + /// contract, which the supervisor resolves by match order rather than policy. + ConflictingInspectionContracts { + host: String, + port: u32, + /// Rendered effective contracts found at this host and port, sorted. + contracts: Vec, + }, + /// Several rules carry the same endpoint, so an operation that identifies + /// its target by host and port alone cannot say which binary scope it means. + AmbiguousEndpointRule { + host: String, + port: u32, + /// Rendered rule, and path where one is set, for each endpoint the host + /// and port resolves to. Sorted. + targets: Vec, + }, + /// A remove-binary operation targeted a rule that authorizes any binary, + /// where there is no binary entry to remove. + CannotRemoveBinaryFromAnyBinaryScope { + operation_index: usize, + rule_name: String, + binary_path: String, + }, InvalidEndpointReference { host: String, port: u32, @@ -167,6 +375,79 @@ impl std::fmt::Display for PolicyMergeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingRuleNameForAddRule => write!(f, "add-rule operation requires a rule name"), + Self::EmptyAddRuleEndpoints { + operation_index, + rule_name, + } => write!( + f, + "merge operation {operation_index} add-rule '{rule_name}' must contain at least one endpoint" + ), + Self::McpContractConflict { + operation_index, + host, + port, + existing, + incoming, + } => write!( + f, + "merge operation {operation_index} cannot combine MCP contracts at {host}:{port}: existing {existing}, incoming {incoming}" + ), + Self::NewBinaryWouldInheritAuthorization { + operation_index, + rule_name, + binary_scope, + host, + ports, + } => write!( + f, + "merge operation {operation_index} add-rule '{rule_name}' would grant {binary_scope} undeclared authorization for {host} on ports {ports:?}" + ), + Self::ExistingBinariesWouldInheritAuthorization { + operation_index, + rule_name, + host, + ports, + undeclared_binaries, + } => write!( + f, + "merge operation {operation_index} add-rule '{rule_name}' would grant existing binaries new or changed authorization for {host} on ports {ports:?}; also declare {}", + undeclared_binaries.join(", ") + ), + Self::UndeclaredPortWouldChange { + operation_index, + rule_name, + host, + ports, + } => write!( + f, + "merge operation {operation_index} add-rule '{rule_name}' would change authorization for {host} on undeclared ports {ports:?}; declare every port of the endpoint it modifies" + ), + Self::ConflictingInspectionContracts { + host, + port, + contracts, + } => write!( + f, + "{host}:{port} would carry more than one L7 inspection contract ({}); the sandbox resolves one contract per host and port, so these cannot coexist even in different rules or under different paths", + contracts.join(", ") + ), + Self::AmbiguousEndpointRule { + host, + port, + targets, + } => write!( + f, + "endpoint {host}:{port} resolves to {}; this operation selects its target by host and port alone, so it cannot say which binary scope and L7 surface to widen. Replace the policy with the full YAML instead", + targets.join(", ") + ), + Self::CannotRemoveBinaryFromAnyBinaryScope { + operation_index, + rule_name, + binary_path, + } => write!( + f, + "merge operation {operation_index} cannot remove binary '{binary_path}' from rule '{rule_name}' because the rule authorizes any binary; replace the policy with an explicit binary list or remove the rule" + ), Self::InvalidEndpointReference { host, port } => { write!(f, "invalid endpoint reference '{host}:{port}'") } @@ -213,10 +494,8 @@ pub struct PolicyMergeResult { /// merge of `proposed` would produce. /// /// "Contains" means: for every endpoint in `proposed`, some rule in -/// `policy.network_policies` has an endpoint with overlapping -/// host/path/port set AND containing every L7 allow (method/path) the -/// proposed endpoint requested, and that rule's binaries cover every -/// binary in `proposed`. +/// `policy.network_policies` has an endpoint whose authorization covers the +/// full proposed endpoint and whose binaries cover every proposed binary. /// /// The sandbox's `policy.local /wait` long-poll uses this to decide when /// the local supervisor has actually loaded a policy that includes the @@ -226,59 +505,280 @@ pub struct PolicyMergeResult { /// `/wait` calls (false sleep). This check is the property the agent /// actually cares about — "is my rule in effect right now?". /// -/// L4-vs-L7 split: endpoint overlap reuses `endpoints_overlap` so the -/// L4 surface (host/path/port) lines up with the `add_rule` merge — if -/// the gateway folded the chunk into an existing rule under a different -/// key, this check still returns true. The L7 layer is checked -/// separately because `endpoints_overlap` is intentionally L4-only: -/// without the L7 check, coverage would return true the instant the -/// supervisor reloaded *any* change to an overlapping endpoint, even -/// before the new method/path actually landed — exactly the false-wakeup -/// mode this fix exists to prevent, just one layer down. +/// Coverage is intentionally stricter than endpoint overlap used during +/// merging. Every proposed port and every complete protobuf allow/deny matcher +/// must be loaded. Runtime-defaulted scalars are compared by effective value so +/// omitted defaults do not cause false negatives while explicit policy changes +/// do not become wildcards. Different loaded rules may jointly cover the +/// proposal, but every binary-by-port pair must be present in that union. +/// +/// The atomic authorization unit is one binary reaching one host, path, and +/// port. Ports travel as a set on the wire, but the loaded policy may spread +/// them across rules, so each port resolves independently against the whole +/// union instead of requiring one loaded endpoint to carry them all. +/// +/// Coverage asks whether the loaded policy contains the proposal, not whether +/// the two are identical. The loaded endpoint is a superset of any proposal +/// that merged into an endpoint carrying earlier authorizations, so +/// `endpoint_attributes_cover` compares merge-widened fields by containment, +/// treats an unset proposal value as unspecified for fields the merge retains, +/// and exact-matches only the fields the proposal actually set. pub fn policy_covers_rule(policy: &SandboxPolicy, proposed: &NetworkPolicyRule) -> bool { if proposed.endpoints.is_empty() { return false; } proposed.endpoints.iter().all(|target_endpoint| { - policy.network_policies.values().any(|rule| { - rule.endpoints.iter().any(|endpoint| { - endpoints_overlap(endpoint, target_endpoint) - && endpoint_l7_covers(endpoint, target_endpoint) - }) && proposed.binaries.iter().all(|target_binary| { - rule.binaries - .iter() - .any(|binary| binary.path == target_binary.path) + authorization_ports(target_endpoint) + .into_iter() + .all(|target_port| { + if proposed.binaries.is_empty() { + // An any-binary proposal needs a loaded any-binary scope. A + // rule restricted to named binaries authorizes only those + // binaries, so it can never stand in for "any". + return policy.network_policies.values().any(|rule| { + rule.binaries.is_empty() + && rule_authorizes(rule, target_endpoint, target_port) + }); + } + + proposed.binaries.iter().all(|target_binary| { + policy.network_policies.values().any(|rule| { + binary_scope_covers(rule, target_binary) + && rule_authorizes(rule, target_endpoint, target_port) + }) + }) }) - }) }) } -/// L7 coverage for a single endpoint match. If the proposed endpoint -/// declared explicit L7 allow rules (method+path), every one of them must -/// be present in the merged endpoint's `rules`. An empty `proposed.rules` -/// is treated as "L4-only" and returns true (the endpoint match alone is -/// sufficient). +/// True when some endpoint on `rule` authorizes `proposed` at `port`. +fn rule_authorizes( + rule: &NetworkPolicyRule, + proposed: &NetworkEndpoint, + port: Option, +) -> bool { + rule.endpoints + .iter() + .any(|endpoint| endpoint_authorization_covers_port(endpoint, proposed, port)) +} + +/// The authorization units an endpoint declares, one per port. /// -/// Conservative on access presets: if a merged endpoint uses -/// `access: read-write` instead of explicit rules, this returns false -/// even though the preset would permit the method at runtime. That -/// produces a one-cycle re-issue on the agent's side — preferable to a -/// false-positive coverage signal that lets the agent retry too early. -fn endpoint_l7_covers(merged: &NetworkEndpoint, proposed: &NetworkEndpoint) -> bool { - if proposed.rules.is_empty() { - return true; +/// `None` represents an endpoint that declares no port at all. Returning it +/// keeps the unit list non-empty, because an empty list would make the callers' +/// `all()` vacuously true and report an endpoint that no loaded rule matches as +/// covered. +fn authorization_ports(endpoint: &NetworkEndpoint) -> Vec> { + let ports = canonical_ports(endpoint); + if ports.is_empty() { + vec![None] + } else { + ports.into_iter().map(Some).collect() } - proposed.rules.iter().all(|proposed_rule| { - let Some(proposed_allow) = proposed_rule.allow.as_ref() else { - return true; - }; - merged.rules.iter().any(|existing| { - existing.allow.as_ref().is_some_and(|existing_allow| { - existing_allow.method == proposed_allow.method - && existing_allow.path == proposed_allow.path - }) +} + +fn binary_scope_covers(rule: &NetworkPolicyRule, proposed: &NetworkBinary) -> bool { + rule.binaries.is_empty() + || rule + .binaries + .iter() + .any(|binary| binary.path == proposed.path) +} + +/// Coverage for a single atomic authorization unit: `loaded` authorizes +/// `proposed` at `port`. `None` means the proposal declares no port, which +/// places no constraint on the loaded port set. +fn endpoint_authorization_covers_port( + loaded: &NetworkEndpoint, + proposed: &NetworkEndpoint, + port: Option, +) -> bool { + if let Some(port) = port + && !canonical_ports(loaded).contains(&port) + { + return false; + } + endpoint_attributes_cover(loaded, proposed) +} + +/// Coverage for everything about an endpoint except its ports. +fn endpoint_attributes_cover(loaded: &NetworkEndpoint, proposed: &NetworkEndpoint) -> bool { + // Host and path identify the endpoint rather than describe it: + // `endpoints_overlap` only ever merges endpoints that already agree on + // both, so a difference here is a different endpoint, not an unmet request. + if !loaded.host.eq_ignore_ascii_case(&proposed.host) || loaded.path != proposed.path { + return false; + } + + // Fields `merge_endpoint` retains rather than widens. An unset proposal + // value is unspecified, not a request for the default: the merge leaves the + // loaded value in place and reports success, so comparing effective values + // would report a proposal that did land as uncovered and leave the + // `policy.local /wait` long poll spinning until its deadline. A set value + // still has to match, because the merge would have kept the loaded value + // and the proposal genuinely is not in effect. + if !proposed.protocol.is_empty() && !protocols_match(&loaded.protocol, &proposed.protocol) { + return false; + } + if !proposed.tls.is_empty() && effective_tls(&loaded.tls) != effective_tls(&proposed.tls) { + return false; + } + if !proposed.enforcement.is_empty() + && effective_enforcement(&loaded.enforcement) + != effective_enforcement(&proposed.enforcement) + { + return false; + } + if !proposed.access.is_empty() && loaded.access != proposed.access { + return false; + } + + // The MCP contract is compared unconditionally in both directions because + // `ensure_mcp_contract_compatible` rejects a merge that would give one host + // and port two contracts. A mismatch here therefore cannot be a proposal + // that landed, in either direction. + if !mcp_contracts_match(loaded, proposed) { + return false; + } + + // Widened fields (list appends and authorization flags) use containment: + // merging + // into an endpoint that already carries them leaves the loaded copy a + // superset of the proposal, so equality would report "not covered" for a + // proposal that did land. + contains_all(&loaded.rules, &proposed.rules) + && contains_all(&loaded.deny_rules, &proposed.deny_rules) + && contains_all(&loaded.allowed_ips, &proposed.allowed_ips) + && flag_covers(loaded.allow_encoded_slash, proposed.allow_encoded_slash) + && flag_covers( + loaded.websocket_credential_rewrite, + proposed.websocket_credential_rewrite, + ) + && flag_covers( + loaded.request_body_credential_rewrite, + proposed.request_body_credential_rewrite, + ) + // Fields the merge neither widens nor retains: it drops them entirely. + // An unset proposal value asks for nothing and is satisfied by whatever + // is loaded; a set value that differs was dropped, so the proposal is + // not in effect and coverage must stay false until it is resubmitted in + // a form the merge preserves. + && unset_or_equal(&proposed.persisted_queries, &loaded.persisted_queries, String::is_empty) + && unset_or_equal( + &proposed.graphql_persisted_queries, + &loaded.graphql_persisted_queries, + HashMap::is_empty, + ) + && unset_or_equal( + &proposed.graphql_max_body_bytes, + &loaded.graphql_max_body_bytes, + |value| *value == 0, + ) + && unset_or_equal( + &proposed.credential_signing, + &loaded.credential_signing, + String::is_empty, + ) + && unset_or_equal( + &proposed.signing_service, + &loaded.signing_service, + String::is_empty, + ) + && unset_or_equal( + &proposed.signing_region, + &loaded.signing_region, + String::is_empty, + ) + && (endpoint_uses_mcp(loaded) + || unset_or_equal( + &proposed.json_rpc_max_body_bytes, + &loaded.json_rpc_max_body_bytes, + |value| *value == 0, + )) +} + +/// Coverage for a field whose proposal value may be unset. An unset proposal +/// value expresses no requirement, so it is covered by any loaded value. +fn unset_or_equal(proposed: &T, loaded: &T, is_unset: impl Fn(&T) -> bool) -> bool { + is_unset(proposed) || proposed == loaded +} + +/// Containment for a field the merge appends to: every proposed entry must be +/// loaded, but the loaded endpoint may carry entries from earlier merges. +fn contains_all(loaded: &[T], proposed: &[T]) -> bool { + proposed.iter().all(|item| loaded.contains(item)) +} + +/// Containment for a field the merge combines with `|=`: the loaded flag only +/// ever gains bits, so coverage holds unless the proposal set a bit that is +/// missing from the loaded endpoint. +fn flag_covers(loaded: bool, proposed: bool) -> bool { + loaded || !proposed +} + +fn protocols_match(left: &str, right: &str) -> bool { + if left.eq_ignore_ascii_case("mcp") || right.eq_ignore_ascii_case("mcp") { + left.eq_ignore_ascii_case("mcp") && right.eq_ignore_ascii_case("mcp") + } else { + left == right + } +} + +fn effective_tls(value: &str) -> &str { + match value { + "" | "terminate" | "passthrough" => "auto", + value => value, + } +} + +fn effective_enforcement(value: &str) -> &str { + if value.is_empty() { "audit" } else { value } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EffectiveMcpContract { + strict_tool_names: bool, + allow_all_known_mcp_methods: bool, + max_body_bytes: u32, +} + +fn effective_mcp_contract(endpoint: &NetworkEndpoint) -> Option { + endpoint + .protocol + .eq_ignore_ascii_case("mcp") + .then(|| EffectiveMcpContract { + strict_tool_names: endpoint + .mcp + .as_ref() + .and_then(|options| options.strict_tool_names) + .unwrap_or(true), + allow_all_known_mcp_methods: endpoint + .mcp + .as_ref() + .and_then(|options| options.allow_all_known_mcp_methods) + .unwrap_or(false), + max_body_bytes: effective_json_rpc_max_body_bytes(endpoint.json_rpc_max_body_bytes), }) - }) +} + +fn effective_json_rpc_max_body_bytes(value: u32) -> u32 { + if value == 0 { + DEFAULT_JSON_RPC_MAX_BODY_BYTES + } else { + value + } +} + +fn endpoint_uses_mcp(endpoint: &NetworkEndpoint) -> bool { + endpoint.protocol.eq_ignore_ascii_case("mcp") || endpoint.mcp.is_some() +} + +fn mcp_contracts_match(left: &NetworkEndpoint, right: &NetworkEndpoint) -> bool { + match (effective_mcp_contract(left), effective_mcp_contract(right)) { + (None, None) => !endpoint_uses_mcp(left) && !endpoint_uses_mcp(right), + (Some(left), Some(right)) => left == right, + _ => false, + } } pub fn merge_policy( @@ -288,9 +788,15 @@ pub fn merge_policy( let mut merged = policy.clone(); let mut warnings = Vec::new(); - for operation in operations { - apply_operation(&mut merged, operation, &mut warnings)?; + // Validate and apply in request order. `merged` is private until every + // operation succeeds, so failures remain atomic without allowing a later + // malformed operation to replace the error from an earlier operation. + let conflicts_before = conflicting_inspection_contracts(&policy); + for (operation_index, operation) in operations.iter().enumerate() { + validate_operation(operation_index, operation)?; + apply_operation(&mut merged, operation_index, operation, &mut warnings)?; } + ensure_no_new_inspection_conflicts(&merged, &conflicts_before)?; let changed = merged != policy; Ok(PolicyMergeResult { @@ -300,6 +806,129 @@ pub fn merge_policy( }) } +/// The MCP inspection contract an endpoint establishes for its host and port. +#[derive(Debug, Clone, PartialEq, Eq)] +struct EffectiveInspection { + protocol: String, + /// Present only when the endpoint is inspected as MCP. + mcp: Option, +} + +/// Host and port pairs whose inspected endpoints cannot coexist, mapped to the +/// rendered contracts in play. +/// +/// `merge_endpoint` cannot catch these on its own, because it only compares +/// endpoints that fold together, and a differing path or a separate rule keeps +/// them apart. Provider-composed rules are deliberately included: the supervisor +/// resolves inspection per host and port regardless of which rule contributed +/// the endpoint. +fn conflicting_inspection_contracts( + policy: &SandboxPolicy, +) -> BTreeMap<(String, u32), Vec> { + let mut contracts: BTreeMap<(String, u32), Vec<(EffectiveInspection, String)>> = + BTreeMap::new(); + for rule in policy.network_policies.values() { + for endpoint in &rule.endpoints { + // Uninspected endpoints carry no L7 contract and never compete. + if endpoint.protocol.is_empty() { + continue; + } + let inspection = EffectiveInspection { + protocol: endpoint.protocol.to_ascii_lowercase(), + mcp: effective_mcp_contract(endpoint), + }; + for port in canonical_ports(endpoint) { + contracts + .entry((endpoint.host.to_ascii_lowercase(), port)) + .or_default() + .push((inspection.clone(), describe_mcp_contract(endpoint))); + } + } + } + + contracts + .into_iter() + .filter_map(|(key, found)| { + if !inspections_conflict(&found) { + return None; + } + let mut rendered: Vec = + found.into_iter().map(|(_, rendered)| rendered).collect(); + rendered.sort(); + rendered.dedup(); + Some((key, rendered)) + }) + .collect() +} + +/// True when the inspections established for one host and port cannot coexist. +/// +/// Authorization is evaluated existentially across every endpoint matching a +/// request, while the parser is chosen by most-specific path. Two non-MCP +/// endpoints share the same method-and-path rule vocabulary, so the broader one +/// authorizing what the narrower one covers is the documented behaviour and +/// stays supported. +/// +/// MCP is different. Its allow rules address JSON-RPC methods and tool names, so +/// a plain REST rule on an overlapping path can satisfy authorization for a tool +/// call the MCP endpoint never allowed, and the relay forwards it. MCP therefore +/// cannot share a host and port with a differently inspected endpoint. Two MCP +/// endpoints must also agree on one contract, because MCP options are not +/// path-selected. +fn inspections_conflict(found: &[(EffectiveInspection, String)]) -> bool { + let mut mcp_contracts = found + .iter() + .filter_map(|(inspection, _)| inspection.mcp.as_ref()); + let Some(first) = mcp_contracts.next() else { + return false; + }; + if found.iter().any(|(inspection, _)| inspection.mcp.is_none()) { + return true; + } + mcp_contracts.any(|contract| contract != first) +} + +/// Rejects an operation that introduces an L7 inspection-contract conflict. +/// +/// Only conflicts absent from `before` are rejected. A policy that already +/// carries one, for instance through a provider profile composed outside this +/// merge, would otherwise make every later update fail with an error the +/// operation did nothing to cause. +fn ensure_no_new_inspection_conflicts( + merged: &SandboxPolicy, + before: &BTreeMap<(String, u32), Vec>, +) -> Result<(), PolicyMergeError> { + for ((host, port), contracts) in conflicting_inspection_contracts(merged) { + if before.contains_key(&(host.clone(), port)) { + continue; + } + return Err(PolicyMergeError::ConflictingInspectionContracts { + host, + port, + contracts, + }); + } + Ok(()) +} + +fn validate_operation( + operation_index: usize, + operation: &PolicyMergeOp, +) -> Result<(), PolicyMergeError> { + if let PolicyMergeOp::AddRule { rule_name, rule } = operation { + if rule_name.trim().is_empty() { + return Err(PolicyMergeError::MissingRuleNameForAddRule); + } + if rule.endpoints.is_empty() { + return Err(PolicyMergeError::EmptyAddRuleEndpoints { + operation_index, + rule_name: rule_name.clone(), + }); + } + } + Ok(()) +} + pub fn generated_rule_name(host: &str, port: u32) -> String { let sanitized = host .replace(['.', '-'], "_") @@ -311,12 +940,13 @@ pub fn generated_rule_name(host: &str, port: u32) -> String { fn apply_operation( policy: &mut SandboxPolicy, + operation_index: usize, operation: &PolicyMergeOp, warnings: &mut Vec, ) -> Result<(), PolicyMergeError> { match operation { PolicyMergeOp::AddRule { rule_name, rule } => { - add_rule(policy, rule_name, rule, warnings)?; + add_rule(policy, operation_index, rule_name, rule, warnings)?; } PolicyMergeOp::RemoveEndpoint { rule_name, @@ -333,6 +963,7 @@ fn apply_operation( port, deny_rules, } => { + ensure_endpoint_target_is_unambiguous(policy, host, *port)?; let endpoint = find_endpoint_mut(policy, host, *port).ok_or_else(|| { PolicyMergeError::EndpointNotFound { host: host.clone(), @@ -349,6 +980,7 @@ fn apply_operation( append_unique_deny_rules(&mut endpoint.deny_rules, deny_rules); } PolicyMergeOp::AddAllowRules { host, port, rules } => { + ensure_endpoint_target_is_unambiguous(policy, host, *port)?; let endpoint = find_endpoint_mut(policy, host, *port).ok_or_else(|| { PolicyMergeError::EndpointNotFound { host: host.clone(), @@ -363,9 +995,28 @@ fn apply_operation( rule_name, binary_path, } => { + // An empty binary list authorizes every binary, so there is no entry + // to drop and narrowing the rule would mean naming every binary that + // should keep access. Reject instead of reporting a success that + // leaves the target binary authorized. + if policy + .network_policies + .get(rule_name) + .is_some_and(|rule| rule.binaries.is_empty()) + { + return Err(PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope { + operation_index, + rule_name: rule_name.clone(), + binary_path: binary_path.clone(), + }); + } + let should_remove = if let Some(rule) = policy.network_policies.get_mut(rule_name) { let original_len = rule.binaries.len(); rule.binaries.retain(|binary| binary.path != *binary_path); + // Removing the last named binary deletes the rule rather than + // leaving an empty list behind, which would silently widen the + // rule to every binary. original_len != rule.binaries.len() && rule.binaries.is_empty() } else { false @@ -380,14 +1031,11 @@ fn apply_operation( fn add_rule( policy: &mut SandboxPolicy, + operation_index: usize, rule_name: &str, incoming_rule: &NetworkPolicyRule, warnings: &mut Vec, ) -> Result<(), PolicyMergeError> { - if rule_name.trim().is_empty() { - return Err(PolicyMergeError::MissingRuleNameForAddRule); - } - let mut incoming_rule = incoming_rule.clone(); normalize_rule(&mut incoming_rule); if incoming_rule.name.is_empty() { @@ -409,7 +1057,8 @@ fn add_rule( // provider rule's binary list. The agent's contribution is kept on its // own rule key, the prover sees the actual narrow proposal, and the // reviewer gets honest signal about what's being added. - let target_key = if policy.network_policies.contains_key(rule_name) { + let requested_key_exists = policy.network_policies.contains_key(rule_name); + let target_key = if requested_key_exists { Some(rule_name.to_string()) } else { let mut keys: Vec<_> = policy.network_policies.keys().cloned().collect(); @@ -426,46 +1075,212 @@ fn add_rule( }) }; - if let Some(key) = target_key { - let existing_rule = policy - .network_policies - .get_mut(&key) - .expect("existing rule must be present"); - merge_rules(existing_rule, &incoming_rule, warnings)?; - } else { - policy - .network_policies - .insert(rule_name.to_string(), incoming_rule); + match target_key { + // The operation named this rule, so an inheritance conflict is the + // answer the proposer needs: declare the rule's whole binary and + // endpoint scope, or target a different rule. + Some(key) if requested_key_exists => { + let existing_rule = policy + .network_policies + .get_mut(&key) + .expect("existing rule must be present"); + merge_rules( + existing_rule, + &incoming_rule, + operation_index, + rule_name, + &key, + warnings, + )?; + } + // The overlap fallback chose this rule. Folding is a convenience for + // incremental refinement, not something the operation asked for, so a + // conflict it creates is not the proposer's error to fix: keeping the + // proposal on its requested key authorizes exactly the product the + // operation declared and nothing else. Merging into a copy keeps the + // rejected attempt from leaving partial edits behind. + Some(key) => { + let mut candidate = policy + .network_policies + .get(&key) + .expect("existing rule must be present") + .clone(); + let mut candidate_warnings = Vec::new(); + match merge_rules( + &mut candidate, + &incoming_rule, + operation_index, + rule_name, + &key, + &mut candidate_warnings, + ) { + Ok(()) => { + policy.network_policies.insert(key, candidate); + warnings.extend(candidate_warnings); + } + Err(error) if is_authorization_inheritance_conflict(&error) => { + // Surface the skipped fold. The operator asked for one rule + // and gets two, and the same host and port now appears in + // both, which changes which rule later host-and-port + // operations resolve to. + warnings.push(PolicyMergeWarning::KeptRequestedRuleNameToAvoidWidening { + rule_name: rule_name.to_string(), + overlapping_rule_name: key, + reason: error.to_string(), + }); + policy + .network_policies + .insert(rule_name.to_string(), incoming_rule); + } + Err(error) => return Err(error), + } + } + None => { + policy + .network_policies + .insert(rule_name.to_string(), incoming_rule); + } } Ok(()) } +/// True for the merge errors that exist only because two authorizations share +/// one rule, so moving the incoming authorization to its own rule resolves them. +/// +/// `UndeclaredPortWouldChange` belongs here for the same reason as the two +/// inheritance variants. The undeclared port exists only on the endpoint the +/// fold would merge into; an operation kept on its own rule carries exactly the +/// ports it declared, so nothing reaches a port it did not name. Leaving it out +/// makes a narrow update against a multi-port endpoint fail outright instead of +/// landing on its own rule. +/// +/// An MCP contract conflict is deliberately excluded. The supervisor establishes +/// one inspection contract per host and port rather than per rule, so two +/// contracts for the same endpoint stay ambiguous in separate rules and the +/// operation has to be rejected wherever it lands. +/// +/// The match is exhaustive on purpose. A catch-all would let a new variant +/// default to "not fold-only" and silently withdraw the separate-rule remedy +/// for whichever updates start hitting it first, so adding a variant has to be +/// an explicit decision here. +// The three groups returning `false` are kept apart on purpose: each declines +// for a different reason, and merging them into one arm would leave a future +// variant with no guidance on which group it belongs to. +#[allow(clippy::match_same_arms)] +fn is_authorization_inheritance_conflict(error: &PolicyMergeError) -> bool { + match error { + // Scope conflicts that exist only because the fold puts two + // authorizations in one rule. Kept on its own rule, the incoming + // authorization grants exactly the binaries, endpoints, and ports it + // declared, and the conflict disappears. + PolicyMergeError::NewBinaryWouldInheritAuthorization { .. } + | PolicyMergeError::ExistingBinariesWouldInheritAuthorization { .. } + | PolicyMergeError::UndeclaredPortWouldChange { .. } => true, + + // Separating the rules does not help. The supervisor establishes one MCP + // inspection contract per host and port rather than per rule, so two + // contracts stay ambiguous however they are split. + PolicyMergeError::McpContractConflict { .. } + | PolicyMergeError::ConflictingInspectionContracts { .. } => false, + + // Reports an unsupported or missing state in the policy the fold + // targeted rather than a scope conflict. Routing around it would leave + // the operator with a rule they did not ask for and an existing endpoint + // still needing attention. + PolicyMergeError::UnsupportedAccessPreset { .. } + | PolicyMergeError::UnsupportedEndpointProtocol { .. } + | PolicyMergeError::EndpointHasNoL7Inspection { .. } + | PolicyMergeError::EndpointHasNoAllowBase { .. } + | PolicyMergeError::EndpointNotFound { .. } + | PolicyMergeError::InvalidEndpointReference { .. } + | PolicyMergeError::AmbiguousEndpointRule { .. } => false, + + // Malformed operations, and operations `merge_rules` never produces. + // Neither is answered by choosing a different rule to write to. + PolicyMergeError::MissingRuleNameForAddRule + | PolicyMergeError::EmptyAddRuleEndpoints { .. } + | PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope { .. } => false, + } +} + fn merge_rules( existing_rule: &mut NetworkPolicyRule, incoming_rule: &NetworkPolicyRule, + operation_index: usize, + rule_name: &str, + // Key of the rule actually being modified. Differs from `rule_name` when the + // endpoint-overlap fallback folded the operation into another rule, so + // warnings name the rule the operator would have to inspect. + target_rule_name: &str, warnings: &mut Vec, ) -> Result<(), PolicyMergeError> { - append_unique_binaries(&mut existing_rule.binaries, &incoming_rule.binaries); - + // A rule authorizes the Cartesian product of its binaries and endpoints. + // Build the final endpoint set off to the side, then reject any implicit + // grants before publishing either half of that product. + let mut merged_endpoints = existing_rule.endpoints.clone(); + let mut endpoint_warnings = Vec::new(); for incoming_endpoint in &incoming_rule.endpoints { let mut incoming_endpoint = incoming_endpoint.clone(); normalize_endpoint(&mut incoming_endpoint); if let Some(existing_endpoint) = - find_matching_endpoint_mut(&mut existing_rule.endpoints, &incoming_endpoint) + find_matching_endpoint_mut(&mut merged_endpoints, &incoming_endpoint) { - merge_endpoint(existing_endpoint, &incoming_endpoint, warnings)?; + merge_endpoint( + existing_endpoint, + &incoming_endpoint, + operation_index, + &mut endpoint_warnings, + )?; } else { - existing_rule.endpoints.push(incoming_endpoint); + merged_endpoints.push(incoming_endpoint); } } + ensure_authorization_inheritance_is_declared( + existing_rule, + incoming_rule, + &merged_endpoints, + operation_index, + rule_name, + )?; + + existing_rule.endpoints = merged_endpoints; + if existing_rule.binaries.is_empty() { + // The rule already authorizes any binary, so an incoming named list is + // a subset of what is already granted. Appending it would make the list + // non-empty and revoke every binary the operation did not name, turning + // an additive operation into a silent mass revocation. Keep the wider + // scope and say so; narrowing is `RemoveBinary` or a full replacement. + if !incoming_rule.binaries.is_empty() { + warnings.push(PolicyMergeWarning::ExistingAnyBinaryScopeRetained { + rule_name: target_rule_name.to_string(), + incoming: incoming_rule + .binaries + .iter() + .map(|binary| binary.path.clone()) + .collect(), + }); + } + } else if incoming_rule.binaries.is_empty() { + // An incoming any-binary scope replaces the restricted scope instead of + // appending to it. Appending an empty list would leave the existing + // binaries in place, so the operation would report success while never + // authorizing the scope it asked for and coverage would never converge. + // The inheritance check above already required this operation to + // declare every merged endpoint before the widening is allowed. + existing_rule.binaries.clear(); + } else { + append_unique_binaries(&mut existing_rule.binaries, &incoming_rule.binaries); + } + warnings.extend(endpoint_warnings); Ok(()) } fn merge_endpoint( existing: &mut NetworkEndpoint, incoming: &NetworkEndpoint, + operation_index: usize, warnings: &mut Vec, ) -> Result<(), PolicyMergeError> { let host = if existing.host.is_empty() { @@ -473,12 +1288,17 @@ fn merge_endpoint( } else { existing.host.clone() }; - let port = canonical_ports(existing) + let existing_ports = canonical_ports(existing); + let incoming_ports = canonical_ports(incoming); + let port = existing_ports .into_iter() - .next() - .or_else(|| canonical_ports(incoming).into_iter().next()) + .find(|port| incoming_ports.contains(port)) + .or_else(|| incoming_ports.first().copied()) .unwrap_or(0); + let promotes_l4_to_mcp = promotes_l4_endpoint_to_mcp(existing, incoming); + ensure_mcp_contract_compatible(existing, incoming, operation_index, &host, port)?; + if existing.host.is_empty() { existing.host.clone_from(&incoming.host); } @@ -499,6 +1319,10 @@ fn merge_endpoint( }, warnings, ); + if promotes_l4_to_mcp { + existing.mcp.clone_from(&incoming.mcp); + existing.json_rpc_max_body_bytes = incoming.json_rpc_max_body_bytes; + } let existing_enforcement = existing.enforcement.clone(); merge_string_field( &mut existing.enforcement, @@ -558,17 +1382,326 @@ fn merge_endpoint( existing.allow_encoded_slash |= incoming.allow_encoded_slash; existing.websocket_credential_rewrite |= incoming.websocket_credential_rewrite; existing.request_body_credential_rewrite |= incoming.request_body_credential_rewrite; - existing.advisor_proposed |= incoming.advisor_proposed; + existing.allow_uninspected_credentials |= incoming.allow_uninspected_credentials; + // Provenance is not an authorization bit. If either declaration came + // directly from a user or provider, keep the endpoint explicit. This + // mirrors binary provenance and prevents an advisor overlay from tainting + // an already explicit endpoint for exact-host SSRF evaluation. + existing.advisor_proposed &= incoming.advisor_proposed; normalize_endpoint(existing); Ok(()) } -fn merge_string_field( - existing: &mut String, - incoming: &str, - warning: PolicyMergeWarning, - warnings: &mut Vec, -) { +fn ensure_mcp_contract_compatible( + existing: &NetworkEndpoint, + incoming: &NetworkEndpoint, + operation_index: usize, + host: &str, + port: u32, +) -> Result<(), PolicyMergeError> { + if promotes_l4_endpoint_to_mcp(existing, incoming) || mcp_contracts_match(existing, incoming) { + return Ok(()); + } + + Err(PolicyMergeError::McpContractConflict { + operation_index, + host: host.to_string(), + port, + existing: describe_mcp_contract(existing), + incoming: describe_mcp_contract(incoming), + }) +} + +/// Renders the values `mcp_contracts_match` compares, so the reported conflict +/// names the fields that have to agree. The effective contract is rendered +/// rather than the raw options because an omitted option and its default are +/// the same contract. +fn describe_mcp_contract(endpoint: &NetworkEndpoint) -> String { + effective_mcp_contract(endpoint).map_or_else( + || format!("non-mcp(protocol='{}')", endpoint.protocol), + |contract| { + format!( + "mcp(strict_tool_names={}, allow_all_known_mcp_methods={}, max_body_bytes={})", + contract.strict_tool_names, + contract.allow_all_known_mcp_methods, + contract.max_body_bytes + ) + }, + ) +} + +fn promotes_l4_endpoint_to_mcp(existing: &NetworkEndpoint, incoming: &NetworkEndpoint) -> bool { + // Only an endpoint without an established inspection contract can adopt + // the complete incoming MCP contract instead of combining two contracts. + existing.protocol.is_empty() + && existing.mcp.is_none() + && existing.json_rpc_max_body_bytes == 0 + && effective_mcp_contract(incoming).is_some() +} + +fn ensure_authorization_inheritance_is_declared( + existing_rule: &NetworkPolicyRule, + incoming_rule: &NetworkPolicyRule, + merged_endpoints: &[NetworkEndpoint], + operation_index: usize, + rule_name: &str, +) -> Result<(), PolicyMergeError> { + let existing_binary_paths: HashSet<&str> = existing_rule + .binaries + .iter() + .map(|binary| binary.path.as_str()) + .collect(); + // Binary scope the operation adds to the rule. An empty incoming list means + // any binary, which introduces every binary outside the existing list, so it + // widens this side of the Cartesian product just as a new concrete path does + // and has to declare the endpoints it will reach. An existing empty list + // already authorizes any binary, so nothing incoming can widen it further. + let new_binary_scope = if existing_rule.binaries.is_empty() { + None + } else if incoming_rule.binaries.is_empty() { + Some(ANY_BINARY_SCOPE.to_string()) + } else { + incoming_rule + .binaries + .iter() + .find(|binary| !existing_binary_paths.contains(binary.path.as_str())) + .map(|binary| format!("binary '{}'", binary.path)) + }; + let undeclared_binaries = undeclared_existing_binaries(existing_rule, incoming_rule); + + for endpoint in merged_endpoints { + let units = authorization_ports(endpoint); + + if let Some(binary_scope) = &new_binary_scope { + // Adoption depends on the declaration and this endpoint, not on the + // port, so it is resolved once per endpoint rather than per port. + let declarations: Vec = incoming_rule + .endpoints + .iter() + .map(|declared| adopt_unset_retained_fields(declared, endpoint)) + .collect(); + // The operation may declare one endpoint's ports across several + // incoming endpoints, so each port is checked against every + // declaration rather than against a single one. + let undeclared_ports: Vec> = units + .iter() + .copied() + .filter(|port| !operation_declares(&declarations, endpoint, *port)) + .collect(); + if !undeclared_ports.is_empty() { + return Err(PolicyMergeError::NewBinaryWouldInheritAuthorization { + operation_index, + rule_name: rule_name.to_string(), + binary_scope: binary_scope.clone(), + host: endpoint.host.clone(), + ports: undeclared_ports.into_iter().flatten().collect(), + }); + } + } + + let changed_ports: Vec> = units + .iter() + .copied() + .filter(|port| { + !existing_rule + .endpoints + .iter() + .any(|existing| authorization_unit_unchanged(existing, endpoint, *port)) + }) + .collect(); + + // Every changed port must be named by the operation, whether or not the + // rule's binary scope is fully declared. Widened fields merge into the + // endpoint as a whole, so a change declared for one port of a + // multi-port endpoint reaches that endpoint's other ports too. Checking + // this only alongside undeclared binaries would let an operation that + // lists every existing binary widen an undeclared port. + let undeclared_changed_ports: Vec> = changed_ports + .iter() + .copied() + .filter(|port| !operation_names_port(incoming_rule, endpoint, *port)) + .collect(); + if !undeclared_changed_ports.is_empty() { + return Err(PolicyMergeError::UndeclaredPortWouldChange { + operation_index, + rule_name: rule_name.to_string(), + host: endpoint.host.clone(), + ports: undeclared_changed_ports.into_iter().flatten().collect(), + }); + } + + if !changed_ports.is_empty() && !undeclared_binaries.is_empty() { + return Err( + PolicyMergeError::ExistingBinariesWouldInheritAuthorization { + operation_index, + rule_name: rule_name.to_string(), + host: endpoint.host.clone(), + ports: changed_ports.into_iter().flatten().collect(), + undeclared_binaries, + }, + ); + } + } + + Ok(()) +} + +/// True when the operation names `port` on an endpoint matching `merged`'s host +/// and path. +/// +/// This asks only whether the operation addressed the port, not whether it +/// restated the endpoint's authorization. Pre-existing authorization on a port +/// the operation names is not something the operation granted, but a widened +/// field landing on a port the operation never named is. +fn operation_names_port( + incoming_rule: &NetworkPolicyRule, + merged: &NetworkEndpoint, + port: Option, +) -> bool { + incoming_rule.endpoints.iter().any(|declared| { + declared.host.eq_ignore_ascii_case(&merged.host) + && declared.path == merged.path + && port.is_none_or(|port| canonical_ports(declared).contains(&port)) + }) +} + +/// True when the operation itself declares the authorization `merged` grants at +/// `port`, anywhere in `declarations`. +/// +/// `declarations` must already have been through `adopt_unset_retained_fields` +/// against `merged`. +fn operation_declares( + declarations: &[NetworkEndpoint], + merged: &NetworkEndpoint, + port: Option, +) -> bool { + declarations + .iter() + .any(|declared| endpoint_authorization_covers_port(declared, merged, port)) +} + +/// Fills a declaration's unset carry-over fields from the endpoint it is being +/// judged against. +/// +/// A carry-over field is one the merge never widens: it either retains the +/// existing value (`protocol`, `tls`, `enforcement`, `access`) or ignores the +/// incoming value entirely (the signing and GraphQL fields). Either way the new +/// binary receives whatever the endpoint already carried, and the operation +/// cannot change it, so a declaration that leaves the field unset expresses no +/// opinion rather than a request for the default. Comparing the unset value +/// against the merged one would reject a complete declaration over a field the +/// operation never tried to change, and for `enforcement` it would reject +/// specifically because the binary is about to receive the stricter mode. +/// +/// Every carry-over field has to be listed here. Missing one costs a false +/// rejection of a complete declaration, which is why adoption is written as an +/// explicit allow-list rather than by copying `merged` and restoring the widened +/// fields: forgetting a field in that direction would silently satisfy the +/// declaration instead. +/// +/// This is the declaration direction only. Coverage still exact-matches a value +/// the proposal actually set, because there the question is whether the +/// proposer's own request took effect. +fn adopt_unset_retained_fields( + declared: &NetworkEndpoint, + merged: &NetworkEndpoint, +) -> NetworkEndpoint { + let mut adopted = declared.clone(); + if adopted.protocol.is_empty() { + adopted.protocol.clone_from(&merged.protocol); + } + if adopted.tls.is_empty() { + adopted.tls.clone_from(&merged.tls); + } + if adopted.enforcement.is_empty() { + adopted.enforcement.clone_from(&merged.enforcement); + } + // `merge_endpoint` only touches `access` when the incoming endpoint carries + // an access preset or explicit rules, so an endpoint declaring neither keeps + // whatever preset is already loaded. + if adopted.access.is_empty() && adopted.rules.is_empty() { + adopted.access.clone_from(&merged.access); + } + if adopted.persisted_queries.is_empty() { + adopted + .persisted_queries + .clone_from(&merged.persisted_queries); + } + if adopted.graphql_persisted_queries.is_empty() { + adopted + .graphql_persisted_queries + .clone_from(&merged.graphql_persisted_queries); + } + if adopted.graphql_max_body_bytes == 0 { + adopted.graphql_max_body_bytes = merged.graphql_max_body_bytes; + } + if adopted.credential_signing.is_empty() { + adopted + .credential_signing + .clone_from(&merged.credential_signing); + } + if adopted.signing_service.is_empty() { + adopted.signing_service.clone_from(&merged.signing_service); + } + if adopted.signing_region.is_empty() { + adopted.signing_region.clone_from(&merged.signing_region); + } + if adopted.json_rpc_max_body_bytes == 0 { + adopted.json_rpc_max_body_bytes = merged.json_rpc_max_body_bytes; + } + adopted +} + +/// True when `existing` already authorizes exactly what `merged` authorizes at +/// `port`. Equivalence rather than coverage: an existing endpoint authorizing +/// strictly less has changed, and the rule's other binaries would inherit the +/// difference. +fn authorization_unit_unchanged( + existing: &NetworkEndpoint, + merged: &NetworkEndpoint, + port: Option, +) -> bool { + endpoint_authorization_covers_port(existing, merged, port) + && endpoint_authorization_covers_port(merged, existing, port) +} + +/// Existing binary scope the incoming rule failed to declare. Empty means the +/// operation covers the whole existing scope and no binary can inherit a new +/// endpoint implicitly. +/// +/// An empty binary list means any binary: an incoming any-binary scope covers +/// every existing binary, while a specific incoming list can never claim an +/// existing any-binary scope. +fn undeclared_existing_binaries( + existing_rule: &NetworkPolicyRule, + incoming_rule: &NetworkPolicyRule, +) -> Vec { + if incoming_rule.binaries.is_empty() { + return Vec::new(); + } + if existing_rule.binaries.is_empty() { + return vec!["the existing any-binary scope".to_string()]; + } + + existing_rule + .binaries + .iter() + .filter(|existing| { + !incoming_rule + .binaries + .iter() + .any(|incoming| incoming.path == existing.path) + }) + .map(|existing| existing.path.clone()) + .collect() +} + +fn merge_string_field( + existing: &mut String, + incoming: &str, + warning: PolicyMergeWarning, + warnings: &mut Vec, +) { if incoming.is_empty() { return; } @@ -636,6 +1769,53 @@ fn find_matching_endpoint_mut<'a>( .find(|endpoint| endpoints_overlap(endpoint, target)) } +/// Every endpoint `host:port` resolves to, rendered with its owning rule. +/// +/// `endpoint_matches_host_port` deliberately ignores `path`, and +/// `endpoints_overlap` treats a different path as a different endpoint, so one +/// rule can own several matching endpoints. Counting rules alone would miss +/// that, so this counts endpoints. +fn matching_endpoint_targets(policy: &SandboxPolicy, host: &str, port: u32) -> Vec { + let mut targets: Vec = policy + .network_policies + .iter() + .filter(|(key, _)| !is_provider_rule_name(key)) + .flat_map(|(key, rule)| { + rule.endpoints + .iter() + .filter(|endpoint| endpoint_matches_host_port(endpoint, host, port)) + .map(move |endpoint| { + if endpoint.path.is_empty() { + key.clone() + } else { + format!("{key} (path '{}')", endpoint.path) + } + }) + }) + .collect(); + targets.sort(); + targets +} + +/// Fails closed when `host:port` resolves to more than one endpoint, because +/// appending an L7 rule widens authorization for every binary on whichever +/// endpoint is picked, and the operation cannot say which one it means. +fn ensure_endpoint_target_is_unambiguous( + policy: &SandboxPolicy, + host: &str, + port: u32, +) -> Result<(), PolicyMergeError> { + let targets = matching_endpoint_targets(policy, host, port); + if targets.len() > 1 { + return Err(PolicyMergeError::AmbiguousEndpointRule { + host: host.to_string(), + port, + targets, + }); + } + Ok(()) +} + fn find_endpoint_mut<'a>( policy: &'a mut SandboxPolicy, host: &str, @@ -917,12 +2097,14 @@ mod tests { use std::collections::HashMap; use super::{ - PolicyMergeError, PolicyMergeOp, PolicyMergeWarning, generated_rule_name, merge_policy, - policy_covers_rule, + ANY_BINARY_SCOPE, DEFAULT_JSON_RPC_MAX_BODY_BYTES, PolicyMergeError, PolicyMergeOp, + PolicyMergeWarning, canonical_ports, canonicalize_advisor_add_rule, generated_rule_name, + merge_policy, policy_covers_rule, }; use crate::restrictive_default_policy; use openshell_core::proto::{ - L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, + L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, McpOptions, NetworkBinary, NetworkEndpoint, + NetworkPolicyRule, SandboxPolicy, }; fn endpoint(host: &str, port: u32) -> NetworkEndpoint { @@ -970,962 +2152,3423 @@ mod tests { } #[test] - fn generated_rule_name_sanitizes_host() { - assert_eq!( - generated_rule_name("api.github.com", 443), - "allow_api_github_com_443" - ); + fn canonicalize_advisor_expands_existing_inspected_rule_without_l7_downgrade() { + let mut existing_endpoint = endpoint("index.crates.io", 443); + existing_endpoint.protocol = "rest".to_string(); + existing_endpoint.enforcement = "enforce".to_string(); + existing_endpoint.access = "read-only".to_string(); + let existing = NetworkPolicyRule { + name: "cargo-registry".to_string(), + endpoints: vec![existing_endpoint.clone()], + binaries: vec![NetworkBinary { + path: "/usr/bin/cargo".to_string(), + ..Default::default() + }], + }; + let mut base = SandboxPolicy::default(); + base.network_policies + .insert("cargo_registry".to_string(), existing); + let effective = base.clone(); + let mut observed = endpoint("index.crates.io", 443); + observed.advisor_proposed = true; + let incoming = NetworkPolicyRule { + name: "allow_index_crates_io_443".to_string(), + endpoints: vec![observed], + binaries: vec![advisor_binary("/usr/bin/curl")], + }; + + let (rule_name, canonical) = canonicalize_advisor_add_rule( + &base, + &effective, + "allow_index_crates_io_443", + &incoming, + ) + .unwrap(); + + assert_eq!(rule_name, "cargo_registry"); + assert_eq!(canonical.endpoints, vec![existing_endpoint]); + assert_eq!(canonical.binaries[0].path, "/usr/bin/curl"); + #[allow(deprecated)] + { + assert!(canonical.binaries[0].harness); + } } #[test] - fn add_rule_merges_l7_fields_into_existing_endpoint() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), + fn canonicalize_advisor_mirrors_provider_contract_into_sandbox_overlay() { + let base = SandboxPolicy::default(); + let mut provider_endpoint = endpoint("api.example.com", 443); + provider_endpoint.protocol = "rest".to_string(); + provider_endpoint.enforcement = "enforce".to_string(); + provider_endpoint.access = "read-only".to_string(); + provider_endpoint.provider_credentialed = true; + let mut effective = SandboxPolicy::default(); + effective.network_policies.insert( + "_provider_example".to_string(), NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], + name: "provider-example".to_string(), + endpoints: vec![provider_endpoint.clone()], + binaries: Vec::new(), }, ); - let incoming = NetworkPolicyRule { - name: "incoming".to_string(), + name: "advisor_example".to_string(), endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), + host: "api.example.com".to_string(), port: 443, - ports: vec![443], - protocol: "rest".to_string(), - enforcement: "enforce".to_string(), - rules: vec![rest_rule("GET", "/repos/**")], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/gh".to_string(), + advisor_proposed: true, ..Default::default() }], + binaries: vec![advisor_binary("/usr/bin/curl")], }; - let result = merge_policy( - policy, - &[PolicyMergeOp::AddRule { - rule_name: "allow_api_github_com_443".to_string(), - rule: incoming, - }], - ) - .expect("merge should succeed"); + let (rule_name, canonical) = + canonicalize_advisor_add_rule(&base, &effective, "advisor_example", &incoming).unwrap(); - let rule = &result.policy.network_policies["existing"]; - let endpoint = &rule.endpoints[0]; - assert_eq!(endpoint.protocol, "rest"); - assert_eq!(endpoint.enforcement, "enforce"); - assert_eq!(endpoint.rules.len(), 1); - assert_eq!(rule.binaries.len(), 2); + assert_eq!(rule_name, "advisor_example"); + assert_eq!(canonical.endpoints[0].protocol, "rest"); + assert_eq!(canonical.endpoints[0].access, "read-only"); + assert!(!canonical.endpoints[0].provider_credentialed); + assert!(canonical.endpoints[0].advisor_proposed); + assert_eq!( + effective.network_policies["_provider_example"].endpoints[0], + provider_endpoint + ); } #[test] - fn add_rule_user_binary_clears_advisor_marker_for_same_path() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), + fn canonicalize_advisor_ignores_endpoint_provenance_when_inferring_contract() { + let mut provider_endpoint = endpoint("api.example.com", 443); + provider_endpoint.protocol = "rest".to_string(); + provider_endpoint.enforcement = "enforce".to_string(); + provider_endpoint.access = "read-only".to_string(); + provider_endpoint.provider_credentialed = true; + + let mut advisor_endpoint = provider_endpoint.clone(); + advisor_endpoint.provider_credentialed = false; + advisor_endpoint.advisor_proposed = true; + + let mut base = SandboxPolicy::default(); + base.network_policies.insert( + "existing_advisor".to_string(), NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], + name: "existing-advisor".to_string(), + endpoints: vec![advisor_endpoint], binaries: vec![advisor_binary("/usr/bin/curl")], }, ); + let mut effective = base.clone(); + effective.network_policies.insert( + "_provider_example".to_string(), + NetworkPolicyRule { + name: "provider-example".to_string(), + endpoints: vec![provider_endpoint], + binaries: vec![binary("/usr/bin/gh")], + }, + ); + let incoming = NetworkPolicyRule { - name: "incoming".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), + name: "advisor_example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + advisor_proposed: true, ..Default::default() }], + binaries: vec![advisor_binary("/usr/bin/python")], }; - let result = merge_policy( - policy, - &[PolicyMergeOp::AddRule { - rule_name: "existing".to_string(), - rule: incoming, - }], - ) - .expect("merge should succeed"); + let (rule_name, canonical) = + canonicalize_advisor_add_rule(&base, &effective, "advisor_example", &incoming) + .expect("provenance alone must not create multiple endpoint contracts"); - let rule = &result.policy.network_policies["existing"]; - assert_eq!(rule.binaries.len(), 1); - #[allow(deprecated)] - { - assert!(!rule.binaries[0].harness); - } + assert_eq!(rule_name, "existing_advisor"); + assert_eq!(canonical.endpoints[0].protocol, "rest"); + assert_eq!(canonical.endpoints[0].access, "read-only"); } #[test] - fn add_rule_duplicate_binaries_prefer_user_declared_marker() { - let incoming = NetworkPolicyRule { - name: "incoming".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![ - advisor_binary("/usr/bin/curl"), - NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }, - ], + fn canonicalize_advisor_narrows_multi_port_contract_and_keeps_overlay() { + let mut existing_endpoint = endpoint("index.crates.io", 443); + existing_endpoint.port = 80; + existing_endpoint.ports = vec![80, 443]; + existing_endpoint.protocol = "rest".to_string(); + existing_endpoint.enforcement = "enforce".to_string(); + existing_endpoint.access = "read-only".to_string(); + let existing = NetworkPolicyRule { + name: "cargo-registry".to_string(), + endpoints: vec![existing_endpoint], + binaries: vec![binary("/usr/bin/cargo")], }; - - let result = merge_policy( - restrictive_default_policy(), - &[PolicyMergeOp::AddRule { - rule_name: "github".to_string(), - rule: incoming, - }], - ) - .expect("merge should succeed"); - - let rule = &result.policy.network_policies["github"]; - assert_eq!(rule.binaries.len(), 1); - #[allow(deprecated)] - { - assert!(!rule.binaries[0].harness); - } - } - - #[test] - fn add_rule_preserves_advisor_endpoint_marker_when_binary_is_deduped() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "app-api".to_string(), - NetworkPolicyRule { - name: "app-api".to_string(), - endpoints: vec![endpoint("api.example.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/python".to_string(), - ..Default::default() - }], - }, - ); - + let mut base = SandboxPolicy::default(); + base.network_policies + .insert("cargo_registry".to_string(), existing); + let effective = base.clone(); let incoming = NetworkPolicyRule { - name: "app-api".to_string(), + name: "allow_index_crates_io_443".to_string(), endpoints: vec![NetworkEndpoint { - host: "internal-admin.local".to_string(), + host: "index.crates.io".to_string(), port: 443, - ports: vec![443], advisor_proposed: true, ..Default::default() }], - binaries: vec![advisor_binary("/usr/bin/python")], + binaries: vec![advisor_binary("/usr/bin/curl")], }; - let result = merge_policy( - policy, + let (rule_name, canonical) = canonicalize_advisor_add_rule( + &base, + &effective, + "allow_index_crates_io_443", + &incoming, + ) + .unwrap(); + + assert_eq!(rule_name, "allow_index_crates_io_443"); + assert_eq!(canonical.endpoints[0].ports, vec![443]); + assert_eq!(canonical.endpoints[0].protocol, "rest"); + assert_eq!(canonical.endpoints[0].access, "read-only"); + + let merged = merge_policy( + base, &[PolicyMergeOp::AddRule { - rule_name: "app-api".to_string(), - rule: incoming, + rule_name, + rule: canonical, }], ) - .expect("merge should succeed"); - - let rule = &result.policy.network_policies["app-api"]; - assert_eq!(rule.binaries.len(), 1, "binary should still dedupe"); - #[allow(deprecated)] - { - assert!( - !rule.binaries[0].harness, - "existing user binary provenance should be retained" - ); + .unwrap() + .policy; + assert_eq!( + merged.network_policies["cargo_registry"].endpoints[0].ports, + vec![80, 443] + ); + assert_eq!( + merged.network_policies["allow_index_crates_io_443"].endpoints[0].ports, + vec![443] + ); + assert_eq!( + merged.network_policies["allow_index_crates_io_443"].binaries[0].path, + "/usr/bin/curl" + ); + } + + fn binary(path: &str) -> NetworkBinary { + NetworkBinary { + path: path.to_string(), + ..Default::default() } - let internal_endpoint = rule - .endpoints - .iter() - .find(|endpoint| endpoint.host == "internal-admin.local") - .expect("advisor endpoint should be appended"); + } + + fn mcp_tool_rule(tool: &str) -> L7Rule { + L7Rule { + allow: Some(L7Allow { + method: "tools/call".to_string(), + params: HashMap::from([( + "name".to_string(), + L7QueryMatcher { + glob: tool.to_string(), + any: Vec::new(), + }, + )]), + ..Default::default() + }), + } + } + + fn mcp_endpoint( + host: &str, + ports: &[u32], + strict_tool_names: Option, + allow_all_known_mcp_methods: Option, + max_body_bytes: u32, + rules: Vec, + ) -> NetworkEndpoint { + NetworkEndpoint { + host: host.to_string(), + port: ports.first().copied().unwrap_or_default(), + ports: ports.to_vec(), + protocol: "mcp".to_string(), + rules, + json_rpc_max_body_bytes: max_body_bytes, + mcp: Some(McpOptions { + strict_tool_names, + allow_all_known_mcp_methods, + }), + ..Default::default() + } + } + + fn rule_with_authorizations( + name: &str, + endpoints: Vec, + binaries: &[&str], + ) -> NetworkPolicyRule { + NetworkPolicyRule { + name: name.to_string(), + endpoints, + binaries: binaries.iter().map(|path| binary(path)).collect(), + } + } + + fn policy_with_rule(rule_name: &str, rule: NetworkPolicyRule) -> SandboxPolicy { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert(rule_name.to_string(), rule); + policy + } + + #[test] + fn generated_rule_name_sanitizes_host() { + assert_eq!( + generated_rule_name("api.github.com", 443), + "allow_api_github_com_443" + ); + } + + #[test] + fn add_rule_rejects_empty_endpoints_at_the_library_boundary() { + let error = merge_policy( + restrictive_default_policy(), + &[PolicyMergeOp::AddRule { + rule_name: "empty".to_string(), + rule: rule_with_authorizations("empty", Vec::new(), &["/usr/bin/client"]), + }], + ) + .expect_err("an AddRule without authorization endpoints must fail"); + + assert_eq!( + error, + PolicyMergeError::EmptyAddRuleEndpoints { + operation_index: 0, + rule_name: "empty".to_string(), + } + ); + } + + #[test] + fn merge_reports_the_first_failing_operation_in_request_order() { + let operations = [ + PolicyMergeOp::AddAllowRules { + host: "missing.example.com".to_string(), + port: 443, + rules: vec![rest_rule("GET", "/")], + }, + PolicyMergeOp::AddRule { + rule_name: "empty".to_string(), + rule: NetworkPolicyRule::default(), + }, + ]; + + assert_eq!( + merge_policy(restrictive_default_policy(), &operations), + Err(PolicyMergeError::EndpointNotFound { + host: "missing.example.com".to_string(), + port: 443, + }) + ); + } + + #[test] + fn new_binary_cannot_inherit_an_undeclared_existing_rest_endpoint() { + let existing_endpoint = NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/admin")], + ..endpoint("admin.example.com", 443) + }; + let existing = + rule_with_authorizations("existing", vec![existing_endpoint], &["/usr/bin/trusted"]); + let incoming = rule_with_authorizations( + "existing", + vec![endpoint("public.example.com", 443)], + &["/usr/bin/untrusted"], + ); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("the new binary did not declare the existing REST endpoint"); + + assert!(matches!( + error, + PolicyMergeError::NewBinaryWouldInheritAuthorization { + operation_index: 0, + binary_scope, + host, + ports, + .. + } if binary_scope == "binary '/usr/bin/untrusted'" + && host == "admin.example.com" + && ports == vec![443] + )); + } + + #[test] + fn new_binary_cannot_inherit_an_undeclared_existing_mcp_endpoint() { + let existing = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("existing-tool")], + )], + &["/usr/bin/trusted"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![endpoint("unrelated.example.com", 443)], + &["/usr/bin/untrusted"], + ); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("the new binary did not declare the existing MCP endpoint"); + + assert!(matches!( + error, + PolicyMergeError::NewBinaryWouldInheritAuthorization { + operation_index: 0, + binary_scope, + host, + .. + } if binary_scope == "binary '/usr/bin/untrusted'" && host == "mcp.example.com" + )); + } + + #[test] + fn new_binary_must_declare_every_existing_mcp_endpoint() { + let endpoint_a = mcp_endpoint( + "a.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("a")], + ); + let endpoint_b = mcp_endpoint( + "b.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("b")], + ); + let existing = rule_with_authorizations( + "existing", + vec![endpoint_a.clone(), endpoint_b], + &["/usr/bin/trusted"], + ); + let incoming = + rule_with_authorizations("existing", vec![endpoint_a], &["/usr/bin/untrusted"]); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("declaring only one endpoint must not grant the second endpoint"); + + assert!(matches!( + error, + PolicyMergeError::NewBinaryWouldInheritAuthorization { host, .. } + if host == "b.example.com" + )); + } + + #[test] + fn existing_binary_scope_must_be_declared_for_a_new_mcp_endpoint() { + let existing = rule_with_authorizations( + "existing", + vec![endpoint("rest.example.com", 443)], + &["/usr/bin/first", "/usr/bin/second"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("new-tool")], + )], + &["/usr/bin/first"], + ); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("the undeclared second binary would inherit the new MCP endpoint"); + + assert!(matches!( + error, + PolicyMergeError::ExistingBinariesWouldInheritAuthorization { host, .. } + if host == "mcp.example.com" + )); + } + + #[test] + fn existing_any_binary_scope_requires_an_incoming_any_binary_declaration() { + let existing = + rule_with_authorizations("existing", vec![endpoint("rest.example.com", 443)], &[]); + let incoming_endpoint = mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("new-tool")], + ); + let specific_incoming = rule_with_authorizations( + "existing", + vec![incoming_endpoint.clone()], + &["/usr/bin/client"], + ); + + assert!(matches!( + merge_policy( + policy_with_rule("existing", existing.clone()), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: specific_incoming, + }] + ), + Err(PolicyMergeError::ExistingBinariesWouldInheritAuthorization { .. }) + )); + + let any_binary_incoming = + rule_with_authorizations("existing", vec![incoming_endpoint], &[]); + assert!( + merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: any_binary_incoming, + }] + ) + .is_ok(), + "an explicit any-binary proposal covers the existing any-binary scope" + ); + } + + #[test] + fn l4_endpoint_promotion_to_mcp_requires_the_complete_existing_binary_scope() { + let existing = rule_with_authorizations( + "existing", + vec![endpoint("mcp.example.com", 443)], + &["/usr/bin/first", "/usr/bin/second"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("new-tool")], + )], + &["/usr/bin/first"], + ); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("the second binary did not declare the MCP promotion"); + + assert!(matches!( + error, + PolicyMergeError::ExistingBinariesWouldInheritAuthorization { + operation_index: 0, + host, + ports, + .. + } if host == "mcp.example.com" && ports == vec![443] + )); + } + + #[test] + fn l4_endpoint_promotion_to_mcp_preserves_the_declared_contract() { + let existing = rule_with_authorizations( + "existing", + vec![endpoint("mcp.example.com", 443)], + &["/usr/bin/first", "/usr/bin/second"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + Some(false), + Some(true), + 128 * 1024, + vec![mcp_tool_rule("new-tool")], + )], + &["/usr/bin/first", "/usr/bin/second"], + ); + + let result = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect("the complete binary scope explicitly accepts the MCP promotion"); + + let promoted = &result.policy.network_policies["existing"].endpoints[0]; + assert_eq!(promoted.protocol, "mcp"); + assert_eq!(promoted.json_rpc_max_body_bytes, 128 * 1024); + assert_eq!( + promoted.mcp, + Some(McpOptions { + strict_tool_names: Some(false), + allow_all_known_mcp_methods: Some(true), + }) + ); + assert_eq!(promoted.rules, vec![mcp_tool_rule("new-tool")]); + } + + #[test] + fn established_rest_endpoint_cannot_be_reinterpreted_as_mcp() { + let existing_endpoint = NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/")], + ..endpoint("api.example.com", 443) + }; + let existing = + rule_with_authorizations("existing", vec![existing_endpoint], &["/usr/bin/client"]); + let incoming = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "api.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("tool")], + )], + &["/usr/bin/client"], + ); + + assert!(matches!( + merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }] + ), + Err(PolicyMergeError::McpContractConflict { + existing, incoming, .. + }) if existing == "non-mcp(protocol='rest')" && incoming.starts_with("mcp(") + )); + } + + #[test] + fn mcp_overlap_uses_effective_defaults_and_rejects_body_limit_changes() { + let existing_endpoint = mcp_endpoint( + "mcp.example.com", + &[443, 8443], + None, + None, + 0, + vec![mcp_tool_rule("tool")], + ); + let explicit_defaults = mcp_endpoint( + "mcp.example.com", + &[8443], + Some(true), + Some(false), + DEFAULT_JSON_RPC_MAX_BODY_BYTES, + vec![mcp_tool_rule("tool")], + ); + let existing = + rule_with_authorizations("existing", vec![existing_endpoint], &["/usr/bin/client"]); + let equivalent = rule_with_authorizations( + "incoming", + vec![explicit_defaults.clone()], + &["/usr/bin/client"], + ); + assert!( + merge_policy( + policy_with_rule("existing", existing.clone()), + &[PolicyMergeOp::AddRule { + rule_name: "incoming".to_string(), + rule: equivalent, + }] + ) + .is_ok(), + "omitted MCP booleans and body limit must equal their runtime defaults" + ); + + let mut different_body_limit = explicit_defaults; + different_body_limit.json_rpc_max_body_bytes = 128 * 1024; + let conflicting = + rule_with_authorizations("incoming", vec![different_body_limit], &["/usr/bin/client"]); + assert!(matches!( + merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "incoming".to_string(), + rule: conflicting, + }] + ), + // The existing endpoint omitted the limit, so the conflict reports + // its effective default rather than the raw zero. + Err(PolicyMergeError::McpContractConflict { + port: 8443, + existing, + incoming, + .. + }) if existing.contains("max_body_bytes=65536") + && incoming.contains("max_body_bytes=131072") + )); + } + + #[test] + fn non_overlapping_mcp_endpoints_may_use_different_contracts() { + let existing = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "first.example.com", + &[443, 8443], + None, + None, + 0, + vec![mcp_tool_rule("first-tool")], + )], + &["/usr/bin/client"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "second.example.com", + &[443], + Some(false), + Some(true), + 128 * 1024, + vec![mcp_tool_rule("second-tool")], + )], + &["/usr/bin/client"], + ); + + let result = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect("contract differences on disjoint endpoints are independent"); + + assert_eq!( + result.policy.network_policies["existing"].endpoints.len(), + 2 + ); + } + + #[test] + fn policy_coverage_checks_full_mcp_matchers_ports_contract_and_defaults() { + let loaded_endpoint = mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("loaded-tool")], + ); + let loaded = policy_with_rule( + "loaded", + rule_with_authorizations( + "loaded", + vec![loaded_endpoint.clone()], + &["/usr/bin/client"], + ), + ); + + let wrong_tool = rule_with_authorizations( + "proposed", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + Some(true), + Some(false), + DEFAULT_JSON_RPC_MAX_BODY_BYTES, + vec![mcp_tool_rule("different-tool")], + )], + &["/usr/bin/client"], + ); + assert!(!policy_covers_rule(&loaded, &wrong_tool)); + + let extra_port = rule_with_authorizations( + "proposed", + vec![mcp_endpoint( + "mcp.example.com", + &[443, 8443], + None, + None, + 0, + vec![mcp_tool_rule("loaded-tool")], + )], + &["/usr/bin/client"], + ); + assert!(!policy_covers_rule(&loaded, &extra_port)); + + let equivalent_defaults = rule_with_authorizations( + "proposed", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + Some(true), + Some(false), + DEFAULT_JSON_RPC_MAX_BODY_BYTES, + vec![mcp_tool_rule("loaded-tool")], + )], + &["/usr/bin/client"], + ); + assert!(policy_covers_rule(&loaded, &equivalent_defaults)); + + let different_body = rule_with_authorizations( + "proposed", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 128 * 1024, + vec![mcp_tool_rule("loaded-tool")], + )], + &["/usr/bin/client"], + ); + assert!(!policy_covers_rule(&loaded, &different_body)); + + let mut explicit_defaults = loaded_endpoint; + explicit_defaults.tls = "passthrough".to_string(); + explicit_defaults.enforcement = "audit".to_string(); + let runtime_defaults = rule_with_authorizations( + "proposed", + vec![explicit_defaults.clone()], + &["/usr/bin/client"], + ); + assert!(policy_covers_rule(&loaded, &runtime_defaults)); + + explicit_defaults.tls = "terminate".to_string(); + let legacy_terminate = rule_with_authorizations( + "proposed", + vec![explicit_defaults.clone()], + &["/usr/bin/client"], + ); + assert!(policy_covers_rule(&loaded, &legacy_terminate)); + + explicit_defaults.tls = "skip".to_string(); + let skip_tls = rule_with_authorizations( + "proposed", + vec![explicit_defaults.clone()], + &["/usr/bin/client"], + ); + assert!(!policy_covers_rule(&loaded, &skip_tls)); + + explicit_defaults.tls.clear(); + explicit_defaults.enforcement = "enforce".to_string(); + let different_runtime_scalars = + rule_with_authorizations("proposed", vec![explicit_defaults], &["/usr/bin/client"]); + assert!(!policy_covers_rule(&loaded, &different_runtime_scalars)); + } + + /// `policy_covers_rule` answers "is my rule in effect?" for the sandbox's + /// `/wait` long-poll, so anything `merge_policy` accepts must read back as + /// covered once loaded. Otherwise the poll spins to its deadline and the + /// agent is told its approved rule never landed. + #[test] + fn merged_policy_covers_the_rule_it_merged() { + // Every field `merge_endpoint` widens rather than replaces, set on the + // existing endpoint and absent from the proposal. + let existing_endpoint = NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/health")], + deny_rules: vec![L7DenyRule { + method: "DELETE".to_string(), + path: "/*".to_string(), + ..Default::default() + }], + allowed_ips: vec!["10.0.0.1".to_string()], + allow_encoded_slash: true, + websocket_credential_rewrite: true, + request_body_credential_rewrite: true, + advisor_proposed: true, + ..endpoint("api.example.com", 443) + }; + let proposed = rule_with_authorizations( + "api", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/users")], + ..endpoint("api.example.com", 443) + }], + &["/usr/bin/client"], + ); + + let merged = merge_policy( + policy_with_rule( + "api", + rule_with_authorizations("api", vec![existing_endpoint], &["/usr/bin/client"]), + ), + &[PolicyMergeOp::AddRule { + rule_name: "api".to_string(), + rule: proposed.clone(), + }], + ) + .expect("merge must accept a proposal that declares the full binary scope"); + + assert!(policy_covers_rule(&merged.policy, &proposed)); + } + + #[test] + fn policy_coverage_requires_proposed_denies_but_allows_loaded_only_denies() { + let deny = |path: &str| L7DenyRule { + method: "GET".to_string(), + path: path.to_string(), + ..Default::default() + }; + let proposed_endpoint = NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/users")], + deny_rules: vec![deny("/users/secret")], + ..endpoint("api.example.com", 443) + }; + let proposed = rule_with_authorizations( + "proposed", + vec![proposed_endpoint.clone()], + &["/usr/bin/client"], + ); + let cover_with = |deny_rules: Vec| { + let loaded_endpoint = NetworkEndpoint { + deny_rules, + ..proposed_endpoint.clone() + }; + policy_covers_rule( + &policy_with_rule( + "loaded", + rule_with_authorizations("loaded", vec![loaded_endpoint], &["/usr/bin/client"]), + ), + &proposed, + ) + }; + + assert!( + !cover_with(Vec::new()), + "a proposed deny that is not loaded means the proposal is not in effect" + ); + assert!( + cover_with(vec![deny("/users/secret")]), + "the proposed deny alone is coverage" + ); + assert!( + cover_with(vec![deny("/users/secret"), deny("/admin")]), + "a deny carried by the loaded endpoint from an earlier merge must not block coverage" + ); + } + + #[test] + fn policy_coverage_ignores_endpoint_advisor_provenance() { + let loaded_endpoint = endpoint("api.example.com", 443); + let mut proposed_endpoint = loaded_endpoint.clone(); + proposed_endpoint.advisor_proposed = true; + let loaded = policy_with_rule( + "loaded", + rule_with_authorizations("loaded", vec![loaded_endpoint], &["/usr/bin/client"]), + ); + let proposed = + rule_with_authorizations("proposed", vec![proposed_endpoint], &["/usr/bin/client"]); + + assert!(policy_covers_rule(&loaded, &proposed)); + } + + #[test] + fn explicit_endpoint_provenance_wins_in_either_merge_order() { + let explicit_endpoint = endpoint("api.example.com", 443); + let mut proposed_endpoint = explicit_endpoint.clone(); + proposed_endpoint.advisor_proposed = true; + + for (existing_endpoint, incoming_endpoint) in [ + (explicit_endpoint.clone(), proposed_endpoint.clone()), + (proposed_endpoint, explicit_endpoint), + ] { + let incoming = + rule_with_authorizations("api", vec![incoming_endpoint], &["/usr/bin/client"]); + let merged = merge_policy( + policy_with_rule( + "api", + rule_with_authorizations("api", vec![existing_endpoint], &["/usr/bin/client"]), + ), + &[PolicyMergeOp::AddRule { + rule_name: "api".to_string(), + rule: incoming.clone(), + }], + ) + .expect("compatible explicit and advisor rules should merge"); + + let endpoint = &merged.policy.network_policies["api"].endpoints[0]; + assert!(!endpoint.advisor_proposed); + assert!(policy_covers_rule(&merged.policy, &incoming)); + } + } + + #[test] + fn policy_coverage_checks_every_binary_endpoint_pair_across_rule_union() { + let proposed = rule_with_authorizations( + "proposed", + vec![ + endpoint("a.example.com", 443), + endpoint("b.example.com", 443), + ], + &["/usr/bin/first", "/usr/bin/second"], + ); + let mut loaded = restrictive_default_policy(); + for (name, host, binary_path) in [ + ("a-first", "a.example.com", "/usr/bin/first"), + ("a-second", "a.example.com", "/usr/bin/second"), + ("b-first", "b.example.com", "/usr/bin/first"), + ] { + loaded.network_policies.insert( + name.to_string(), + rule_with_authorizations(name, vec![endpoint(host, 443)], &[binary_path]), + ); + } + + assert!( + !policy_covers_rule(&loaded, &proposed), + "the missing b.example.com × /usr/bin/second pair must fail coverage" + ); + + loaded.network_policies.insert( + "b-second".to_string(), + rule_with_authorizations( + "b-second", + vec![endpoint("b.example.com", 443)], + &["/usr/bin/second"], + ), + ); + assert!( + policy_covers_rule(&loaded, &proposed), + "separate loaded rules may jointly cover the complete Cartesian product" + ); + } + + #[test] + fn add_rule_merges_l7_fields_into_existing_endpoint() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + ); + + let incoming = NetworkPolicyRule { + name: "incoming".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![rest_rule("GET", "/repos/**")], + ..Default::default() + }], + binaries: vec![binary("/usr/bin/curl"), binary("/usr/bin/gh")], + }; + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "allow_api_github_com_443".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + let rule = &result.policy.network_policies["existing"]; + let endpoint = &rule.endpoints[0]; + assert_eq!(endpoint.protocol, "rest"); + assert_eq!(endpoint.enforcement, "enforce"); + assert_eq!(endpoint.rules.len(), 1); + assert_eq!(rule.binaries.len(), 2); + } + + #[test] + fn add_rule_user_binary_clears_advisor_marker_for_same_path() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![advisor_binary("/usr/bin/curl")], + }, + ); + + let incoming = NetworkPolicyRule { + name: "incoming".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + let rule = &result.policy.network_policies["existing"]; + assert_eq!(rule.binaries.len(), 1); + #[allow(deprecated)] + { + assert!(!rule.binaries[0].harness); + } + } + + #[test] + fn add_rule_duplicate_binaries_prefer_user_declared_marker() { + let incoming = NetworkPolicyRule { + name: "incoming".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![ + advisor_binary("/usr/bin/curl"), + NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }, + ], + }; + + let result = merge_policy( + restrictive_default_policy(), + &[PolicyMergeOp::AddRule { + rule_name: "github".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + let rule = &result.policy.network_policies["github"]; + assert_eq!(rule.binaries.len(), 1); + #[allow(deprecated)] + { + assert!(!rule.binaries[0].harness); + } + } + + #[test] + fn add_rule_preserves_advisor_endpoint_marker_when_binary_is_deduped() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "app-api".to_string(), + NetworkPolicyRule { + name: "app-api".to_string(), + endpoints: vec![endpoint("api.example.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/python".to_string(), + ..Default::default() + }], + }, + ); + + let incoming = NetworkPolicyRule { + name: "app-api".to_string(), + endpoints: vec![NetworkEndpoint { + host: "internal-admin.local".to_string(), + port: 443, + ports: vec![443], + advisor_proposed: true, + ..Default::default() + }], + binaries: vec![advisor_binary("/usr/bin/python")], + }; + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "app-api".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + let rule = &result.policy.network_policies["app-api"]; + assert_eq!(rule.binaries.len(), 1, "binary should still dedupe"); + #[allow(deprecated)] + { + assert!( + !rule.binaries[0].harness, + "existing user binary provenance should be retained" + ); + } + let internal_endpoint = rule + .endpoints + .iter() + .find(|endpoint| endpoint.host == "internal-admin.local") + .expect("advisor endpoint should be appended"); + assert!( + internal_endpoint.advisor_proposed, + "endpoint provenance must survive merge even when binary provenance is deduped" + ); + } + + #[test] + fn add_rule_merges_websocket_credential_rewrite_flag() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![NetworkEndpoint { + host: "realtime.example.com".to_string(), + port: 443, + ports: vec![443], + protocol: "websocket".to_string(), + access: "read-write".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ); + + let incoming = NetworkPolicyRule { + name: "incoming".to_string(), + endpoints: vec![NetworkEndpoint { + host: "realtime.example.com".to_string(), + port: 443, + ports: vec![443], + protocol: "websocket".to_string(), + websocket_credential_rewrite: true, + ..Default::default() + }], + ..Default::default() + }; + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "allow_realtime_example_com_443".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + let endpoint = &result.policy.network_policies["existing"].endpoints[0]; + assert!(endpoint.websocket_credential_rewrite); + } + + #[test] + fn add_rule_merges_request_body_credential_rewrite_flag() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![NetworkEndpoint { + host: "slack.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + access: "read-write".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ); + + let incoming = NetworkPolicyRule { + name: "incoming".to_string(), + endpoints: vec![NetworkEndpoint { + host: "slack.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + request_body_credential_rewrite: true, + ..Default::default() + }], + ..Default::default() + }; + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "allow_slack_com_443".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + let endpoint = &result.policy.network_policies["existing"].endpoints[0]; + assert!(endpoint.request_body_credential_rewrite); + } + + #[test] + fn add_rule_merges_allow_uninspected_credentials_flag() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.vendor.example".to_string(), + port: 443, + ports: vec![443], + ..Default::default() + }], + ..Default::default() + }, + ); + + let incoming = NetworkPolicyRule { + name: "incoming".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.vendor.example".to_string(), + port: 443, + ports: vec![443], + allow_uninspected_credentials: true, + ..Default::default() + }], + ..Default::default() + }; + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "allow_api_vendor_example_443".to_string(), + rule: incoming, + }], + ) + .expect("merge should succeed"); + + let endpoint = &result.policy.network_policies["existing"].endpoints[0]; + assert!(endpoint.allow_uninspected_credentials); + } + + #[test] + fn add_allow_expands_access_preset() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "github".to_string(), + NetworkPolicyRule { + name: "github".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + access: "read-only".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ); + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddAllowRules { + host: "api.github.com".to_string(), + port: 443, + rules: vec![rest_rule("POST", "/repos/*/issues")], + }], + ) + .expect("merge should succeed"); + + let endpoint = &result.policy.network_policies["github"].endpoints[0]; + assert!(endpoint.access.is_empty()); + assert_eq!(endpoint.rules.len(), 4); + assert!(result.warnings.iter().any(|warning| matches!( + warning, + PolicyMergeWarning::ExpandedAccessPreset { access, .. } if access == "read-only" + ))); + } + + #[test] + fn add_allow_expands_websocket_access_preset() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "realtime".to_string(), + NetworkPolicyRule { + name: "realtime".to_string(), + endpoints: vec![NetworkEndpoint { + host: "realtime.example.com".to_string(), + port: 443, + ports: vec![443], + protocol: "websocket".to_string(), + access: "read-write".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ); + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddAllowRules { + host: "realtime.example.com".to_string(), + port: 443, + rules: vec![rest_rule("WEBSOCKET_TEXT", "/rooms/private/**")], + }], + ) + .expect("merge should succeed"); + + let endpoint = &result.policy.network_policies["realtime"].endpoints[0]; + assert!(endpoint.access.is_empty()); + assert_eq!(endpoint.rules.len(), 3); + assert!(endpoint.rules.contains(&rest_rule("GET", "**"))); + assert!(endpoint.rules.contains(&rest_rule("WEBSOCKET_TEXT", "**"))); + assert!( + endpoint + .rules + .contains(&rest_rule("WEBSOCKET_TEXT", "/rooms/private/**")) + ); + assert!(!endpoint.rules.contains(&rest_rule("POST", "**"))); + assert!(result.warnings.iter().any(|warning| matches!( + warning, + PolicyMergeWarning::ExpandedAccessPreset { access, .. } if access == "read-write" + ))); + } + + #[test] + fn add_deny_accepts_websocket_protocol() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "realtime".to_string(), + NetworkPolicyRule { + name: "realtime".to_string(), + endpoints: vec![NetworkEndpoint { + host: "realtime.example.com".to_string(), + port: 443, + ports: vec![443], + protocol: "websocket".to_string(), + access: "read-write".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ); + + let result = merge_policy( + policy, + &[PolicyMergeOp::AddDenyRules { + host: "realtime.example.com".to_string(), + port: 443, + deny_rules: vec![L7DenyRule { + method: "WEBSOCKET_TEXT".to_string(), + path: "/admin/**".to_string(), + ..Default::default() + }], + }], + ) + .expect("merge should succeed"); + + let endpoint = &result.policy.network_policies["realtime"].endpoints[0]; + assert_eq!(endpoint.deny_rules.len(), 1); + assert_eq!(endpoint.deny_rules[0].method, "WEBSOCKET_TEXT"); + assert_eq!(endpoint.deny_rules[0].path, "/admin/**"); + } + + #[test] + fn add_deny_rejects_unsupported_protocol() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "db".to_string(), + NetworkPolicyRule { + name: "db".to_string(), + endpoints: vec![NetworkEndpoint { + host: "db.example.com".to_string(), + port: 5432, + ports: vec![5432], + protocol: "sql".to_string(), + access: "full".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ); + + let error = merge_policy( + policy, + &[PolicyMergeOp::AddDenyRules { + host: "db.example.com".to_string(), + port: 5432, + deny_rules: vec![L7DenyRule { + method: "POST".to_string(), + path: "/admin".to_string(), + ..Default::default() + }], + }], + ) + .expect_err("merge should fail"); + + assert!(matches!( + error, + PolicyMergeError::UnsupportedEndpointProtocol { protocol, .. } if protocol == "sql" + )); + } + + #[test] + fn remove_endpoint_drops_only_requested_port() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "multi".to_string(), + NetworkPolicyRule { + name: "multi".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 80, + ports: vec![80, 443], + ..Default::default() + }], + ..Default::default() + }, + ); + + let result = merge_policy( + policy, + &[PolicyMergeOp::RemoveEndpoint { + rule_name: None, + host: "api.example.com".to_string(), + port: 443, + }], + ) + .expect("merge should succeed"); + + let endpoint = &result.policy.network_policies["multi"].endpoints[0]; + assert_eq!(endpoint.ports, vec![80]); + assert_eq!(endpoint.port, 80); + } + + #[test] + fn remove_binary_removes_rule_when_last_binary_is_deleted() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "github".to_string(), + NetworkPolicyRule { + name: "github".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/gh".to_string(), + ..Default::default() + }], + }, + ); + + let result = merge_policy( + policy, + &[PolicyMergeOp::RemoveBinary { + rule_name: "github".to_string(), + binary_path: "/usr/bin/gh".to_string(), + }], + ) + .expect("merge should succeed"); + + assert!(!result.policy.network_policies.contains_key("github")); + } + + #[test] + fn policy_covers_rule_returns_true_when_merged_rule_present() { + let proposed = NetworkPolicyRule { + name: "agent_proposed".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + let merged = merge_policy( + restrictive_default_policy(), + &[PolicyMergeOp::AddRule { + rule_name: "allow_api_github_com_443".to_string(), + rule: proposed.clone(), + }], + ) + .expect("merge should succeed"); + + assert!(policy_covers_rule(&merged.policy, &proposed)); + } + + #[test] + fn policy_covers_rule_returns_false_when_unrelated_rule_present() { + let proposed = NetworkPolicyRule { + name: "agent_proposed".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + // Merge an *unrelated* rule for a different host. The proposed rule + // for api.github.com is still not present — this is John's + // "false-wakeup" case: an unrelated policy reload must not signal + // that the agent's rule is loaded. + let merged = merge_policy( + restrictive_default_policy(), + &[PolicyMergeOp::AddRule { + rule_name: "allow_api_example_com_443".to_string(), + rule: rule_with_endpoint("unrelated", "api.example.com", 443), + }], + ) + .expect("merge should succeed"); + + assert!(!policy_covers_rule(&merged.policy, &proposed)); + } + + #[test] + fn policy_covers_rule_handles_merge_into_existing_endpoint() { + // The merge logic folds a new rule into an existing rule when their + // endpoints overlap, even under a different network_policies key. + // Coverage must survive that fold — name-keyed checks would miss it. + let proposed = NetworkPolicyRule { + name: "agent_proposed".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "preexisting_github".to_string(), + NetworkPolicyRule { + name: "preexisting_github".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/git".to_string(), + ..Default::default() + }], + }, + ); + + let merged = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "allow_api_github_com_443".to_string(), + rule: proposed.clone(), + }], + ) + .expect("merge should succeed"); + + assert!( + !merged + .policy + .network_policies + .contains_key("allow_api_github_com_443"), + "proposed rule should have been folded into the existing key" + ); + assert!(policy_covers_rule(&merged.policy, &proposed)); + } + + #[test] + fn policy_covers_rule_returns_false_when_binary_missing() { + let proposed = NetworkPolicyRule { + name: "agent_proposed".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + // Endpoint exists in the policy but with a *different* binary. The + // agent's retry would still be denied; reload coverage should + // reflect that. + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/git".to_string(), + ..Default::default() + }], + }, + ); + + assert!(!policy_covers_rule(&policy, &proposed)); + } + + #[test] + fn policy_covers_rule_returns_false_for_empty_proposed_endpoints() { + // Defensive: a rule with no endpoints carries no signal we can match + // on, so coverage is never true. + let proposed = NetworkPolicyRule::default(); + let policy = restrictive_default_policy(); + assert!(!policy_covers_rule(&policy, &proposed)); + } + + #[test] + fn policy_covers_rule_returns_false_when_proposed_l7_method_not_loaded() { + // John's false-wakeup mode at L7: the supervisor has an + // overlapping endpoint loaded (e.g. read-only GET), but the + // chunk's proposed PUT method is not in the merged endpoint's + // rules yet. Coverage must NOT return true here, or the agent + // retries the PUT and hits another policy_denied. + let proposed = NetworkPolicyRule { + name: "agent_put".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing_readonly".to_string(), + NetworkPolicyRule { + name: "existing_readonly".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/repos/foo/bar/contents/x.md")], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + ); + + assert!( + !policy_covers_rule(&policy, &proposed), + "endpoint overlaps but L7 PUT not loaded yet; must not signal coverage" + ); + } + + #[test] + fn policy_covers_rule_returns_true_after_l7_merge_lands() { + // Same setup as above, but with the proposed L7 rule merged in. + // Coverage must now return true. + let proposed = NetworkPolicyRule { + name: "agent_put".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ports: vec![443], + protocol: "rest".to_string(), + rules: vec![ + rest_rule("GET", "/repos/foo/bar/contents/x.md"), + rest_rule("PUT", "/repos/foo/bar/contents/x.md"), + ], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + ); + + assert!(policy_covers_rule(&policy, &proposed)); + } + + #[test] + fn policy_covers_rule_returns_true_for_l4_only_proposed_when_endpoint_present() { + // A chunk that targets a non-REST surface (no L7 rules) needs + // only the L4 endpoint match to be considered covered. Empty + // proposed.rules must not be treated as "no method matches". + let proposed = NetworkPolicyRule { + name: "ssh_clone".to_string(), + endpoints: vec![NetworkEndpoint { + host: "github.com".to_string(), + port: 22, + ports: vec![22], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/git".to_string(), + ..Default::default() + }], + }; + + let merged = merge_policy( + restrictive_default_policy(), + &[PolicyMergeOp::AddRule { + rule_name: "allow_github_com_22".to_string(), + rule: proposed.clone(), + }], + ) + .expect("merge should succeed"); + + assert!(policy_covers_rule(&merged.policy, &proposed)); + } + + #[test] + fn policy_covers_rule_requires_loaded_any_binary_scope_for_any_binary_proposal() { + // A proposed rule with no binaries is the "any binary" shape. + // A specific loaded binary list cannot cover that Cartesian scope. + let proposed = NetworkPolicyRule { + name: "any_binary_rule".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![], + }; + + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "existing".to_string(), + NetworkPolicyRule { + name: "existing".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + ); + + assert!( + !policy_covers_rule(&policy, &proposed), + "a specific loaded binary list must not cover an any-binary proposal" + ); + + policy + .network_policies + .get_mut("existing") + .expect("existing rule") + .binaries + .clear(); + assert!(policy_covers_rule(&policy, &proposed)); + } + + #[test] + fn add_rule_without_existing_match_inserts_requested_key() { + let policy = restrictive_default_policy(); + let result = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "allow_api_example_com_443".to_string(), + rule: rule_with_endpoint("custom", "api.example.com", 443), + }], + ) + .expect("merge should succeed"); + + assert!( + result + .policy + .network_policies + .contains_key("allow_api_example_com_443") + ); + } + + /// Provider-injected rules (`_provider_*`) are excluded from the + /// endpoint-overlap fallback: an agent chunk for the same `(host, port)` + /// as a provider rule lands as its own key instead of being merged into + /// the provider's rule. This keeps agent contributions honestly narrow + /// (no silent expansion via the provider rule's `access` shorthand) and + /// preserves binary-list separation. + #[test] + fn add_rule_does_not_merge_agent_chunk_into_provider_rule() { + use crate::compose::{ProviderPolicyLayer, compose_effective_policy}; + use openshell_core::proto::SandboxPolicy; + + // Compose a policy where the github provider profile contributes a + // `_provider_*` rule for api.github.com with `access: read-write` + // and gh/git binaries. + let provider_rule = NetworkPolicyRule { + name: "_provider_work_github".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "read-write".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/gh".to_string(), + ..Default::default() + }], + }; + let composed = compose_effective_policy( + &SandboxPolicy::default(), + &[ProviderPolicyLayer { + rule_name: "_provider_work_github".to_string(), + rule: provider_rule, + }], + ); assert!( - internal_endpoint.advisor_proposed, - "endpoint provenance must survive merge even when binary provenance is deduped" + composed + .network_policies + .contains_key("_provider_work_github"), + "precondition: provider rule must be present in baseline" + ); + + // Agent submits a narrow PUT rule targeting the same host/port via + // curl. Without the filter, this would merge into the provider rule. + let agent_rule = NetworkPolicyRule { + name: "github_contents_put".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![rest_rule("PUT", "/repos/owner/repo/contents/file.md")], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + let result = merge_policy( + composed, + &[PolicyMergeOp::AddRule { + rule_name: "github_contents_put".to_string(), + rule: agent_rule, + }], + ) + .expect("merge should succeed"); + + // The agent's chunk lands as its own rule key. + assert!( + result + .policy + .network_policies + .contains_key("github_contents_put"), + "agent chunk must land as a separate rule (not merged into the provider rule); \ + got keys: {:?}", + result.policy.network_policies.keys().collect::>() + ); + + // The provider rule is unchanged: still has only gh as a binary + // (no silent broadening), still has the read-write shorthand + // intact (no preset expansion into wildcard paths). + let provider_rule_after = result + .policy + .network_policies + .get("_provider_work_github") + .expect("provider rule must still be present"); + assert_eq!( + provider_rule_after.binaries.len(), + 1, + "provider rule's binary list must NOT have been merged with the agent's binaries" + ); + assert_eq!(provider_rule_after.binaries[0].path, "/usr/bin/gh"); + assert_eq!( + provider_rule_after.endpoints[0].access, "read-write", + "provider rule's `access` shorthand must remain intact" + ); + assert!( + provider_rule_after.endpoints[0].rules.is_empty(), + "provider rule must NOT have had its access expanded into explicit wildcard rules" ); + + // The agent's rule retains its narrow scope. + let agent_rule_after = &result.policy.network_policies["github_contents_put"]; + assert_eq!(agent_rule_after.binaries[0].path, "/usr/bin/curl"); + assert_eq!(agent_rule_after.endpoints[0].rules.len(), 1); } + /// Non-provider rules still merge by endpoint overlap when the incoming + /// `rule_name` doesn't match an existing key. This preserves the + /// long-standing behavior for user-authored and mechanistic chunks. #[test] - fn add_rule_merges_websocket_credential_rewrite_flag() { + fn add_rule_still_merges_user_chunk_into_user_rule_by_endpoint_overlap() { let mut policy = restrictive_default_policy(); policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![NetworkEndpoint { - host: "realtime.example.com".to_string(), - port: 443, - ports: vec![443], - protocol: "websocket".to_string(), - access: "read-write".to_string(), - ..Default::default() - }], - ..Default::default() - }, + "custom_github".to_string(), + rule_with_endpoint("custom_github", "api.github.com", 443), ); let incoming = NetworkPolicyRule { - name: "incoming".to_string(), - endpoints: vec![NetworkEndpoint { - host: "realtime.example.com".to_string(), - port: 443, - ports: vec![443], - protocol: "websocket".to_string(), - websocket_credential_rewrite: true, + name: "ignored_when_merging".to_string(), + endpoints: vec![endpoint("api.github.com", 443)], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), ..Default::default() }], - ..Default::default() }; - let result = merge_policy( policy, &[PolicyMergeOp::AddRule { - rule_name: "allow_realtime_example_com_443".to_string(), + rule_name: "different_name".to_string(), rule: incoming, }], ) .expect("merge should succeed"); - let endpoint = &result.policy.network_policies["existing"].endpoints[0]; - assert!(endpoint.websocket_credential_rewrite); + // No new rule entry was created — the chunk merged into the + // existing user rule via endpoint overlap. + assert!( + !result + .policy + .network_policies + .contains_key("different_name"), + "user-authored rule overlap should still merge (no new key); \ + got keys: {:?}", + result.policy.network_policies.keys().collect::>() + ); + // The existing rule declares no binaries, which authorizes any binary. + // Absorbing the incoming path would make the list non-empty and revoke + // every other process, so the wider scope is kept and reported instead. + let merged = &result.policy.network_policies["custom_github"]; + assert!( + merged.binaries.is_empty(), + "an any-binary rule must not be narrowed by an additive operation; got {:?}", + merged.binaries + ); + assert!( + result.warnings.iter().any(|warning| matches!( + warning, + PolicyMergeWarning::ExistingAnyBinaryScopeRetained { rule_name, incoming } + if rule_name == "custom_github" + && incoming == &["/usr/bin/curl".to_string()] + )), + "got warnings: {:?}", + result.warnings + ); + } + + fn endpoint_with_ports(host: &str, ports: &[u32]) -> NetworkEndpoint { + NetworkEndpoint { + host: host.to_string(), + port: ports.first().copied().unwrap_or_default(), + ports: ports.to_vec(), + ..Default::default() + } } + /// A proposal that omits a field the merge retains still has to read back as + /// covered. The merge keeps the loaded value and reports success, so + /// requiring the proposal to match the loaded value would leave the + /// `policy.local /wait` long poll spinning against a policy that did load. #[test] - fn add_rule_merges_request_body_credential_rewrite_flag() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![NetworkEndpoint { - host: "slack.com".to_string(), - port: 443, - ports: vec![443], + fn coverage_treats_an_omitted_retained_field_as_unspecified() { + for (field, loaded_endpoint) in [ + ( + "enforcement", + NetworkEndpoint { + enforcement: "enforce".to_string(), + ..endpoint("api.example.com", 443) + }, + ), + ( + "protocol", + NetworkEndpoint { protocol: "rest".to_string(), - access: "read-write".to_string(), - ..Default::default() + ..endpoint("api.example.com", 443) + }, + ), + ( + "tls", + NetworkEndpoint { + tls: "skip".to_string(), + ..endpoint("api.example.com", 443) + }, + ), + ] { + let existing = + rule_with_authorizations("existing", vec![loaded_endpoint], &["/usr/bin/trusted"]); + // The proposal declares the whole binary scope and leaves the + // retained field unset, so the merge accepts it unchanged. + let proposal = rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/trusted"], + ); + + let result = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: proposal.clone(), }], - ..Default::default() - }, + ) + .unwrap_or_else(|error| panic!("omitting {field} should merge cleanly: {error}")); + + assert!( + policy_covers_rule(&result.policy, &proposal), + "a proposal omitting {field} merged cleanly, so it must read back as covered" + ); + } + } + + /// A rule's port list is a set of independent authorizations, so the loaded + /// policy may spread one proposed endpoint's ports across several rules. + #[test] + fn coverage_resolves_each_port_across_the_loaded_rule_union() { + let mut policy = policy_with_rule( + "https", + rule_with_authorizations( + "https", + vec![endpoint_with_ports("api.example.com", &[443])], + &["/usr/bin/client"], + ), + ); + policy.network_policies.insert( + "alt_https".to_string(), + rule_with_authorizations( + "alt_https", + vec![endpoint_with_ports("api.example.com", &[8443])], + &["/usr/bin/client"], + ), ); - let incoming = NetworkPolicyRule { - name: "incoming".to_string(), - endpoints: vec![NetworkEndpoint { - host: "slack.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - request_body_credential_rewrite: true, + let proposed = rule_with_authorizations( + "combined", + vec![endpoint_with_ports("api.example.com", &[443, 8443])], + &["/usr/bin/client"], + ); + + assert!( + policy_covers_rule(&policy, &proposed), + "two loaded rules covering one port each authorize the same traffic \ + as one rule covering both" + ); + } + + /// An endpoint that declares no port at all still needs a matching loaded + /// endpoint. Iterating an empty port list would make the coverage `all()` + /// vacuously true and report an unmatched proposal as covered. + #[test] + fn coverage_rejects_a_portless_endpoint_no_loaded_rule_matches() { + let policy = policy_with_rule( + "existing", + rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/client"], + ), + ); + let proposed = rule_with_authorizations( + "other", + vec![NetworkEndpoint { + host: "unrelated.example.com".to_string(), ..Default::default() }], - ..Default::default() - }; + &["/usr/bin/client"], + ); + + assert!( + !policy_covers_rule(&policy, &proposed), + "no loaded rule mentions the proposed host, so coverage must fail closed" + ); + } + + /// A complete declaration may arrive split across several incoming + /// endpoints. Requiring one declaration to cover the whole merged endpoint + /// would reject a new binary that did declare every port it will reach. + #[test] + fn new_binary_may_declare_one_endpoints_ports_across_several_declarations() { + let existing = rule_with_authorizations( + "existing", + vec![endpoint_with_ports("api.example.com", &[443, 8443])], + &["/usr/bin/trusted"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![ + endpoint_with_ports("api.example.com", &[443]), + endpoint_with_ports("api.example.com", &[8443]), + ], + &["/usr/bin/trusted", "/usr/bin/second"], + ); let result = merge_policy( - policy, + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect("the new binary declared both ports, just in separate endpoints"); + + let merged = &result.policy.network_policies["existing"]; + assert!(merged.binaries.iter().any(|b| b.path == "/usr/bin/second")); + } + + /// The complement: a split declaration that misses a port is still an + /// implicit grant and must be rejected, naming the port left undeclared. + #[test] + fn new_binary_split_declaration_must_still_cover_every_port() { + let existing = rule_with_authorizations( + "existing", + vec![endpoint_with_ports("api.example.com", &[443, 8443])], + &["/usr/bin/trusted"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![endpoint_with_ports("api.example.com", &[443])], + &["/usr/bin/trusted", "/usr/bin/second"], + ); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("port 8443 was never declared for the new binary"); + + assert!(matches!( + error, + PolicyMergeError::NewBinaryWouldInheritAuthorization { ports, .. } + if ports == vec![8443] + )); + } + + /// An incoming empty binary list means any binary, which widens a + /// restricted rule to every binary on the system. It has to declare the + /// rule's whole endpoint scope first, exactly like a new concrete path. + #[test] + fn any_binary_proposal_cannot_inherit_undeclared_endpoints() { + let existing = rule_with_authorizations( + "existing", + vec![ + endpoint("api.example.com", 443), + endpoint("admin.example.com", 443), + ], + &["/usr/bin/trusted"], + ); + let incoming = + rule_with_authorizations("existing", vec![endpoint("api.example.com", 443)], &[]); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("any-binary scope would reach the undeclared admin endpoint"); + + assert!(matches!( + error, + PolicyMergeError::NewBinaryWouldInheritAuthorization { binary_scope, host, .. } + if binary_scope == ANY_BINARY_SCOPE && host == "admin.example.com" + )); + } + + /// Once the declaration is complete the promotion has to be applied. + /// Appending an empty binary list would leave the restricted scope in place, + /// so the operation would report success while authorizing nothing it asked + /// for and coverage would never converge. + #[test] + fn any_binary_proposal_replaces_a_restricted_scope_once_fully_declared() { + let existing = rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/trusted"], + ); + let incoming = + rule_with_authorizations("existing", vec![endpoint("api.example.com", 443)], &[]); + + let result = merge_policy( + policy_with_rule("existing", existing), &[PolicyMergeOp::AddRule { - rule_name: "allow_slack_com_443".to_string(), - rule: incoming, + rule_name: "existing".to_string(), + rule: incoming.clone(), }], ) - .expect("merge should succeed"); + .expect("the proposal declared the rule's only endpoint"); - let endpoint = &result.policy.network_policies["existing"].endpoints[0]; - assert!(endpoint.request_body_credential_rewrite); + assert!( + result.policy.network_policies["existing"] + .binaries + .is_empty(), + "the any-binary scope the operation asked for must be applied" + ); + assert!( + policy_covers_rule(&result.policy, &incoming), + "an applied any-binary scope must read back as covered" + ); } + /// The overlap fallback is a convenience for incremental refinement, not + /// something the operation requested. When folding would grant undeclared + /// authorization, the proposal keeps its own rule name instead, which + /// authorizes exactly the product it declared. Without this the documented + /// "put the new authorization in a separate rule" remediation is + /// unreachable for any endpoint that overlaps an existing rule. #[test] - fn add_allow_expands_access_preset() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "github".to_string(), - NetworkPolicyRule { - name: "github".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - access: "read-only".to_string(), - ..Default::default() - }], - ..Default::default() - }, + fn overlapping_partial_authorization_keeps_its_requested_rule_name() { + let existing = rule_with_authorizations( + "existing", + vec![ + endpoint("api.example.com", 443), + endpoint("admin.example.com", 443), + ], + &["/usr/bin/trusted"], ); let result = merge_policy( - policy, - &[PolicyMergeOp::AddAllowRules { - host: "api.github.com".to_string(), - port: 443, - rules: vec![rest_rule("POST", "/repos/*/issues")], + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "narrow_grant".to_string(), + rule: rule_with_authorizations( + "narrow_grant", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/limited"], + ), }], ) - .expect("merge should succeed"); + .expect("a partial grant must land on its own rule rather than be rejected"); - let endpoint = &result.policy.network_policies["github"].endpoints[0]; - assert!(endpoint.access.is_empty()); - assert_eq!(endpoint.rules.len(), 4); - assert!(result.warnings.iter().any(|warning| matches!( - warning, - PolicyMergeWarning::ExpandedAccessPreset { access, .. } if access == "read-only" - ))); + let narrow = result + .policy + .network_policies + .get("narrow_grant") + .expect("the requested key must be preserved when folding would widen"); + assert_eq!(narrow.binaries.len(), 1); + assert_eq!(narrow.endpoints.len(), 1); + + // Skipping the fold has to be visible: the operator asked for one rule + // and got two, and later host-and-port operations now have two + // candidate rules to resolve against. + assert!( + result.warnings.iter().any(|warning| matches!( + warning, + PolicyMergeWarning::KeptRequestedRuleNameToAvoidWidening { + rule_name, + overlapping_rule_name, + .. + } if rule_name == "narrow_grant" && overlapping_rule_name == "existing" + )), + "got warnings: {:?}", + result.warnings + ); + + // The existing rule keeps its own product untouched: the limited binary + // never gains the admin endpoint. + let untouched = &result.policy.network_policies["existing"]; + assert_eq!(untouched.binaries.len(), 1); + assert!( + untouched + .binaries + .iter() + .all(|b| b.path != "/usr/bin/limited") + ); } + /// Folding stays the default when it grants nothing new, so incremental + /// refinement under a fresh rule name still consolidates. #[test] - fn add_allow_expands_websocket_access_preset() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "realtime".to_string(), - NetworkPolicyRule { - name: "realtime".to_string(), - endpoints: vec![NetworkEndpoint { - host: "realtime.example.com".to_string(), - port: 443, - ports: vec![443], - protocol: "websocket".to_string(), - access: "read-write".to_string(), - ..Default::default() - }], - ..Default::default() - }, + fn overlapping_complete_authorization_still_folds_into_the_existing_rule() { + let existing = rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/trusted"], ); let result = merge_policy( - policy, - &[PolicyMergeOp::AddAllowRules { - host: "realtime.example.com".to_string(), - port: 443, - rules: vec![rest_rule("WEBSOCKET_TEXT", "/rooms/private/**")], + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "refinement".to_string(), + rule: rule_with_authorizations( + "refinement", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/trusted", "/usr/bin/second"], + ), }], ) - .expect("merge should succeed"); + .expect("the operation declared the rule's whole scope"); - let endpoint = &result.policy.network_policies["realtime"].endpoints[0]; - assert!(endpoint.access.is_empty()); - assert_eq!(endpoint.rules.len(), 3); - assert!(endpoint.rules.contains(&rest_rule("GET", "**"))); - assert!(endpoint.rules.contains(&rest_rule("WEBSOCKET_TEXT", "**"))); assert!( - endpoint - .rules - .contains(&rest_rule("WEBSOCKET_TEXT", "/rooms/private/**")) + !result.policy.network_policies.contains_key("refinement"), + "a complete declaration should still fold into the overlapping rule" ); - assert!(!endpoint.rules.contains(&rest_rule("POST", "**"))); - assert!(result.warnings.iter().any(|warning| matches!( - warning, - PolicyMergeWarning::ExpandedAccessPreset { access, .. } if access == "read-write" - ))); + assert_eq!(result.policy.network_policies["existing"].binaries.len(), 2); } + /// An MCP contract conflict is not resolved by moving the authorization to + /// another rule, because the supervisor establishes one contract per host + /// and port. It must propagate even on the fallback path. #[test] - fn add_deny_accepts_websocket_protocol() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "realtime".to_string(), - NetworkPolicyRule { - name: "realtime".to_string(), - endpoints: vec![NetworkEndpoint { - host: "realtime.example.com".to_string(), - port: 443, - ports: vec![443], - protocol: "websocket".to_string(), - access: "read-write".to_string(), - ..Default::default() - }], - ..Default::default() - }, + fn overlapping_mcp_contract_conflict_is_not_deflected_to_a_separate_rule() { + let existing = rule_with_authorizations( + "existing", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 65536, + vec![mcp_tool_rule("existing-tool")], + )], + &["/usr/bin/trusted"], ); - let result = merge_policy( - policy, - &[PolicyMergeOp::AddDenyRules { - host: "realtime.example.com".to_string(), - port: 443, - deny_rules: vec![L7DenyRule { - method: "WEBSOCKET_TEXT".to_string(), - path: "/admin/**".to_string(), - ..Default::default() - }], + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "second_contract".to_string(), + rule: rule_with_authorizations( + "second_contract", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 131_072, + vec![mcp_tool_rule("other-tool")], + )], + &["/usr/bin/trusted"], + ), }], ) - .expect("merge should succeed"); + .expect_err("one host and port cannot carry two inspection contracts"); - let endpoint = &result.policy.network_policies["realtime"].endpoints[0]; - assert_eq!(endpoint.deny_rules.len(), 1); - assert_eq!(endpoint.deny_rules[0].method, "WEBSOCKET_TEXT"); - assert_eq!(endpoint.deny_rules[0].path, "/admin/**"); + assert!(matches!( + error, + PolicyMergeError::McpContractConflict { .. } + )); } + /// The round-trip property the whole design rests on: whatever the gateway + /// accepts must immediately read back as covered, or `/wait` hangs. #[test] - fn add_deny_rejects_unsupported_protocol() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "db".to_string(), - NetworkPolicyRule { - name: "db".to_string(), - endpoints: vec![NetworkEndpoint { - host: "db.example.com".to_string(), - port: 5432, - ports: vec![5432], - protocol: "sql".to_string(), - access: "full".to_string(), - ..Default::default() + fn every_accepted_merge_reads_back_as_covered() { + let cases: Vec<(&str, NetworkPolicyRule, NetworkPolicyRule)> = vec![ + ( + "omitted enforcement against an enforced endpoint", + rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + enforcement: "enforce".to_string(), + ..endpoint("api.example.com", 443) + }], + &["/usr/bin/trusted"], + ), + rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/trusted"], + ), + ), + ( + "split ports declared across two endpoints", + rule_with_authorizations( + "existing", + vec![endpoint_with_ports("api.example.com", &[443, 8443])], + &["/usr/bin/trusted"], + ), + rule_with_authorizations( + "existing", + vec![ + endpoint_with_ports("api.example.com", &[443]), + endpoint_with_ports("api.example.com", &[8443]), + ], + &["/usr/bin/trusted", "/usr/bin/second"], + ), + ), + ( + "any-binary promotion", + rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/trusted"], + ), + rule_with_authorizations("existing", vec![endpoint("api.example.com", 443)], &[]), + ), + ]; + + for (label, existing, proposal) in cases { + let result = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: proposal.clone(), }], - ..Default::default() - }, + ) + .unwrap_or_else(|error| panic!("{label} should merge: {error}")); + + assert!( + policy_covers_rule(&result.policy, &proposal), + "{label}: the merge was accepted, so coverage must report it loaded" + ); + } + } + + /// A declaration that leaves a retained field unset expresses no opinion + /// about it. Rejecting on that field would refuse a complete declaration + /// over a mode the operation never tried to change, and for enforcement it + /// would refuse specifically because the binary receives the stricter mode. + #[test] + fn declaration_may_omit_retained_fields_it_does_not_change() { + for (field, loaded_endpoint) in [ + ( + "enforcement", + NetworkEndpoint { + enforcement: "enforce".to_string(), + ..endpoint("api.example.com", 443) + }, + ), + ( + "protocol", + NetworkEndpoint { + protocol: "rest".to_string(), + ..endpoint("api.example.com", 443) + }, + ), + ( + "tls", + NetworkEndpoint { + tls: "skip".to_string(), + ..endpoint("api.example.com", 443) + }, + ), + ( + "access", + NetworkEndpoint { + protocol: "rest".to_string(), + access: "read-only".to_string(), + ..endpoint("api.example.com", 443) + }, + ), + ( + "credential_signing", + NetworkEndpoint { + credential_signing: "sigv4".to_string(), + signing_service: "s3".to_string(), + signing_region: "us-west-2".to_string(), + ..endpoint("api.example.com", 443) + }, + ), + ( + "persisted_queries", + NetworkEndpoint { + persisted_queries: "allow_registered".to_string(), + graphql_max_body_bytes: 4096, + ..endpoint("api.example.com", 443) + }, + ), + ( + "json_rpc_max_body_bytes", + NetworkEndpoint { + json_rpc_max_body_bytes: 65536, + ..endpoint("api.example.com", 443) + }, + ), + ] { + let existing = + rule_with_authorizations("existing", vec![loaded_endpoint], &["/usr/bin/trusted"]); + // Declares the whole scope, but says nothing about the carry-over + // field the endpoint already carries. + let incoming = rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/trusted", "/usr/bin/second"], + ); + + merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .unwrap_or_else(|error| { + panic!("a declaration omitting {field} covers the whole scope: {error}") + }); + } + } + + /// Omitting a retained field is not the same as contradicting one. A + /// declaration that names a different protocol still has to be rejected. + #[test] + fn declaration_that_contradicts_a_retained_field_is_still_rejected() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + ..endpoint("api.example.com", 443) + }], + &["/usr/bin/trusted"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + protocol: "websocket".to_string(), + ..endpoint("api.example.com", 443) + }], + &["/usr/bin/trusted", "/usr/bin/second"], ); let error = merge_policy( - policy, - &[PolicyMergeOp::AddDenyRules { - host: "db.example.com".to_string(), - port: 5432, - deny_rules: vec![L7DenyRule { - method: "POST".to_string(), - path: "/admin".to_string(), - ..Default::default() - }], + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, }], ) - .expect_err("merge should fail"); + .expect_err("the declaration describes a protocol the merge will not apply"); assert!(matches!( error, - PolicyMergeError::UnsupportedEndpointProtocol { protocol, .. } if protocol == "sql" + PolicyMergeError::NewBinaryWouldInheritAuthorization { .. } )); } + /// `AddAllowRules` and `AddDenyRules` name their target by host and port + /// alone, so when two rules carry that endpoint they cannot say which binary + /// scope to widen. Picking one silently would add the rule to whichever key + /// sorts first. #[test] - fn remove_endpoint_drops_only_requested_port() { - let mut policy = restrictive_default_policy(); + fn l7_operations_reject_an_endpoint_carried_by_several_rules() { + let mut policy = policy_with_rule( + "broad", + rule_with_authorizations( + "broad", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/public")], + ..endpoint("api.example.com", 443) + }], + &["/usr/bin/trusted", "/usr/bin/limited"], + ), + ); policy.network_policies.insert( - "multi".to_string(), - NetworkPolicyRule { - name: "multi".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.example.com".to_string(), - port: 80, - ports: vec![80, 443], - ..Default::default() + "narrow".to_string(), + rule_with_authorizations( + "narrow", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/public")], + ..endpoint("api.example.com", 443) }], - ..Default::default() + &["/usr/bin/other"], + ), + ); + + for operation in [ + PolicyMergeOp::AddAllowRules { + host: "api.example.com".to_string(), + port: 443, + rules: vec![rest_rule("POST", "/admin")], + }, + PolicyMergeOp::AddDenyRules { + host: "api.example.com".to_string(), + port: 443, + deny_rules: Vec::new(), }, + ] { + let error = merge_policy(policy.clone(), &[operation]) + .expect_err("two rules carry this endpoint, so the target is ambiguous"); + + assert!( + matches!( + &error, + PolicyMergeError::AmbiguousEndpointRule { targets, .. } + if targets == &["broad".to_string(), "narrow".to_string()] + ), + "got {error:?}" + ); + } + } + + /// A single rule can own several endpoints on one host and port, because + /// `endpoints_overlap` treats a different path as a different endpoint. + /// Counting owning rules would miss this and let the operation land on + /// whichever endpoint happens to sit first in the vector. + #[test] + fn l7_operations_reject_two_paths_on_one_host_and_port_within_a_rule() { + let policy = policy_with_rule( + "versioned", + rule_with_authorizations( + "versioned", + vec![ + NetworkEndpoint { + protocol: "rest".to_string(), + path: "/v1".to_string(), + rules: vec![rest_rule("GET", "/public")], + ..endpoint("api.example.com", 443) + }, + NetworkEndpoint { + protocol: "rest".to_string(), + path: "/v2".to_string(), + rules: vec![rest_rule("GET", "/public")], + ..endpoint("api.example.com", 443) + }, + ], + &["/usr/bin/trusted"], + ), ); - let result = merge_policy( + let error = merge_policy( policy, - &[PolicyMergeOp::RemoveEndpoint { - rule_name: None, + &[PolicyMergeOp::AddAllowRules { host: "api.example.com".to_string(), port: 443, + rules: vec![rest_rule("POST", "/admin")], }], ) - .expect("merge should succeed"); + .expect_err("one host and port resolves to two endpoints on this rule"); - let endpoint = &result.policy.network_policies["multi"].endpoints[0]; - assert_eq!(endpoint.ports, vec![80]); - assert_eq!(endpoint.port, 80); + assert!( + matches!( + &error, + PolicyMergeError::AmbiguousEndpointRule { targets, .. } + if targets == &[ + "versioned (path '/v1')".to_string(), + "versioned (path '/v2')".to_string(), + ] + ), + "got {error:?}" + ); } + /// The unambiguous case must keep working, or every incremental L7 update + /// breaks. #[test] - fn remove_binary_removes_rule_when_last_binary_is_deleted() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "github".to_string(), - NetworkPolicyRule { - name: "github".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/gh".to_string(), - ..Default::default() + fn l7_operations_still_apply_when_one_rule_carries_the_endpoint() { + let policy = policy_with_rule( + "only", + rule_with_authorizations( + "only", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/public")], + ..endpoint("api.example.com", 443) }], - }, + &["/usr/bin/trusted"], + ), ); let result = merge_policy( policy, - &[PolicyMergeOp::RemoveBinary { - rule_name: "github".to_string(), - binary_path: "/usr/bin/gh".to_string(), + &[PolicyMergeOp::AddAllowRules { + host: "api.example.com".to_string(), + port: 443, + rules: vec![rest_rule("POST", "/issues")], }], ) - .expect("merge should succeed"); + .expect("a single owning rule is unambiguous"); - assert!(!result.policy.network_policies.contains_key("github")); + assert_eq!( + result.policy.network_policies["only"].endpoints[0] + .rules + .len(), + 2 + ); } + /// Removing a binary from an any-binary rule has no entry to drop, so + /// reporting success would leave the operator believing a revocation landed + /// while the rule still authorizes every binary. #[test] - fn policy_covers_rule_returns_true_when_merged_rule_present() { - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; + fn remove_binary_rejects_an_any_binary_rule_instead_of_doing_nothing() { + let policy = policy_with_rule( + "wide", + rule_with_authorizations("wide", vec![endpoint("api.example.com", 443)], &[]), + ); - let merged = merge_policy( - restrictive_default_policy(), - &[PolicyMergeOp::AddRule { - rule_name: "allow_api_github_com_443".to_string(), - rule: proposed.clone(), + let error = merge_policy( + policy, + &[PolicyMergeOp::RemoveBinary { + rule_name: "wide".to_string(), + binary_path: "/usr/bin/untrusted".to_string(), }], ) - .expect("merge should succeed"); + .expect_err("an any-binary rule has no binary entry to remove"); - assert!(policy_covers_rule(&merged.policy, &proposed)); + assert!(matches!( + error, + PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope { rule_name, binary_path, .. } + if rule_name == "wide" && binary_path == "/usr/bin/untrusted" + )); } + /// Removing the last named binary deletes the rule rather than leaving an + /// empty list, which would silently widen it to every binary. #[test] - fn policy_covers_rule_returns_false_when_unrelated_rule_present() { - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; + fn remove_binary_deletes_the_rule_rather_than_widening_it() { + let policy = policy_with_rule( + "narrow", + rule_with_authorizations( + "narrow", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/only"], + ), + ); - // Merge an *unrelated* rule for a different host. The proposed rule - // for api.github.com is still not present — this is John's - // "false-wakeup" case: an unrelated policy reload must not signal - // that the agent's rule is loaded. - let merged = merge_policy( - restrictive_default_policy(), - &[PolicyMergeOp::AddRule { - rule_name: "allow_api_example_com_443".to_string(), - rule: rule_with_endpoint("unrelated", "api.example.com", 443), + let result = merge_policy( + policy, + &[PolicyMergeOp::RemoveBinary { + rule_name: "narrow".to_string(), + binary_path: "/usr/bin/only".to_string(), }], ) - .expect("merge should succeed"); + .expect("removing the last binary is a valid revocation"); - assert!(!policy_covers_rule(&merged.policy, &proposed)); + assert!( + !result.policy.network_policies.contains_key("narrow"), + "an emptied rule must be removed, not left authorizing any binary" + ); } + /// Widened fields merge into an endpoint as a whole, so a change declared + /// for one port of a multi-port endpoint reaches the endpoint's other ports. + /// Declaring every existing binary must not exempt the operation from + /// naming the ports its change lands on. #[test] - fn policy_covers_rule_handles_merge_into_existing_endpoint() { - // The merge logic folds a new rule into an existing rule when their - // endpoints overlap, even under a different network_policies key. - // Coverage must survive that fold — name-keyed checks would miss it. - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() + fn declaring_every_binary_does_not_license_an_undeclared_port() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/x")], + ..endpoint_with_ports("api.example.com", &[443, 8443]) }], - }; - - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "preexisting_github".to_string(), - NetworkPolicyRule { - name: "preexisting_github".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/git".to_string(), - ..Default::default() - }], - }, + &["/usr/bin/only"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("POST", "/y")], + ..endpoint_with_ports("api.example.com", &[443]) + }], + &["/usr/bin/only"], ); - let merged = merge_policy( - policy, + let error = merge_policy( + policy_with_rule("existing", existing), &[PolicyMergeOp::AddRule { - rule_name: "allow_api_github_com_443".to_string(), - rule: proposed.clone(), + rule_name: "existing".to_string(), + rule: incoming, }], ) - .expect("merge should succeed"); + .expect_err("POST would also land on the undeclared port 8443"); assert!( - !merged - .policy - .network_policies - .contains_key("allow_api_github_com_443"), - "proposed rule should have been folded into the existing key" + matches!( + &error, + PolicyMergeError::UndeclaredPortWouldChange { ports, host, .. } + if ports == &[8443] && host == "api.example.com" + ), + "got {error:?}" ); - assert!(policy_covers_rule(&merged.policy, &proposed)); } + /// Naming every port the change lands on is accepted, so the incremental + /// flow still works once the operation is explicit about its scope. #[test] - fn policy_covers_rule_returns_false_when_binary_missing() { - let proposed = NetworkPolicyRule { - name: "agent_proposed".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() + fn naming_every_changed_port_is_accepted() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/x")], + ..endpoint_with_ports("api.example.com", &[443, 8443]) }], - }; - - // Endpoint exists in the policy but with a *different* binary. The - // agent's retry would still be denied; reload coverage should - // reflect that. - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/git".to_string(), - ..Default::default() - }], - }, + &["/usr/bin/only"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("POST", "/y")], + ..endpoint_with_ports("api.example.com", &[443, 8443]) + }], + &["/usr/bin/only"], ); - assert!(!policy_covers_rule(&policy, &proposed)); - } + let result = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect("the operation named both ports the change reaches"); - #[test] - fn policy_covers_rule_returns_false_for_empty_proposed_endpoints() { - // Defensive: a rule with no endpoints carries no signal we can match - // on, so coverage is never true. - let proposed = NetworkPolicyRule::default(); - let policy = restrictive_default_policy(); - assert!(!policy_covers_rule(&policy, &proposed)); + assert_eq!( + result.policy.network_policies["existing"].endpoints[0] + .rules + .len(), + 2 + ); } + /// The supervisor resolves one MCP contract per host and port, matching on + /// host and port without consulting the path, so two contracts cannot + /// coexist even under different paths on the same rule. #[test] - fn policy_covers_rule_returns_false_when_proposed_l7_method_not_loaded() { - // John's false-wakeup mode at L7: the supervisor has an - // overlapping endpoint loaded (e.g. read-only GET), but the - // chunk's proposed PUT method is not in the merged endpoint's - // rules yet. Coverage must NOT return true here, or the agent - // retries the PUT and hits another policy_denied. - let proposed = NetworkPolicyRule { - name: "agent_put".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")], - ..Default::default() + fn two_mcp_contracts_on_one_host_and_port_are_rejected_across_paths() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/a".to_string(), + ..mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 65536, + vec![mcp_tool_rule("first")], + ) }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() + &["/usr/bin/only"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/b".to_string(), + ..mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 131_072, + vec![mcp_tool_rule("second")], + ) }], - }; - - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing_readonly".to_string(), - NetworkPolicyRule { - name: "existing_readonly".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![rest_rule("GET", "/repos/foo/bar/contents/x.md")], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }, + &["/usr/bin/only"], ); + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("a differing path does not license a second inspection contract"); + assert!( - !policy_covers_rule(&policy, &proposed), - "endpoint overlaps but L7 PUT not loaded yet; must not signal coverage" + matches!( + &error, + PolicyMergeError::ConflictingInspectionContracts { host, port, contracts } + if host == "mcp.example.com" && *port == 443 && contracts.len() == 2 + ), + "got {error:?}" ); } + /// The same conflict across two rules is equally unsafe, because the + /// supervisor does not care which rule contributed the endpoint. #[test] - fn policy_covers_rule_returns_true_after_l7_merge_lands() { - // Same setup as above, but with the proposed L7 rule merged in. - // Coverage must now return true. - let proposed = NetworkPolicyRule { - name: "agent_put".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ports: vec![443], - protocol: "rest".to_string(), - rules: vec![ - rest_rule("GET", "/repos/foo/bar/contents/x.md"), - rest_rule("PUT", "/repos/foo/bar/contents/x.md"), - ], - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }, + fn two_mcp_contracts_on_one_host_and_port_are_rejected_across_rules() { + let policy = policy_with_rule( + "first_rule", + rule_with_authorizations( + "first_rule", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 65536, + vec![mcp_tool_rule("first")], + )], + &["/usr/bin/a"], + ), ); - assert!(policy_covers_rule(&policy, &proposed)); + let error = merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "second_rule".to_string(), + rule: rule_with_authorizations( + "second_rule", + vec![NetworkEndpoint { + path: "/other".to_string(), + ..mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 131_072, + vec![mcp_tool_rule("second")], + ) + }], + &["/usr/bin/b"], + ), + }], + ) + .expect_err("a separate rule does not license a second inspection contract"); + + assert!(matches!( + error, + PolicyMergeError::ConflictingInspectionContracts { .. } + )); } + /// Matching contracts on one host and port are fine, so splitting an MCP + /// surface across paths or rules stays possible when the contract agrees. #[test] - fn policy_covers_rule_returns_true_for_l4_only_proposed_when_endpoint_present() { - // A chunk that targets a non-REST surface (no L7 rules) needs - // only the L4 endpoint match to be considered covered. Empty - // proposed.rules must not be treated as "no method matches". - let proposed = NetworkPolicyRule { - name: "ssh_clone".to_string(), - endpoints: vec![NetworkEndpoint { - host: "github.com".to_string(), - port: 22, - ports: vec![22], - ..Default::default() + fn matching_mcp_contracts_on_one_host_and_port_are_accepted() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/a".to_string(), + ..mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 65536, + vec![mcp_tool_rule("first")], + ) }], - binaries: vec![NetworkBinary { - path: "/usr/bin/git".to_string(), - ..Default::default() + &["/usr/bin/only"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/b".to_string(), + ..mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 65536, + vec![mcp_tool_rule("second")], + ) }], - }; + &["/usr/bin/only"], + ); - let merged = merge_policy( - restrictive_default_policy(), + merge_policy( + policy_with_rule("existing", existing), &[PolicyMergeOp::AddRule { - rule_name: "allow_github_com_22".to_string(), - rule: proposed.clone(), + rule_name: "existing".to_string(), + rule: incoming, }], ) - .expect("merge should succeed"); - - assert!(policy_covers_rule(&merged.policy, &proposed)); + .expect("identical contracts resolve the same way whichever endpoint matches first"); } + /// A conflict already present in the baseline must not make every later + /// update fail with an error the operation did nothing to cause. #[test] - fn policy_covers_rule_treats_empty_proposed_binaries_as_any_binary() { - // A proposed rule with no binaries is the "any binary" shape. - // The merged rule keeps its own binaries; coverage holds iff - // endpoint and (vacuously satisfied) binary set match. Document - // the semantics so a future reader doesn't flip it accidentally. - let proposed = NetworkPolicyRule { - name: "any_binary_rule".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![], - }; - - let mut policy = restrictive_default_policy(); + fn a_preexisting_mcp_conflict_does_not_block_an_unrelated_update() { + let mut policy = policy_with_rule( + "first_rule", + rule_with_authorizations( + "first_rule", + vec![mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 65536, + vec![mcp_tool_rule("first")], + )], + &["/usr/bin/a"], + ), + ); policy.network_policies.insert( - "existing".to_string(), - NetworkPolicyRule { - name: "existing".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() + "second_rule".to_string(), + rule_with_authorizations( + "second_rule", + vec![NetworkEndpoint { + path: "/other".to_string(), + ..mcp_endpoint( + "mcp.example.com", + &[443], + None, + None, + 131_072, + vec![mcp_tool_rule("second")], + ) }], - }, + &["/usr/bin/b"], + ), ); - assert!( - policy_covers_rule(&policy, &proposed), - "empty proposed binaries should match any merged binary set" - ); + merge_policy( + policy, + &[PolicyMergeOp::AddRule { + rule_name: "unrelated".to_string(), + rule: rule_with_authorizations( + "unrelated", + vec![endpoint("other.example.com", 443)], + &["/usr/bin/c"], + ), + }], + ) + .expect("an unrelated endpoint must not inherit a baseline conflict"); } + /// An additive operation must never revoke access. Appending a named binary + /// to a rule that authorizes any binary would restrict it to that one path. #[test] - fn add_rule_without_existing_match_inserts_requested_key() { - let policy = restrictive_default_policy(); + fn naming_binaries_does_not_narrow_an_any_binary_rule() { + let existing = + rule_with_authorizations("existing", vec![endpoint("api.example.com", 443)], &[]); + let incoming = rule_with_authorizations( + "existing", + vec![endpoint("api.example.com", 443)], + &["/usr/bin/a"], + ); + let result = merge_policy( - policy, + policy_with_rule("existing", existing), &[PolicyMergeOp::AddRule { - rule_name: "allow_api_example_com_443".to_string(), - rule: rule_with_endpoint("custom", "api.example.com", 443), + rule_name: "existing".to_string(), + rule: incoming.clone(), }], ) - .expect("merge should succeed"); + .expect("naming a binary already covered by any-binary is additive"); assert!( - result - .policy - .network_policies - .contains_key("allow_api_example_com_443") + result.policy.network_policies["existing"] + .binaries + .is_empty(), + "the any-binary scope must survive; got {:?}", + result.policy.network_policies["existing"].binaries + ); + assert!(result.warnings.iter().any(|warning| matches!( + warning, + PolicyMergeWarning::ExistingAnyBinaryScopeRetained { .. } + ))); + assert!( + policy_covers_rule(&result.policy, &incoming), + "an any-binary rule covers the named binary, so coverage must converge" ); } - /// Provider-injected rules (`_provider_*`) are excluded from the - /// endpoint-overlap fallback: an agent chunk for the same `(host, port)` - /// as a provider rule lands as its own key instead of being merged into - /// the provider's rule. This keeps agent contributions honestly narrow - /// (no silent expansion via the provider rule's `access` shorthand) and - /// preserves binary-list separation. + /// A narrow update under its own rule name must survive an undeclared-port + /// conflict the fold would create. The undeclared port belongs to the + /// endpoint the fold merges into; a separate rule carries only the ports it + /// declared, so nothing reaches a port the operation did not name. #[test] - fn add_rule_does_not_merge_agent_chunk_into_provider_rule() { - use crate::compose::{ProviderPolicyLayer, compose_effective_policy}; - use openshell_core::proto::SandboxPolicy; + fn undeclared_port_conflict_keeps_a_differently_named_rule_separate() { + let broad = rule_with_authorizations( + "broad", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/x")], + ..endpoint_with_ports("api.example.com", &[443, 8443]) + }], + &["/usr/bin/a", "/usr/bin/b"], + ); - // Compose a policy where the github provider profile contributes a - // `_provider_*` rule for api.github.com with `access: read-write` - // and gh/git binaries. - let provider_rule = NetworkPolicyRule { - name: "_provider_work_github".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, + let result = merge_policy( + policy_with_rule("broad", broad), + &[PolicyMergeOp::AddRule { + rule_name: "narrow".to_string(), + rule: rule_with_authorizations( + "narrow", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("POST", "/y")], + ..endpoint_with_ports("api.example.com", &[443]) + }], + &["/usr/bin/a"], + ), + }], + ) + .expect("a differently named narrow rule must land rather than be rejected"); + + let narrow = result + .policy + .network_policies + .get("narrow") + .expect("the requested key must be preserved when folding would widen"); + assert_eq!(narrow.binaries.len(), 1); + assert_eq!(canonical_ports(&narrow.endpoints[0]), vec![443]); + + // The broad rule keeps its own product: neither binary gains POST, and + // port 8443 is untouched. + let broad_after = &result.policy.network_policies["broad"]; + assert_eq!(broad_after.endpoints[0].rules.len(), 1); + assert_eq!(canonical_ports(&broad_after.endpoints[0]), vec![443, 8443]); + } + + /// The same conflict against the rule's own name is still an error: there + /// the operation chose the target, so the undeclared port is real. + #[test] + fn undeclared_port_conflict_still_fails_on_a_same_key_update() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { protocol: "rest".to_string(), - enforcement: "enforce".to_string(), - access: "read-write".to_string(), - ..Default::default() + rules: vec![rest_rule("GET", "/x")], + ..endpoint_with_ports("api.example.com", &[443, 8443]) }], - binaries: vec![NetworkBinary { - path: "/usr/bin/gh".to_string(), - ..Default::default() + &["/usr/bin/a"], + ); + + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + protocol: "rest".to_string(), + rules: vec![rest_rule("POST", "/y")], + ..endpoint_with_ports("api.example.com", &[443]) + }], + &["/usr/bin/a"], + ), }], - }; - let composed = compose_effective_policy( - &SandboxPolicy::default(), - &[ProviderPolicyLayer { - rule_name: "_provider_work_github".to_string(), - rule: provider_rule, + ) + .expect_err("the operation named this rule, so the undeclared port stands"); + + assert!(matches!( + error, + PolicyMergeError::UndeclaredPortWouldChange { ports, .. } if ports == vec![8443] + )); + } + + /// Endpoints agreeing on protocol are fine, so splitting one REST surface + /// across paths stays supported. + #[test] + fn same_protocol_on_one_host_and_port_across_paths_is_accepted() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/v1".to_string(), + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/x")], + ..endpoint("api.example.com", 443) }], + &["/usr/bin/only"], ); - assert!( - composed - .network_policies - .contains_key("_provider_work_github"), - "precondition: provider rule must be present in baseline" + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/v2".to_string(), + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/y")], + ..endpoint("api.example.com", 443) + }], + &["/usr/bin/only"], ); - // Agent submits a narrow PUT rule targeting the same host/port via - // curl. Without the filter, this would merge into the provider rule. - let agent_rule = NetworkPolicyRule { - name: "github_contents_put".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, + merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect("two REST endpoints resolve to the same inspection contract"); + } + + /// An uninspected L4 endpoint supplies no L7 configuration, so it never + /// competes with an inspected endpoint on the same host and port. + #[test] + fn an_l4_endpoint_does_not_conflict_with_an_inspected_one() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { protocol: "rest".to_string(), - enforcement: "enforce".to_string(), - rules: vec![rest_rule("PUT", "/repos/owner/repo/contents/file.md")], - ..Default::default() + rules: vec![rest_rule("GET", "/x")], + ..endpoint("api.example.com", 443) }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() + &["/usr/bin/only"], + ); + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/raw".to_string(), + ..endpoint("api.example.com", 443) }], - }; - let result = merge_policy( - composed, + &["/usr/bin/only"], + ); + + merge_policy( + policy_with_rule("existing", existing), &[PolicyMergeOp::AddRule { - rule_name: "github_contents_put".to_string(), - rule: agent_rule, + rule_name: "existing".to_string(), + rule: incoming, }], ) - .expect("merge should succeed"); + .expect("an endpoint with no protocol carries no inspection contract"); + } - // The agent's chunk lands as its own rule key. - assert!( - result - .policy - .network_policies - .contains_key("github_contents_put"), - "agent chunk must land as a separate rule (not merged into the provider rule); \ - got keys: {:?}", - result.policy.network_policies.keys().collect::>() + /// The supervisor picks among matching configs by most-specific path, so a + /// broad REST endpoint and a narrow GraphQL endpoint on one host and port + /// are unambiguous. Rejecting them would refuse an update whose equivalent + /// full policy the runtime supports. + #[test] + fn path_disambiguated_protocols_on_one_host_and_port_are_accepted() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/**".to_string(), + protocol: "rest".to_string(), + rules: vec![rest_rule("GET", "/repos/**")], + ..endpoint("api.github.com", 443) + }], + &["/usr/bin/only"], ); - - // The provider rule is unchanged: still has only gh as a binary - // (no silent broadening), still has the read-write shorthand - // intact (no preset expansion into wildcard paths). - let provider_rule_after = result - .policy - .network_policies - .get("_provider_work_github") - .expect("provider rule must still be present"); - assert_eq!( - provider_rule_after.binaries.len(), - 1, - "provider rule's binary list must NOT have been merged with the agent's binaries" + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/graphql".to_string(), + protocol: "graphql".to_string(), + ..endpoint("api.github.com", 443) + }], + &["/usr/bin/only"], ); - assert_eq!(provider_rule_after.binaries[0].path, "/usr/bin/gh"); - assert_eq!( - provider_rule_after.endpoints[0].access, "read-write", - "provider rule's `access` shorthand must remain intact" + + merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect("path selectors disambiguate these protocols at request time"); + } + + /// Authorization is evaluated across every matching endpoint, but the parser + /// is chosen by most-specific path. A broad REST rule can therefore satisfy + /// authorization for a JSON-RPC tool call that the MCP endpoint selected to + /// parse it never allowed, and the relay forwards it. MCP cannot share a + /// host and port with a differently inspected endpoint. + #[test] + fn mcp_alongside_a_broader_rest_endpoint_is_rejected() { + let existing = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/**".to_string(), + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![rest_rule("POST", "/**")], + ..endpoint("svc.example.com", 443) + }], + &["/usr/bin/agent"], ); - assert!( - provider_rule_after.endpoints[0].rules.is_empty(), - "provider rule must NOT have had its access expanded into explicit wildcard rules" + let incoming = rule_with_authorizations( + "existing", + vec![NetworkEndpoint { + path: "/mcp".to_string(), + ..mcp_endpoint( + "svc.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("safe")], + ) + }], + &["/usr/bin/agent"], ); - // The agent's rule retains its narrow scope. - let agent_rule_after = &result.policy.network_policies["github_contents_put"]; - assert_eq!(agent_rule_after.binaries[0].path, "/usr/bin/curl"); - assert_eq!(agent_rule_after.endpoints[0].rules.len(), 1); + let error = merge_policy( + policy_with_rule("existing", existing), + &[PolicyMergeOp::AddRule { + rule_name: "existing".to_string(), + rule: incoming, + }], + ) + .expect_err("a broader REST endpoint would authorize tool calls MCP denies"); + + assert!(matches!( + error, + PolicyMergeError::ConflictingInspectionContracts { .. } + )); } - /// Non-provider rules still merge by endpoint overlap when the incoming - /// `rule_name` doesn't match an existing key. This preserves the - /// long-standing behavior for user-authored and mechanistic chunks. + /// The same bypass is reachable when the two endpoints live in separate + /// rules, so the scan has to span the whole merged policy. #[test] - fn add_rule_still_merges_user_chunk_into_user_rule_by_endpoint_overlap() { - let mut policy = restrictive_default_policy(); - policy.network_policies.insert( - "custom_github".to_string(), - rule_with_endpoint("custom_github", "api.github.com", 443), + fn mcp_alongside_a_broader_rest_endpoint_in_another_rule_is_rejected() { + let policy = policy_with_rule( + "rest_rule", + rule_with_authorizations( + "rest_rule", + vec![NetworkEndpoint { + path: "/**".to_string(), + protocol: "rest".to_string(), + rules: vec![rest_rule("POST", "/**")], + ..endpoint("svc.example.com", 443) + }], + &["/usr/bin/agent"], + ), ); - let incoming = NetworkPolicyRule { - name: "ignored_when_merging".to_string(), - endpoints: vec![endpoint("api.github.com", 443)], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), - ..Default::default() - }], - }; - let result = merge_policy( + let error = merge_policy( policy, &[PolicyMergeOp::AddRule { - rule_name: "different_name".to_string(), - rule: incoming, + rule_name: "mcp_rule".to_string(), + rule: rule_with_authorizations( + "mcp_rule", + vec![NetworkEndpoint { + path: "/mcp".to_string(), + ..mcp_endpoint( + "svc.example.com", + &[443], + None, + None, + 0, + vec![mcp_tool_rule("safe")], + ) + }], + &["/usr/bin/agent"], + ), }], ) - .expect("merge should succeed"); + .expect_err("a separate rule does not make the mixed inspection safe"); - // No new rule entry was created — the chunk merged into the - // existing user rule via endpoint overlap. - assert!( - !result - .policy - .network_policies - .contains_key("different_name"), - "user-authored rule overlap should still merge (no new key); \ - got keys: {:?}", - result.policy.network_policies.keys().collect::>() - ); - let merged = &result.policy.network_policies["custom_github"]; - assert!( - merged.binaries.iter().any(|b| b.path == "/usr/bin/curl"), - "user rule should have absorbed the incoming curl binary" - ); + assert!(matches!( + error, + PolicyMergeError::ConflictingInspectionContracts { .. } + )); } } diff --git a/crates/openshell-prover/BUILD.bazel b/crates/openshell-prover/BUILD.bazel deleted file mode 100644 index c443b657b0..0000000000 --- a/crates/openshell-prover/BUILD.bazel +++ /dev/null @@ -1,35 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-prover", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - compile_data = glob(["registry/**/*"]), - rustc_env = { - "CARGO_MANIFEST_DIR": "crates/openshell-prover", - }, - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-prover_test", - crate = ":openshell-prover", - data = glob(["testdata/**/*"]), - rustc_env = { - "CARGO_MANIFEST_DIR": "crates/openshell-prover", - }, - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-prover", - ":openshell-prover_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index 0fb8757577..913045fe7d 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -105,10 +105,11 @@ mod tests { fn test_filesystem_policy() { let path = testdata_dir().join("policy.yaml"); let model = parse_policy(&path).expect("failed to parse policy"); - let readable = model.filesystem_policy.readable_paths(); + let readable = model.filesystem_policy.readable_paths(None); assert!(readable.contains(&"/usr".to_owned())); assert!(readable.contains(&"/sandbox".to_owned())); assert!(readable.contains(&"/tmp".to_owned())); + assert!(readable.contains(&policy::WORKDIR_PATH_SYMBOL.to_owned())); } // 3. Workdir NOT included by default (matches runtime behavior). @@ -121,8 +122,9 @@ filesystem_policy: - /usr "; let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); + let readable = model.filesystem_policy.readable_paths(None); assert!(!readable.contains(&"/sandbox".to_owned())); + assert!(!readable.contains(&policy::WORKDIR_PATH_SYMBOL.to_owned())); } // 4. Workdir excluded when include_workdir: false. @@ -136,8 +138,9 @@ filesystem_policy: - /usr "; let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); + let readable = model.filesystem_policy.readable_paths(None); assert!(!readable.contains(&"/sandbox".to_owned())); + assert!(!readable.contains(&policy::WORKDIR_PATH_SYMBOL.to_owned())); } // 5. No duplicate when workdir already in read_write. @@ -152,12 +155,30 @@ filesystem_policy: - /tmp "; let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); + let readable = model.filesystem_policy.readable_paths(Some("/sandbox")); let sandbox_count = readable.iter().filter(|p| *p == "/sandbox").count(); assert_eq!(sandbox_count, 1); } - // 6. End-to-end: testdata policy with a github credential in scope and a + // 6. A resolved non-default workdir does not replace an explicit path. + #[test] + fn test_include_workdir_preserves_explicit_sandbox_path() { + let yaml = r" +version: 1 +filesystem_policy: + include_workdir: true + read_write: + - /sandbox +"; + let model = policy::parse_policy_str(yaml).expect("parse"); + let readable = model + .filesystem_policy + .readable_paths(Some("/workspace/project")); + assert!(readable.contains(&"/sandbox".to_owned())); + assert!(readable.contains(&"/workspace/project".to_owned())); + } + + // 7. End-to-end: testdata policy with a github credential in scope and a // bypass-L7 binary (git) emits an `l7_bypass_credentialed` finding. // The prover output is categorical, not severity-graded. #[test] diff --git a/crates/openshell-prover/src/model.rs b/crates/openshell-prover/src/model.rs index bf52993d47..3769b17e43 100644 --- a/crates/openshell-prover/src/model.rs +++ b/crates/openshell-prover/src/model.rs @@ -268,7 +268,7 @@ impl ReachabilityModel { } fn encode_filesystem(&mut self) { - for path in self.policy.filesystem_policy.readable_paths() { + for path in self.policy.filesystem_policy.readable_paths(None) { let var = Bool::new_const(format!("fs_readable_{path}")); self.solver.assert(&var); self.filesystem_readable.insert(path, var); diff --git a/crates/openshell-prover/src/policy.rs b/crates/openshell-prover/src/policy.rs index aa40d07560..599c8d131a 100644 --- a/crates/openshell-prover/src/policy.rs +++ b/crates/openshell-prover/src/policy.rs @@ -257,18 +257,26 @@ pub struct FilesystemPolicy { pub read_write: Vec, } +/// Symbol used when the prover does not know the image-resolved workspace. +/// +/// Keeping this distinct from `/sandbox` prevents the model from inventing a +/// literal compatibility workspace for images that declare another workdir. +pub const WORKDIR_PATH_SYMBOL: &str = ""; + impl FilesystemPolicy { /// All readable paths (union of `read_only` and `read_write`), with workdir - /// added when `include_workdir` is true and not already present. - pub fn readable_paths(&self) -> Vec { + /// added when `include_workdir` is true and not already present. When the + /// resolved workdir is unavailable, retain it as a symbolic path. + pub fn readable_paths(&self, resolved_workdir: Option<&str>) -> Vec { let mut paths: Vec = self .read_only .iter() .chain(self.read_write.iter()) .cloned() .collect(); - if self.include_workdir && !paths.iter().any(|p| p == "/sandbox") { - paths.push("/sandbox".to_owned()); + let workdir = resolved_workdir.unwrap_or(WORKDIR_PATH_SYMBOL); + if self.include_workdir && !paths.iter().any(|path| path == workdir) { + paths.push(workdir.to_owned()); } paths } diff --git a/crates/openshell-providers/BUILD.bazel b/crates/openshell-providers/BUILD.bazel deleted file mode 100644 index bce827652f..0000000000 --- a/crates/openshell-providers/BUILD.bazel +++ /dev/null @@ -1,28 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-providers", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - compile_data = ["//providers:profiles"], - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-providers_test", - crate = ":openshell-providers", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-providers", - ":openshell-providers_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-providers/src/discovery.rs b/crates/openshell-providers/src/discovery.rs index f1af8fbfa3..16cf497e92 100644 --- a/crates/openshell-providers/src/discovery.rs +++ b/crates/openshell-providers/src/discovery.rs @@ -66,6 +66,16 @@ pub fn discover_from_profile( } } + if profile.id == "google-vertex-ai" { + for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { + if let Some(value) = context.env_var(key) + && !value.trim().is_empty() + { + discovered.config.entry((*key).to_string()).or_insert(value); + } + } + } + if discovered.is_empty() { Ok(None) } else { @@ -165,4 +175,27 @@ mod tests { } if profile_id == "custom" && credential_name == "missing" )); } + + #[test] + fn vertex_profile_discovery_includes_supported_configuration() { + let mut profile = profile(); + profile.id = "google-vertex-ai".to_string(); + let ctx = MockDiscoveryContext::new() + .with_env("CUSTOM_API_KEY", "vertex-token") + .with_env("VERTEX_AI_PROJECT_ID", "project-a") + .with_env("VERTEX_AI_REGION", "us-central1"); + + let discovered = discover_from_profile(&profile, &ctx) + .expect("discovery should succeed") + .expect("provider should be discovered"); + + assert_eq!( + discovered.config.get("VERTEX_AI_PROJECT_ID"), + Some(&"project-a".to_string()) + ); + assert_eq!( + discovered.config.get("VERTEX_AI_REGION"), + Some(&"us-central1".to_string()) + ); + } } diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index fabe1a5bed..c5112ee3f0 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -56,21 +56,10 @@ pub struct ProviderDiscoverySpec { pub credential_env_vars: &'static [&'static str], } -pub trait ProviderPlugin: Send + Sync { - /// Canonical provider id (for example: "claude", "gitlab"). +trait ProviderPlugin: Send + Sync { + /// Canonical provider id. fn id(&self) -> &'static str; - /// Discover provider credentials and config from the local machine. - fn discover_existing(&self) -> Result, ProviderError>; - - /// Return the known credential environment variable names for this provider type. - /// - /// Used by the TUI to label BYO key entry fields and to choose which - /// env var name to store a manually-entered credential under. - fn credential_env_vars(&self) -> &'static [&'static str] { - &[] - } - /// Inject provider-specific environment variables into the sandbox env. /// /// Called during sandbox creation to project provider config (project IDs, @@ -79,25 +68,6 @@ pub trait ProviderPlugin: Send + Sync { fn inject_env(&self, _provider: &Provider, _env: &mut HashMap) {} } -/// Blanket implementation of [`ProviderPlugin`] for [`ProviderDiscoverySpec`]. -/// -/// Providers that only need standard env-var discovery can register their -/// `SPEC` constant directly, instead of defining a dedicated struct and -/// repeating the same three-method delegation. -impl ProviderPlugin for ProviderDiscoverySpec { - fn id(&self) -> &'static str { - self.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - discover_with_spec(self, &RealDiscoveryContext) - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - self.credential_env_vars - } -} - #[derive(Default)] pub struct ProviderRegistry { plugins: HashMap<&'static str, Box>, @@ -107,24 +77,15 @@ impl ProviderRegistry { #[must_use] pub fn new() -> Self { let mut registry = Self::default(); - registry.register(providers::claude::SPEC); - registry.register(providers::codex::SPEC); - registry.register(providers::copilot::SPEC); - registry.register(providers::opencode::OpencodeProvider); - registry.register(providers::generic::GenericProvider); - registry.register(providers::openai::SPEC); - registry.register(providers::anthropic::SPEC); - registry.register(providers::nvidia::SPEC); - registry.register(providers::deepinfra::SPEC); - registry.register(providers::github::SPEC); - registry.register(providers::gitlab::SPEC); + // Keep only the legacy config projectors required to run existing + // Google Cloud and Vertex records. Public provider discovery is + // profile-driven; this registry is an internal compatibility adapter. registry.register(providers::google_cloud::GoogleCloudProvider); - registry.register(providers::outlook::OutlookProvider); registry.register(providers::vertex::VertexProvider); registry } - pub fn register

(&mut self, plugin: P) + fn register

(&mut self, plugin: P) where P: ProviderPlugin + 'static, { @@ -132,36 +93,10 @@ impl ProviderRegistry { } #[must_use] - pub fn get(&self, id: &str) -> Option<&dyn ProviderPlugin> { + fn get(&self, id: &str) -> Option<&dyn ProviderPlugin> { self.plugins.get(id).map(Box::as_ref) } - pub fn discover_existing(&self, id: &str) -> Result, ProviderError> { - let Some(plugin) = self.get(id) else { - return Err(ProviderError::UnsupportedProvider(id.to_string())); - }; - plugin.discover_existing() - } - - /// Return the known credential env var names for a provider type. - #[must_use] - pub fn credential_env_vars(&self, id: &str) -> &'static [&'static str] { - self.get(id) - .map_or(&[], ProviderPlugin::credential_env_vars) - } - - #[must_use] - pub fn profile(&self, id: &str) -> Option<&'static ProviderTypeProfile> { - builtin_profiles() - .iter() - .find(|profile| profile.id.eq_ignore_ascii_case(id)) - } - - #[must_use] - pub fn profiles(&self) -> Vec<&'static ProviderTypeProfile> { - builtin_profiles().iter().collect() - } - /// Inject provider-specific env vars via the registered plugin. /// /// Normalizes the provider type and delegates to the plugin's `inject_env`. @@ -176,11 +111,18 @@ impl ProviderRegistry { } } - #[must_use] - pub fn known_types(&self) -> Vec<&'static str> { - let mut types = self.plugins.keys().copied().collect::>(); - types.sort_unstable(); - types + /// Inject config for an already-resolved profile ID without alias + /// normalization. This prevents an exact custom profile whose ID resembles + /// a legacy alias from selecting an unrelated built-in compatibility plugin. + pub fn inject_env_for_profile_id( + &self, + provider: &Provider, + profile_id: &str, + env: &mut HashMap, + ) { + if let Some(plugin) = self.get(profile_id) { + plugin.inject_env(provider, env); + } } } @@ -196,12 +138,8 @@ pub fn normalize_provider_type(input: &str) -> Option<&'static str> { "claude" | "claude-code" | "claude_code" => Some("claude-code"), "codex" => Some("codex"), "copilot" => Some("copilot"), - "opencode" => Some("opencode"), "gcp" | "google-cloud" => Some("google-cloud"), - "generic" => Some("generic"), - "gitlab" | "glab" => Some("gitlab"), "github" | "gh" => Some("github"), - "outlook" => Some("outlook"), _ => None, } } @@ -222,12 +160,12 @@ mod tests { #[test] fn normalizes_known_provider_aliases() { - assert_eq!(normalize_provider_type("gitlab"), Some("gitlab")); - assert_eq!(normalize_provider_type("glab"), Some("gitlab")); assert_eq!(normalize_provider_type("gh"), Some("github")); assert_eq!(normalize_provider_type("CLAUDE"), Some("claude-code")); assert_eq!(normalize_provider_type("claude-code"), Some("claude-code")); - assert_eq!(normalize_provider_type("generic"), Some("generic")); + for retired in ["generic", "gitlab", "glab", "opencode", "outlook"] { + assert_eq!(normalize_provider_type(retired), None); + } assert_eq!(normalize_provider_type("openai"), Some("openai")); assert_eq!(normalize_provider_type("anthropic"), Some("anthropic")); assert_eq!(normalize_provider_type("nvidia"), Some("nvidia")); @@ -252,7 +190,7 @@ mod tests { ); assert_eq!( detect_provider_from_command(&["/usr/bin/glab".to_string()]), - Some("gitlab") + None ); assert_eq!( detect_provider_from_command(&["/usr/bin/bash".to_string()]), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 2ba071db16..a1f9f130a1 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -9,11 +9,14 @@ use openshell_core::proto::{ GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderCredentialRefreshOutput, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCategory, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, ProviderProfileDiscovery, }; use openshell_core::secrets::uses_reserved_revision_namespace; -use openshell_policy::{L7EndpointFields, validate_l7_endpoint_semantics}; +use openshell_policy::{ + L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, +}; use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::collections::{HashMap, HashSet}; @@ -26,6 +29,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/aws.yaml"), include_str!("../../../providers/aws-bedrock.yaml"), include_str!("../../../providers/aws-s3.yaml"), + include_str!("../../../providers/anthropic.yaml"), include_str!("../../../providers/claude-code.yaml"), include_str!("../../../providers/codex.yaml"), include_str!("../../../providers/copilot.yaml"), @@ -35,6 +39,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/google-cloud.yaml"), include_str!("../../../providers/google-vertex-ai.yaml"), include_str!("../../../providers/nvidia.yaml"), + include_str!("../../../providers/openai.yaml"), include_str!("../../../providers/pypi.yaml"), ]; @@ -111,6 +116,13 @@ pub struct CredentialProfile { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct TokenGrantProfile { + #[serde( + default = "default_token_grant_type", + deserialize_with = "deserialize_token_grant_type", + serialize_with = "serialize_token_grant_type", + skip_serializing_if = "is_client_credentials_grant" + )] + pub grant_type: ProviderCredentialTokenGrantType, pub token_endpoint: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub audience: String, @@ -124,6 +136,18 @@ pub struct TokenGrantProfile { pub cache_ttl_seconds: i64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub audience_overrides: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_token: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub requested_token_type: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct TokenGrantSubjectTokenProfile { + pub source: String, + pub credential: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub subject_token_type: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -195,6 +219,10 @@ pub struct DiscoveryProfile { // GraphqlOperation, or NetworkBinary, add it here and in both conversion // directions unless the import/lint path explicitly rejects it. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[allow( + clippy::struct_excessive_bools, + reason = "Endpoint profile mirrors independent policy schema toggles." +)] pub struct EndpointProfile { pub host: String, #[serde(default, skip_serializing_if = "is_zero")] @@ -221,6 +249,8 @@ pub struct EndpointProfile { pub websocket_credential_rewrite: bool, #[serde(default, skip_serializing_if = "is_false")] pub request_body_credential_rewrite: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub allow_uninspected_credentials: bool, #[serde(default, skip_serializing_if = "String::is_empty")] pub persisted_queries: String, #[serde(default, skip_serializing_if = "HashMap::is_empty")] @@ -464,13 +494,25 @@ impl ProviderTypeProfile { /// Whether this profile can be created without initial static credentials. /// - /// Empty provider creation is allowed when at least one credential can be - /// resolved at runtime, and every required credential can be resolved at - /// runtime. Runtime-resolvable credentials are either gateway-mintable - /// refresh credentials, sandbox-side dynamic token grants, or additional - /// outputs co-minted by another credential's gateway-mintable refresh. + /// Empty provider creation is allowed when every required credential can + /// be resolved at runtime. This includes profiles with no credentials and + /// profiles whose credentials are all optional. #[must_use] pub fn allows_empty_provider_credentials(&self) -> bool { + let co_minted = self.co_minted_credential_names(); + self.credentials.iter().all(|credential| { + let is_runtime_resolvable = + credential.is_runtime_resolvable() || co_minted.contains(credential.name.as_str()); + !credential.required || is_runtime_resolvable + }) + } + + /// Whether `--runtime-credentials` is meaningful for this profile. + /// + /// At least one credential must be runtime-resolvable and every required + /// credential must be resolvable without an initial static value. + #[must_use] + pub fn allows_runtime_provider_credentials(&self) -> bool { let co_minted = self.co_minted_credential_names(); let mut has_runtime_resolvable_credential = false; for credential in &self.credentials { @@ -484,6 +526,20 @@ impl ProviderTypeProfile { has_runtime_resolvable_credential } + /// Required credentials that must have an initial static value. + #[must_use] + pub fn required_static_credentials(&self) -> Vec<&CredentialProfile> { + let co_minted = self.co_minted_credential_names(); + self.credentials + .iter() + .filter(|credential| { + credential.required + && !credential.is_runtime_resolvable() + && !co_minted.contains(credential.name.as_str()) + }) + .collect() + } + /// Names of credentials produced as `additional_outputs` of a /// gateway-mintable refresh on some other credential. fn co_minted_credential_names(&self) -> HashSet<&str> { @@ -651,6 +707,21 @@ impl ProviderTypeProfile { } diagnostics } + + /// Whether attaching this profile makes its network endpoints credentialed. + /// + /// Profiles do not currently map individual credentials to individual + /// endpoints, so the safe interpretation is that any declared credential + /// can be used with every endpoint in the same profile. Endpoint signing is + /// also credential-bearing even when placement metadata is implicit. + #[must_use] + pub fn has_credentialed_endpoints(&self) -> bool { + !self.credentials.is_empty() + || self + .endpoints + .iter() + .any(|endpoint| !endpoint.credential_signing.trim().is_empty()) + } } #[allow(clippy::trivially_copy_pass_by_ref)] @@ -659,6 +730,21 @@ fn is_u64_zero(value: &u64) -> bool { } impl CredentialProfile { + /// Keys accepted when storing this credential on a provider. + /// + /// Workload-injectable credentials use their declared environment aliases. + /// Broker-only credentials have no environment aliases and use their + /// logical profile name instead. + #[must_use] + pub fn accepted_stored_keys(&self) -> Vec<&str> { + if self.env_vars.is_empty() { + let name = self.name.trim(); + return (!name.is_empty()).then_some(name).into_iter().collect(); + } + + self.env_vars.iter().map(String::as_str).collect() + } + #[must_use] pub fn is_runtime_resolvable(&self) -> bool { self.token_grant.is_some() @@ -816,6 +902,26 @@ fn default_refresh_strategy() -> ProviderCredentialRefreshStrategy { ProviderCredentialRefreshStrategy::Unspecified } +fn default_token_grant_type() -> ProviderCredentialTokenGrantType { + ProviderCredentialTokenGrantType::ClientCredentials +} + +fn effective_token_grant_type( + grant_type: ProviderCredentialTokenGrantType, +) -> ProviderCredentialTokenGrantType { + match grant_type { + ProviderCredentialTokenGrantType::Unspecified => { + ProviderCredentialTokenGrantType::ClientCredentials + } + other => other, + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_client_credentials_grant(value: &ProviderCredentialTokenGrantType) -> bool { + effective_token_grant_type(*value) == ProviderCredentialTokenGrantType::ClientCredentials +} + fn deserialize_category<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -858,6 +964,28 @@ where serializer.serialize_str(provider_refresh_strategy_to_yaml(*strategy)) } +fn deserialize_token_grant_type<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + provider_token_grant_type_from_yaml(&raw) + .ok_or_else(|| de::Error::custom(format!("unsupported provider token grant type: {raw}"))) +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn serialize_token_grant_type( + grant_type: &ProviderCredentialTokenGrantType, + serializer: S, +) -> Result +where + S: Serializer, +{ + serializer.serialize_str(provider_token_grant_type_to_yaml(*grant_type)) +} + #[must_use] pub fn provider_profile_category_from_yaml(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { @@ -918,6 +1046,26 @@ pub fn provider_refresh_strategy_to_yaml( } } +#[must_use] +pub fn provider_token_grant_type_from_yaml(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "" | "client_credentials" => Some(ProviderCredentialTokenGrantType::ClientCredentials), + "token_exchange" => Some(ProviderCredentialTokenGrantType::TokenExchange), + _ => None, + } +} + +#[must_use] +pub fn provider_token_grant_type_to_yaml( + grant_type: ProviderCredentialTokenGrantType, +) -> &'static str { + match grant_type { + ProviderCredentialTokenGrantType::TokenExchange => "token_exchange", + ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::Unspecified => "client_credentials", + } +} + fn credential_refresh_from_proto(refresh: &ProviderCredentialRefresh) -> CredentialRefreshProfile { CredentialRefreshProfile { strategy: ProviderCredentialRefreshStrategy::try_from(refresh.strategy) @@ -979,6 +1127,10 @@ fn token_grant_from_proto( token_grant: &openshell_core::proto::ProviderCredentialTokenGrant, ) -> TokenGrantProfile { TokenGrantProfile { + grant_type: effective_token_grant_type( + ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) + .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials), + ), token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -990,6 +1142,11 @@ fn token_grant_from_proto( .iter() .map(token_grant_audience_override_from_proto) .collect(), + subject_token: token_grant + .subject_token + .as_ref() + .map(token_grant_subject_token_from_proto), + requested_token_type: token_grant.requested_token_type.clone(), } } @@ -997,6 +1154,7 @@ fn token_grant_to_proto( token_grant: &TokenGrantProfile, ) -> openshell_core::proto::ProviderCredentialTokenGrant { openshell_core::proto::ProviderCredentialTokenGrant { + grant_type: token_grant.grant_type as i32, token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -1008,6 +1166,31 @@ fn token_grant_to_proto( .iter() .map(token_grant_audience_override_to_proto) .collect(), + subject_token: token_grant + .subject_token + .as_ref() + .map(token_grant_subject_token_to_proto), + requested_token_type: token_grant.requested_token_type.clone(), + } +} + +fn token_grant_subject_token_from_proto( + subject_token: &ProviderCredentialTokenGrantSubjectToken, +) -> TokenGrantSubjectTokenProfile { + TokenGrantSubjectTokenProfile { + source: subject_token.source.clone(), + credential: subject_token.credential.clone(), + subject_token_type: subject_token.subject_token_type.clone(), + } +} + +fn token_grant_subject_token_to_proto( + subject_token: &TokenGrantSubjectTokenProfile, +) -> ProviderCredentialTokenGrantSubjectToken { + ProviderCredentialTokenGrantSubjectToken { + source: subject_token.source.clone(), + credential: subject_token.credential.clone(), + subject_token_type: subject_token.subject_token_type.clone(), } } @@ -1074,6 +1257,8 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { allow_encoded_slash: endpoint.allow_encoded_slash, websocket_credential_rewrite: endpoint.websocket_credential_rewrite, request_body_credential_rewrite: endpoint.request_body_credential_rewrite, + allow_uninspected_credentials: endpoint.allow_uninspected_credentials, + provider_credentialed: false, advisor_proposed: false, persisted_queries: endpoint.persisted_queries.clone(), graphql_persisted_queries: endpoint @@ -1088,6 +1273,9 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { credential_signing: endpoint.credential_signing.clone(), signing_service: endpoint.signing_service.clone(), signing_region: endpoint.signing_region.clone(), + // Credential bindings reference a concrete sandbox provider instance + // and therefore cannot be authored by a reusable provider profile. + credential_binding: None, } } @@ -1120,6 +1308,7 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile { allow_encoded_slash: endpoint.allow_encoded_slash, websocket_credential_rewrite: endpoint.websocket_credential_rewrite, request_body_credential_rewrite: endpoint.request_body_credential_rewrite, + allow_uninspected_credentials: endpoint.allow_uninspected_credentials, persisted_queries: endpoint.persisted_queries.clone(), graphql_persisted_queries: endpoint .graphql_persisted_queries @@ -1542,6 +1731,10 @@ pub fn validate_profile_set( } } + diagnostics.extend(validate_broker_only_subject_credentials( + source, profile_id, profile, + )); + let mut env_vars = HashSet::new(); for credential in &profile.credentials { for env_var in &credential.env_vars { @@ -1813,6 +2006,12 @@ pub fn validate_profile_set( message, )); } + diagnostics.extend(validate_token_grant_subject_token( + source, + profile_id, + credential, + &credential_names, + )); diagnostics.extend(validate_token_grant_audience_overrides( source, profile_id, @@ -1896,6 +2095,17 @@ pub fn validate_profile_set( msg, )); } + for msg in validate_explicit_tcp_additional_fields( + &endpoint.protocol, + &additional_l7_profile_fields(endpoint), + ) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + format!("endpoints[{index}]"), + msg, + )); + } if endpoint.protocol == "mcp" { let strict_tool_names = endpoint @@ -2186,6 +2396,27 @@ pub fn validate_profile_set( } } } + + if profile.has_credentialed_endpoints() + && !endpoint.allow_uninspected_credentials + && (endpoint.protocol.trim().is_empty() + || endpoint.tls.trim().eq_ignore_ascii_case("skip")) + { + let mode = if endpoint.protocol.trim().is_empty() { + "L4-only" + } else { + "tls: skip" + }; + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + format!("endpoints[{index}].allow_uninspected_credentials"), + format!( + "credentialed endpoint '{}:{}' uses {mode}; configure L7 inspection or explicitly set allow_uninspected_credentials: true", + endpoint.host, endpoint.port + ), + )); + } } for (index, binary) in profile.binaries.iter().enumerate() { @@ -2215,6 +2446,49 @@ fn endpoint_is_valid(endpoint: &EndpointProfile) -> bool { (1..=65_535).contains(&endpoint.port) } +fn additional_l7_profile_fields(endpoint: &EndpointProfile) -> Vec<&'static str> { + let mut fields = Vec::new(); + for (name, present) in [ + ("enforcement", !endpoint.enforcement.is_empty()), + ("path", !endpoint.path.is_empty()), + ("allow_encoded_slash", endpoint.allow_encoded_slash), + ( + "websocket_credential_rewrite", + endpoint.websocket_credential_rewrite, + ), + ( + "request_body_credential_rewrite", + endpoint.request_body_credential_rewrite, + ), + ("persisted_queries", !endpoint.persisted_queries.is_empty()), + ( + "graphql_persisted_queries", + !endpoint.graphql_persisted_queries.is_empty(), + ), + ( + "graphql_max_body_bytes", + endpoint.graphql_max_body_bytes > 0, + ), + ( + "json_rpc_max_body_bytes", + endpoint.json_rpc_max_body_bytes > 0, + ), + ("mcp", endpoint.mcp.is_some()), + ( + "credential_signing", + !endpoint.credential_signing.is_empty(), + ), + ("signing_service", !endpoint.signing_service.is_empty()), + ("signing_region", !endpoint.signing_region.is_empty()), + ] { + if present { + fields.push(name); + } + } + + fields +} + #[derive(Debug, Clone)] struct TokenGrantOverrideBinding { override_index: usize, @@ -2224,6 +2498,133 @@ struct TokenGrantOverrideBinding { score: u32, } +fn validate_token_grant_subject_token( + source: &str, + profile_id: &str, + credential: &CredentialProfile, + credential_names: &HashSet, +) -> Vec { + let Some(token_grant) = credential.token_grant.as_ref() else { + return Vec::new(); + }; + let grant_type = effective_token_grant_type(token_grant.grant_type); + let mut diagnostics = Vec::new(); + + match grant_type { + ProviderCredentialTokenGrantType::ClientCredentials => { + if token_grant.subject_token.is_some() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token", + "subject_token is only valid for token_exchange grants", + )); + } + } + ProviderCredentialTokenGrantType::TokenExchange => { + let Some(subject_token) = token_grant.subject_token.as_ref() else { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token", + "token_exchange grants require subject_token", + )); + return diagnostics; + }; + + let source_value = subject_token.source.trim(); + if source_value != "provider_credential" { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.source", + "subject_token.source must be provider_credential", + )); + } + + let subject_credential = subject_token.credential.trim(); + if subject_credential.is_empty() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + "subject_token.credential is required", + )); + } else if !credential_names.contains(subject_credential) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + format!("unknown subject token credential: {subject_credential}"), + )); + } + } + ProviderCredentialTokenGrantType::Unspecified => { + unreachable!("effective_token_grant_type must normalize unspecified token grant type") + } + } + + diagnostics +} + +fn validate_broker_only_subject_credentials( + source: &str, + profile_id: &str, + profile: &ProviderTypeProfile, +) -> Vec { + let subject_credentials = token_exchange_subject_credential_names(profile); + if subject_credentials.is_empty() { + return Vec::new(); + } + + let mut diagnostics = Vec::new(); + for credential in &profile.credentials { + let credential_name = credential.name.trim(); + if !subject_credentials.contains(credential_name) { + continue; + } + if credential_has_workload_injection_metadata(credential) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + format!( + "subject token credential '{credential_name}' is broker-only and cannot declare workload injection metadata" + ), + )); + } + } + diagnostics +} + +fn token_exchange_subject_credential_names(profile: &ProviderTypeProfile) -> HashSet<&str> { + profile + .credentials + .iter() + .filter_map(|credential| credential.token_grant.as_ref()) + .filter(|token_grant| { + effective_token_grant_type(token_grant.grant_type) + == ProviderCredentialTokenGrantType::TokenExchange + }) + .filter_map(|token_grant| token_grant.subject_token.as_ref()) + .filter(|subject_token| subject_token.source.trim() == "provider_credential") + .filter_map(|subject_token| { + let credential = subject_token.credential.trim(); + (!credential.is_empty()).then_some(credential) + }) + .collect() +} + +fn credential_has_workload_injection_metadata(credential: &CredentialProfile) -> bool { + !credential.env_vars.is_empty() + || !credential.auth_style.trim().is_empty() + || !credential.header_name.trim().is_empty() + || !credential.query_param.trim().is_empty() + || !credential.path_template.trim().is_empty() + || credential.refresh.is_some() + || credential.token_grant.is_some() +} + fn validate_token_grant_audience_overrides( source: &str, profile_id: &str, @@ -2439,16 +2840,7 @@ fn path_prefix_pattern(path: &str) -> Option<&str> { } fn endpoint_path_matches(pattern: &str, path: &str) -> bool { - if path_matches_all(pattern) { - return true; - } - if pattern == path { - return true; - } - if let Some(prefix) = path_prefix_pattern(pattern) { - return path == prefix || path.starts_with(&format!("{prefix}/")); - } - glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path)) + openshell_core::endpoint_path::matches(pattern, path) } fn validate_token_grant_endpoint(token_endpoint: &str) -> Result<(), String> { @@ -2562,7 +2954,7 @@ pub fn builtin_profiles() -> &'static [ProviderTypeProfile] { mod tests { use std::collections::HashMap; - use openshell_core::proto::ProviderProfileCategory; + use openshell_core::proto::{ProviderCredentialTokenGrantType, ProviderProfileCategory}; use super::{ DiscoveryProfile, L7AllowProfile, L7QueryMatcherProfile, ProfileError, ProviderTypeProfile, @@ -2693,6 +3085,31 @@ mod tests { ); } + #[test] + fn accepted_stored_keys_use_logical_name_for_broker_only_credentials() { + let profile = parse_profile_yaml( + r" +id: token-exchange +display_name: Token Exchange +credentials: + - name: subject_token + required: true + - name: access_token + env_vars: [ACCESS_TOKEN, ACCESS_TOKEN_FALLBACK] +", + ) + .expect("profile"); + + assert_eq!( + profile.credentials[0].accepted_stored_keys(), + vec!["subject_token"] + ); + assert_eq!( + profile.credentials[1].accepted_stored_keys(), + vec!["ACCESS_TOKEN", "ACCESS_TOKEN_FALLBACK"] + ); + } + #[test] fn vertex_profile_declares_discovery_and_fallback_token_env_vars() { let profile = builtin_profile("google-vertex-ai"); @@ -2732,8 +3149,7 @@ mod tests { } #[test] - fn empty_provider_credentials_require_a_runtime_resolvable_path_and_no_required_static_credentials() - { + fn empty_provider_credentials_require_no_required_static_credentials() { let optional_refresh_profile = parse_profile_yaml( r" id: optional-refresh @@ -2747,6 +3163,7 @@ credentials: ) .expect("profile"); assert!(optional_refresh_profile.allows_empty_provider_credentials()); + assert!(optional_refresh_profile.allows_runtime_provider_credentials()); let token_grant_profile = parse_profile_yaml( r" @@ -2761,6 +3178,7 @@ credentials: ) .expect("profile"); assert!(token_grant_profile.allows_empty_provider_credentials()); + assert!(token_grant_profile.allows_runtime_provider_credentials()); let mixed_required_profile = parse_profile_yaml( r" @@ -2777,6 +3195,7 @@ credentials: ) .expect("profile"); assert!(!mixed_required_profile.allows_empty_provider_credentials()); + assert!(!mixed_required_profile.allows_runtime_provider_credentials()); let static_only_profile = parse_profile_yaml( r" @@ -2788,7 +3207,21 @@ credentials: ", ) .expect("profile"); - assert!(!static_only_profile.allows_empty_provider_credentials()); + assert!(static_only_profile.allows_empty_provider_credentials()); + assert!(!static_only_profile.allows_runtime_provider_credentials()); + + let policy_only_profile = parse_profile_yaml( + r" +id: policy-only +display_name: Policy Only +endpoints: + - host: example.com + port: 443 +", + ) + .expect("profile"); + assert!(policy_only_profile.allows_empty_provider_credentials()); + assert!(!policy_only_profile.allows_runtime_provider_credentials()); } #[test] @@ -3146,6 +3579,204 @@ credentials: ); } + #[test] + fn token_exchange_grant_round_trips_through_proto_and_yaml() { + let profile = parse_profile_yaml( + r" +id: keycloak-token-exchange +display_name: Keycloak Token Exchange +credentials: + - name: USER_OIDC_TOKEN + required: true + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN + subject_token_type: urn:ietf:params:oauth:token-type:access_token + jwt_svid_audience: https://keycloak.example.com/realms/openshell + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer + audience: https://graph.example.com + scopes: [graph.read] + requested_token_type: urn:ietf:params:oauth:token-type:access_token +", + ) + .expect("profile should parse"); + + let diagnostics = + validate_profile_set(&[("keycloak-token-exchange.yaml".to_string(), profile.clone())]); + assert!( + diagnostics.is_empty(), + "unexpected diagnostics: {diagnostics:?}" + ); + + let token_grant = profile.credentials[1] + .token_grant + .as_ref() + .expect("token grant should parse"); + assert_eq!( + token_grant.grant_type, + ProviderCredentialTokenGrantType::TokenExchange + ); + assert_eq!( + token_grant + .subject_token + .as_ref() + .map(|subject| subject.credential.as_str()), + Some("USER_OIDC_TOKEN") + ); + + let from_proto = ProviderTypeProfile::from_proto(&profile.to_proto()); + assert_eq!( + from_proto.credentials[1].token_grant, + profile.credentials[1].token_grant + ); + + let exported = profile_to_yaml(&from_proto).expect("yaml"); + assert!(exported.contains("grant_type: token_exchange")); + assert!(exported.contains("subject_token:")); + let reparsed = parse_profile_yaml(&exported).expect("re-parse"); + assert_eq!( + reparsed.credentials[1].token_grant, + profile.credentials[1].token_grant + ); + } + + #[test] + fn validate_profile_set_rejects_token_exchange_without_subject_token() { + let profile = parse_profile_yaml( + r" +id: missing-subject-token +display_name: Missing Subject Token +credentials: + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("missing.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.field == "credentials.token_grant.subject_token") + .expect("expected subject_token diagnostic"); + assert_eq!( + diagnostic.message, + "token_exchange grants require subject_token" + ); + } + + #[test] + fn validate_profile_set_rejects_token_exchange_unknown_subject_credential() { + let profile = parse_profile_yaml( + r" +id: unknown-subject-token +display_name: Unknown Subject Token +credentials: + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("unknown.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| { + diagnostic.field == "credentials.token_grant.subject_token.credential" + }) + .expect("expected subject token credential diagnostic"); + assert!( + diagnostic + .message + .contains("unknown subject token credential: USER_OIDC_TOKEN") + ); + } + + #[test] + fn validate_profile_set_rejects_injectable_token_exchange_subject_credential() { + let profile = parse_profile_yaml( + r" +id: injectable-subject-token +display_name: Injectable Subject Token +credentials: + - name: USER_OIDC_TOKEN + auth_style: header + header_name: X-Subject-Token + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("injectable.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| { + diagnostic.field == "credentials.token_grant.subject_token.credential" + && diagnostic.message.contains("broker-only") + }) + .expect("expected broker-only subject token diagnostic"); + assert!( + diagnostic + .message + .contains("cannot declare workload injection metadata") + ); + } + + #[test] + fn validate_profile_set_rejects_subject_token_on_client_credentials_grant() { + let profile = parse_profile_yaml( + r" +id: misplaced-subject-token +display_name: Misplaced Subject Token +credentials: + - name: USER_OIDC_TOKEN + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("misplaced.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.field == "credentials.token_grant.subject_token") + .expect("expected subject_token diagnostic"); + assert_eq!( + diagnostic.message, + "subject_token is only valid for token_exchange grants" + ); + } + #[test] fn validate_profile_set_rejects_plain_http_token_endpoint() { for token_endpoint in [ @@ -3344,6 +3975,8 @@ endpoints: - host: alpha.default.svc.cluster.local port: 80 path: /v1/** + protocol: rest + access: full ", ) .expect("profile should parse"); @@ -3384,6 +4017,8 @@ endpoints: - host: alpha.default.svc.cluster.local port: 80 path: /v1/** + protocol: rest + access: full ", ) .expect("profile should parse"); @@ -3476,6 +4111,7 @@ endpoints: - method: POST path: /admin/** allow_encoded_slash: true + allow_uninspected_credentials: true binaries: - path: /usr/bin/custom harness: true @@ -3509,6 +4145,8 @@ binaries: assert_eq!(rest_ep.tls, "terminate"); assert_eq!(rest_ep.allowed_ips, vec!["10.0.0.0/24"]); assert!(rest_ep.allow_encoded_slash); + assert!(rest_ep.allow_uninspected_credentials); + assert!(!rest_ep.provider_credentialed); assert_eq!( rest_ep .rules @@ -3527,9 +4165,93 @@ binaries: assert_eq!(reprotoo.endpoints[1].rules.len(), 1); assert_eq!(reprotoo.endpoints[1].deny_rules.len(), 1); assert_eq!(reprotoo.endpoints[1].ports, vec![443, 8443]); + assert!(reprotoo.endpoints[1].allow_uninspected_credentials); + assert!(!reprotoo.endpoints[1].provider_credentialed); assert!(reprotoo.binaries[0].harness); } + #[test] + fn profile_classifies_declared_credentials_and_signing_as_credentialed() { + let with_declared_credential = parse_profile_yaml( + r" +id: credentialed +display_name: Credentialed +credentials: + - name: token + env_vars: [TOKEN] +endpoints: + - host: api.example.com + port: 443 +", + ) + .expect("profile should parse"); + assert!(with_declared_credential.has_credentialed_endpoints()); + + let with_signing = parse_profile_yaml( + r" +id: signed +display_name: Signed +credentials: [] +endpoints: + - host: s3.example.com + port: 443 + credential_signing: sigv4 +", + ) + .expect("profile should parse"); + assert!(with_signing.has_credentialed_endpoints()); + + let plain = parse_profile_yaml( + r" +id: plain +display_name: Plain +credentials: [] +endpoints: + - host: pypi.org + port: 443 +", + ) + .expect("profile should parse"); + assert!(!plain.has_credentialed_endpoints()); + } + + #[test] + fn credentialed_profile_requires_opt_in_for_l4_endpoint() { + let profile = parse_profile_yaml( + r" +id: raw +display_name: Raw +credentials: + - name: token + env_vars: [TOKEN] +endpoints: + - host: raw.example.com + port: 443 +", + ) + .expect("profile should parse"); + let diagnostics = validate_profile_set(&[("raw.yaml".to_string(), profile)]); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.field == "endpoints[0].allow_uninspected_credentials" + })); + + let opted_in = parse_profile_yaml( + r" +id: raw +display_name: Raw +credentials: + - name: token + env_vars: [TOKEN] +endpoints: + - host: raw.example.com + port: 443 + allow_uninspected_credentials: true +", + ) + .expect("profile should parse"); + assert!(validate_profile_set(&[("raw.yaml".to_string(), opted_in)]).is_empty()); + } + #[test] fn validate_profile_set_returns_all_discoverable_diagnostics() { let profile = parse_profile_yaml( @@ -4221,6 +4943,112 @@ binaries: assert!(errors.is_empty(), "unexpected errors: {errors:?}"); } + #[test] + fn validate_accepts_explicit_tcp_without_l7_fields() { + let profile = parse_profile_yaml( + r" +id: valid-tcp +display_name: Valid TCP +credentials: + - name: api_key + env_vars: [API_KEY] + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + tls: skip + allow_uninspected_credentials: true + allowed_ips: [10.0.0.0/8] +binaries: + - /usr/bin/psql +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("profile.yaml".to_string(), profile)]); + let errors: Vec<_> = diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == "error") + .collect(); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + } + + #[test] + fn validate_rejects_additional_l7_field_families_with_explicit_tcp() { + let profile = parse_profile_yaml( + r" +id: invalid-tcp-l7 +display_name: Invalid TCP L7 +credentials: + - name: api_key + env_vars: [API_KEY] + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + enforcement: enforce + path: /query + allow_encoded_slash: true + websocket_credential_rewrite: true + request_body_credential_rewrite: true + persisted_queries: allow_registered + graphql_persisted_queries: + hash: + operation_type: query + graphql_max_body_bytes: 1024 + json_rpc_max_body_bytes: 1024 + mcp: + strict_tool_names: false + credential_signing: sigv4 + signing_service: rds + signing_region: us-west-2 +binaries: + - /usr/bin/psql +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("profile.yaml".to_string(), profile)]); + let tcp_error = diagnostics + .iter() + .find(|diagnostic| { + diagnostic + .message + .contains("protocol tcp does not support L7-only fields") + }) + .expect("explicit TCP should reject additional L7 fields"); + + for field in [ + "enforcement", + "path", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "json_rpc_max_body_bytes", + "mcp", + "credential_signing", + "signing_service", + "signing_region", + ] { + assert!( + tcp_error.message.contains(field), + "missing {field}: {}", + tcp_error.message + ); + } + } + #[test] fn validate_rejects_unknown_protocol() { let profile = parse_profile_yaml( diff --git a/crates/openshell-providers/src/providers/google_cloud.rs b/crates/openshell-providers/src/providers/google_cloud.rs index 470d1d206f..3bea53d825 100644 --- a/crates/openshell-providers/src/providers/google_cloud.rs +++ b/crates/openshell-providers/src/providers/google_cloud.rs @@ -5,29 +5,13 @@ use std::collections::HashMap; use openshell_core::google_cloud; -use crate::{ - DiscoveredProvider, Provider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, - RealDiscoveryContext, discover_with_spec, -}; +use crate::{Provider, ProviderPlugin}; pub struct GoogleCloudProvider; -const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { - id: "google-cloud", - credential_env_vars: google_cloud::TOKEN_ENV_KEYS, -}; - impl ProviderPlugin for GoogleCloudProvider { fn id(&self) -> &'static str { - SPEC.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - discover_with_spec(&SPEC, &RealDiscoveryContext) - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - SPEC.credential_env_vars + "google-cloud" } fn inject_env(&self, provider: &Provider, env: &mut HashMap) { diff --git a/crates/openshell-providers/src/providers/mod.rs b/crates/openshell-providers/src/providers/mod.rs index 2770210318..b6450e170e 100644 --- a/crates/openshell-providers/src/providers/mod.rs +++ b/crates/openshell-providers/src/providers/mod.rs @@ -1,46 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/// Generate a standard discovery smoke-test for a provider whose only test is -/// checking that an env-var credential is picked up by `discover_with_spec`. -/// -/// # Usage -/// ```ignore -/// test_discovers_env_credential!(discovers_openai_env_credentials, "OPENAI_API_KEY", "sk-test"); -/// ``` -macro_rules! test_discovers_env_credential { - ($test_name:ident, $env_var:expr, $env_value:expr) => { - #[cfg(test)] - mod tests { - use super::SPEC; - use crate::discover_with_spec; - use crate::test_helpers::MockDiscoveryContext; - - #[test] - fn $test_name() { - let ctx = MockDiscoveryContext::new().with_env($env_var, $env_value); - let discovered = discover_with_spec(&SPEC, &ctx) - .expect("discovery") - .expect("provider"); - assert_eq!( - discovered.credentials.get($env_var), - Some(&$env_value.to_string()) - ); - } - } - }; -} -pub mod anthropic; -pub mod claude; -pub mod codex; -pub mod copilot; -pub mod deepinfra; -pub mod generic; -pub mod github; -pub mod gitlab; pub mod google_cloud; -pub mod nvidia; -pub mod openai; -pub mod opencode; -pub mod outlook; pub mod vertex; diff --git a/crates/openshell-providers/src/providers/vertex.rs b/crates/openshell-providers/src/providers/vertex.rs index ad52bc51c0..ba3aacd007 100644 --- a/crates/openshell-providers/src/providers/vertex.rs +++ b/crates/openshell-providers/src/providers/vertex.rs @@ -6,43 +6,13 @@ use std::collections::HashMap; use openshell_core::google_cloud; use openshell_core::inference; -use crate::{ - DiscoveredProvider, Provider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, - RealDiscoveryContext, discover_with_spec, -}; +use crate::{Provider, ProviderPlugin}; pub struct VertexProvider; -const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { - id: "google-vertex-ai", - credential_env_vars: inference::VERTEX_AI_CREDENTIAL_KEY_NAMES, -}; - impl ProviderPlugin for VertexProvider { fn id(&self) -> &'static str { - SPEC.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - let mut discovered = discover_with_spec(&SPEC, &RealDiscoveryContext)?.unwrap_or_default(); - - for key in inference::VERTEX_AI_CONFIG_KEY_NAMES { - if let Ok(val) = std::env::var(key) - && !val.trim().is_empty() - { - discovered.config.entry(key.to_string()).or_insert(val); - } - } - - if discovered.is_empty() { - Ok(None) - } else { - Ok(Some(discovered)) - } - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - SPEC.credential_env_vars + "google-vertex-ai" } fn inject_env(&self, provider: &Provider, env: &mut HashMap) { diff --git a/crates/openshell-router/BUILD.bazel b/crates/openshell-router/BUILD.bazel deleted file mode 100644 index fafcfa5bc3..0000000000 --- a/crates/openshell-router/BUILD.bazel +++ /dev/null @@ -1,39 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-router", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-router_test", - crate = ":openshell-router", - deps = all_crate_deps(normal_dev = True), -) - -rust_test( - name = "backend_integration_test", - srcs = ["tests/backend_integration.rs"], - aliases = aliases(), - crate_root = "tests/backend_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-router"], -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":backend_integration_test", - ":openshell-router", - ":openshell-router_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index df8830ae39..84a5c6acf5 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -54,6 +54,10 @@ pub struct ProxyResponse { pub status: u16, pub headers: Vec<(String, String)>, pub body: bytes::Bytes, + /// Model identity of the route that produced this response. + pub route_model: Option, + /// Endpoint of the route that produced this response. + pub route_endpoint: Option, } /// Response from a proxied HTTP request where the body can be streamed @@ -61,6 +65,10 @@ pub struct ProxyResponse { pub struct StreamingProxyResponse { pub status: u16, pub headers: Vec<(String, String)>, + /// Model identity of the route that produced this response. + pub route_model: Option, + /// Endpoint of the route that produced this response. + pub route_endpoint: Option, /// Either a live response to stream from, or a pre-buffered body (for mock routes). body: StreamingBody, } @@ -98,6 +106,8 @@ impl StreamingProxyResponse { Self { status: resp.status, headers: resp.headers, + route_model: resp.route_model, + route_endpoint: resp.route_endpoint, body: StreamingBody::Buffered(Some(resp.body)), } } @@ -685,6 +695,8 @@ pub async fn proxy_to_backend( status, headers: resp_headers, body, + route_model: Some(route.model.clone()), + route_endpoint: Some(route.endpoint.clone()), }) } @@ -752,6 +764,8 @@ pub async fn proxy_to_backend_streaming( Ok(StreamingProxyResponse { status, headers: resp_headers, + route_model: Some(route.model.clone()), + route_endpoint: Some(route.endpoint.clone()), body: StreamingBody::Live(response), }) } @@ -982,8 +996,8 @@ fn is_vertex_anthropic_rawpredict_route(route: &ResolvedRoute) -> bool { mod tests { use super::{ ValidationFailure, ValidationFailureKind, build_backend_url, build_provider_url, - parse_bedrock_invocation_path, prepare_backend_request, rewrite_bedrock_path, - route_is_bedrock, verify_backend_endpoint, + parse_bedrock_invocation_path, prepare_backend_request, proxy_to_backend, + rewrite_bedrock_path, route_is_bedrock, verify_backend_endpoint, }; use crate::RouterError; use crate::config::{DEFAULT_ROUTE_TIMEOUT, ResolvedRoute}; @@ -2597,6 +2611,90 @@ mod tests { ); } + /// Vertex AI's OpenAI-compatible endpoint requires the body `model` field to + /// carry a publisher prefix (e.g. `google/gemini-2.5-flash`). This test + /// simulates the fix: `resolve_vertex_ai_route` sets `route.model` to the + /// prefixed form, and the body rewrite here forwards that value to Vertex. + /// + /// The mock server only accepts the prefixed form — matching Vertex's + /// behaviour — and returns 400 "Malformed publisher model" for the bare name. + #[tokio::test] + async fn vertex_openai_compat_rewrites_body_model_to_publisher_prefixed_form() { + let mock_server = MockServer::start().await; + + // Simulate Vertex accepting only the publisher-prefixed model name. + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_partial_json( + serde_json::json!({"model": "google/gemini-2.5-flash"}), + )) + .respond_with( + ResponseTemplate::new(200).set_body_json( + serde_json::json!({"choices": [{"message": {"content": "hi"}}]}), + ), + ) + .expect(1) + .mount(&mock_server) + .await; + + // Simulate Vertex rejecting the bare model name — the pre-fix failure. + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_partial_json( + serde_json::json!({"model": "gemini-2.5-flash"}), + )) + .respond_with(ResponseTemplate::new(400).set_body_json( + serde_json::json!({"error": {"message": "Malformed publisher model"}}), + )) + .expect(0) // must never be reached after the fix + .mount(&mock_server) + .await; + + // Route as produced by resolve_vertex_ai_route after the fix: + // route.model carries the publisher prefix. + let route = ResolvedRoute { + name: "vertex-gemini".to_string(), + endpoint: mock_server.uri(), + model: "google/gemini-2.5-flash".to_string(), + api_key: "ya29.token".to_string(), + protocols: vec!["openai_chat_completions".to_string()], + auth: AuthHeader::Bearer, + default_headers: vec![], + passthrough_headers: vec![], + timeout: DEFAULT_ROUTE_TIMEOUT, + model_in_path: false, + request_path_override: Some("/chat/completions".to_string()), + }; + + // The client sends the bare model name; the body rewrite must replace it + // with route.model (the publisher-prefixed form) before forwarding. + let client_body = serde_json::to_vec(&serde_json::json!({ + "model": "gemini-2.5-flash", + "messages": [{"role": "user", "content": "hello"}] + })) + .unwrap(); + + let client = reqwest::Client::new(); + let result = proxy_to_backend( + &client, + &route, + "openai_chat_completions", + "POST", + "/chat/completions", + vec![("content-type".to_string(), "application/json".to_string())], + bytes::Bytes::from(client_body), + ) + .await + .expect("proxy should succeed"); + + assert_eq!( + result.status, 200, + "Vertex mock must accept the publisher-prefixed model; \ + got {}: body rewrite did not apply the prefix", + result.status + ); + } + /// Defense-in-depth: a Bedrock route receiving a non-Bedrock path /// is rejected rather than forwarded. The L7 pattern detector /// upstream of the router should never produce this combination, diff --git a/crates/openshell-router/src/mock.rs b/crates/openshell-router/src/mock.rs index a60f7dcfcf..946839bae5 100644 --- a/crates/openshell-router/src/mock.rs +++ b/crates/openshell-router/src/mock.rs @@ -45,6 +45,8 @@ pub fn mock_response(route: &ResolvedRoute, source_protocol: &str) -> ProxyRespo ("x-openshell-mock".to_string(), "true".to_string()), ], body: body_bytes, + route_model: Some(route.model.clone()), + route_endpoint: Some(route.endpoint.clone()), } } diff --git a/crates/openshell-sandbox/BUILD.bazel b/crates/openshell-sandbox/BUILD.bazel deleted file mode 100644 index 3b4c74b96e..0000000000 --- a/crates/openshell-sandbox/BUILD.bazel +++ /dev/null @@ -1,73 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_binary.bzl", "rust_binary") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") -load("@workspace_version//:version.bzl", "WORKSPACE_VERSION") - -rust_library( - name = "openshell-sandbox", - srcs = glob( - ["src/**/*.rs"], - exclude = ["src/main.rs"], - ), - aliases = aliases(), - crate_features = ["telemetry"], - version = WORKSPACE_VERSION, - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_binary( - name = "openshell-sandbox-bin", - srcs = ["src/main.rs"], - aliases = aliases(), - binary_name = "openshell-sandbox", - crate_name = "openshell_sandbox", - version = WORKSPACE_VERSION, - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True) + [":openshell-sandbox"], -) - -rust_test( - name = "openshell-sandbox_lib_test", - compile_data = ["//crates/openshell-supervisor-network:sandbox-policy-rego"], - crate = ":openshell-sandbox", - crate_features = ["telemetry"], - deps = all_crate_deps(normal_dev = True), -) - -rust_test( - name = "openshell-sandbox_bin_test", - srcs = ["src/main.rs"], - aliases = aliases(), - version = WORKSPACE_VERSION, - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-sandbox"], -) - -rust_test( - name = "stdout_logging_integration_test", - srcs = ["tests/stdout_logging.rs"], - aliases = aliases(), - crate_root = "tests/stdout_logging.rs", - data = [":openshell-sandbox-bin"], - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-sandbox"], -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-sandbox", - ":openshell-sandbox-bin", - ":openshell-sandbox_bin_test", - ":openshell-sandbox_lib_test", - ":stdout_logging_integration_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 6a51635b14..3463f03767 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -16,9 +16,10 @@ path = "src/main.rs" [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-extension-core = { path = "../openshell-extension-core" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } -openshell-supervisor-network = { path = "../openshell-supervisor-network" } +openshell-supervisor-network = { path = "../openshell-supervisor-network", default-features = false } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } openshell-supervisor-process = { path = "../openshell-supervisor-process" } @@ -49,15 +50,30 @@ prost = { workspace = true } # Logging tracing = { workspace = true } +uuid = { workspace = true } tracing-subscriber = { workspace = true } tracing-appender = { workspace = true } [features] -default = ["telemetry"] -## Compile in telemetry activity collection (forwards to openshell-core/telemetry). -## On by default; build with `--no-default-features` for a telemetry-free sandbox -## supervisor that never collects or forwards activity summaries. +default = ["telemetry", "bundled-ca-roots"] +## Convenience alias: all defaults except bundled CA roots. Use +## `--no-default-features --features system-ca-roots` to build a supervisor +## that uses the platform trust store with telemetry intact. +system-ca-roots = ["telemetry"] +## Convenience alias: every default feature except `telemetry`. Build a +## telemetry-free supervisor with +## `--no-default-features --features defaults-without-telemetry` and stay +## correct as new default features are added. Cargo cannot subtract a single +## default feature, so this alias must be paired with `--no-default-features`; +## enabling it alongside `telemetry` is a compile error rather than a silent +## telemetry-on build. Kept in sync with `default` by +## `rust:verify:defaults-without-telemetry`. Do not pair it with +## `system-ca-roots`, which re-enables `telemetry`; a build with neither +## telemetry nor bundled CA roots is plain `--no-default-features`. +defaults-without-telemetry = ["bundled-ca-roots"] + telemetry = ["openshell-core/telemetry"] +bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] [dev-dependencies] tempfile = "3" diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index f9555d9f24..41c6ca3c94 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -5,6 +5,16 @@ //! //! This crate provides process sandboxing and monitoring capabilities. +// `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 +// a telemetry-on build that reads as telemetry-free. Fail the build instead. +#[cfg(all(feature = "telemetry", feature = "defaults-without-telemetry"))] +compile_error!( + "features `telemetry` and `defaults-without-telemetry` are mutually exclusive; \ + build a telemetry-free supervisor with `--no-default-features --features defaults-without-telemetry`" +); + mod activity_aggregator; mod denial_aggregator; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] @@ -16,6 +26,7 @@ mod sidecar_control; use miette::{IntoDiagnostic, Result, WrapErr}; use std::future::Future; +use std::pin::Pin; use std::sync::Arc; #[cfg(target_os = "linux")] use std::sync::atomic::Ordering; @@ -26,9 +37,9 @@ use tracing::{debug, info, warn}; use openshell_core::PolicyValidationFailureMode; use openshell_ocsf::{ - ActionId, ActivityId, AppLifecycleBuilder, ConfigStateChangeBuilder, DetectionFindingBuilder, - DispositionId, FindingInfo, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, - ocsf_emit, + ActionId, ActivityId, AppLifecycleBuilder, ConfidenceId, ConfigStateChangeBuilder, + DetectionFindingBuilder, DispositionId, FindingInfo, OcsfEvent, SandboxContext, SeverityId, + StateId, StatusId, ocsf_emit, }; // --------------------------------------------------------------------------- @@ -62,6 +73,7 @@ use openshell_core::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPol use openshell_core::proposals::AgentProposals; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_supervisor_network::opa::OpaEngine; +use openshell_supervisor_network::proxy::ProxyHandle; use openshell_supervisor_process::process::ProcessEnforcementMode; pub use openshell_supervisor_process::process::{ProcessHandle, ProcessStatus}; use openshell_supervisor_process::skills; @@ -70,9 +82,18 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::time::timeout; const SIDECAR_NETWORK_ENFORCEMENT_MODE: &str = "sidecar-nftables"; -const SIDECAR_TLS_DIR: &str = "/etc/openshell-tls/proxy"; +const SIDECAR_TLS_DIR: &str = openshell_core::container_paths::SIDECAR_TLS_DIR; const SIDECAR_CA_CERT: &str = "openshell-ca.pem"; const SIDECAR_CA_BUNDLE: &str = "ca-bundle.pem"; + +#[cfg(any(test, target_os = "linux"))] +fn has_network_runtime_capability(capabilities: Option<&str>, required: &str) -> bool { + capabilities.is_some_and(|capabilities| { + capabilities + .split(',') + .any(|capability| capability.trim() == required) + }) +} const SIDECAR_PROCESS_PROXY_ADDR: &str = "127.0.0.1:3128"; const SIDECAR_READY_TIMEOUT_SECS: u64 = 120; @@ -83,6 +104,7 @@ const SIDECAR_READY_TIMEOUT_SECS: u64 = 120; /// Returns an error if the command fails to start or encounters a fatal error. #[allow( clippy::too_many_arguments, + clippy::implicit_hasher, clippy::similar_names, clippy::fn_params_excessive_bools )] @@ -91,6 +113,7 @@ pub async fn run_sandbox( workdir: Option, timeout_secs: u64, interactive: bool, + await_main_process_attachment: bool, sandbox_id: Option, sandbox: Option, openshell_endpoint: Option, @@ -155,6 +178,11 @@ pub async fn run_sandbox( None }; + // Extension credentials are owned by this supervisor and shared by every + // gateway connection it opens, so the middleware registry's bearer slots + // and the policy poll loop that rotates them stay the same objects. + let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); + // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); @@ -165,6 +193,7 @@ pub async fn run_sandbox( middleware_registry_status, loaded_policy_origin, initial_agent_proposals_enabled, + initial_extension_authentication_enabled, ) = if let Some(bootstrap) = sidecar_bootstrap.as_ref() { let (policy, opa_engine, retained_proto, loaded_policy_origin) = load_policy_from_sidecar_bootstrap(bootstrap)?; @@ -175,6 +204,7 @@ pub async fn run_sandbox( MiddlewareRegistryStatus::Synchronized, loaded_policy_origin, bootstrap.agent_proposals_enabled, + false, ) } else { load_policy( @@ -183,101 +213,159 @@ pub async fn run_sandbox( openshell_endpoint.clone(), policy_rules, policy_data, + &extension_credentials, ) .await? }; // Normalize the active driver's identity contract once, while both the // policy and launched image filesystem are available. Kubernetes and - // OpenShift retain their authoritative numeric pair; Docker and Podman - // fill only omitted policy fields from OCI Config.User. + // OpenShift retain their authoritative numeric pair; Docker fills only + // omitted policy fields from OCI Config.User. #[cfg(unix)] - let resolved_process_identity = { + let (resolved_process_identity, workspace) = { let driver_identity = openshell_supervisor_process::identity::DriverIdentity::from_env()?; - openshell_supervisor_process::identity::resolve_process_identity( + let use_workdir_as_home = matches!( + &driver_identity, + openshell_supervisor_process::identity::DriverIdentity::OciUser { .. } + ); + let resolved = openshell_supervisor_process::identity::resolve_process_identity( &mut policy, &driver_identity, - )? + )?; + ( + resolved, + openshell_supervisor_process::process::ResolvedWorkspace::new( + workdir.clone(), + use_workdir_as_home, + ), + ) }; #[cfg(not(unix))] - let resolved_process_identity = - openshell_supervisor_process::process::ResolvedProcessIdentity::default(); + let (resolved_process_identity, workspace) = ( + openshell_supervisor_process::process::ResolvedProcessIdentity::default(), + openshell_supervisor_process::process::ResolvedWorkspace::new(workdir.clone(), false), + ); #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] - let (provider_credentials, mut provider_env) = - if let Some(bootstrap) = sidecar_bootstrap.as_ref() { - let provider_credentials = ProviderCredentialState::from_child_env_snapshot( - bootstrap.provider_env_revision, - bootstrap.provider_child_env.clone(), - ); - (provider_credentials, bootstrap.provider_child_env.clone()) - } else { - // Fetch provider environment variables from the server. - // This is done after loading the policy so the sandbox can still start - // even if provider env fetch fails (graceful degradation). - let ( - provider_env_revision, - provider_env, - provider_credential_expires_at_ms, - dynamic_credentials, - ) = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) { - match openshell_core::grpc_client::fetch_provider_environment(endpoint, id).await { - Ok(result) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .message(format!( - "Fetched provider environment [env_count:{}]", - result.environment.len() - )) - .build() - ); - ( - result.provider_env_revision, - result.environment, - result.credential_expires_at_ms, - result.dynamic_credentials, - ) - } - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .state(StateId::Other, "degraded") - .message(format!( - "Failed to fetch provider environment, continuing without: {e}" - )) - .build() - ); - ( - 0, - std::collections::HashMap::new(), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - ) - } + let (provider_credentials, mut provider_env) = if let Some(bootstrap) = + sidecar_bootstrap.as_ref() + { + let provider_credentials = ProviderCredentialState::from_child_env_snapshot( + bootstrap.provider_env_revision, + bootstrap.provider_child_env.clone(), + ); + (provider_credentials, bootstrap.provider_child_env.clone()) + } else { + // Fetch provider environment variables from the server. + // This is done after loading the policy so the sandbox can still start + // even if provider env fetch fails (graceful degradation). + let ( + provider_env_revision, + provider_env, + provider_credential_expires_at_ms, + dynamic_credentials, + static_credential_bindings, + non_secret_environment_keys, + ) = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) { + match openshell_core::grpc_client::fetch_provider_environment(endpoint, id).await { + Ok(result) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .message(format!( + "Fetched provider environment [env_count:{}]", + result.environment.len() + )) + .build() + ); + ( + result.provider_env_revision, + result.environment, + result.credential_expires_at_ms, + result.dynamic_credentials, + result.static_credential_bindings, + result.non_secret_environment_keys, + ) } - } else { - ( - 0, - std::collections::HashMap::new(), + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message(format!( + "Failed to fetch provider environment; no provider credentials are active: {e}" + )) + .build() + ); + ( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + Vec::new(), + ) + } + } + } else { + ( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + Vec::new(), + ) + }; + + let dynamic_credentials_fallback = dynamic_credentials.clone(); + let provider_credentials = match ProviderCredentialState::from_bound_environment( + provider_env_revision, + provider_env, + provider_credential_expires_at_ms, + dynamic_credentials, + static_credential_bindings, + non_secret_environment_keys, + ) { + Ok(credentials) => credentials, + Err(error) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message(format!( + "Rejected provider environment bindings; static provider credentials were revoked; fetched dynamic token grants remain active: {error}" + )) + .build() + ); + ProviderCredentialState::from_environment( + provider_env_revision, std::collections::HashMap::new(), std::collections::HashMap::new(), + dynamic_credentials_fallback, ) - }; - - let provider_credentials = ProviderCredentialState::from_environment( - provider_env_revision, - provider_env, - provider_credential_expires_at_ms, - dynamic_credentials, - ); - let provider_env = provider_credentials.child_env_with_gcp_resolved(); - (provider_credentials, provider_env) + } }; + let provider_env = provider_credentials.child_env_with_gcp_resolved(); + (provider_credentials, provider_env) + }; + + if credential_gating_unavailable( + &loaded_policy_origin, + provider_credentials.resolver().is_some(), + network_enabled, + ) { + report_credential_gating_unavailable(); + } + + // Canonical-process overrides are deliberately applied only to the main + // child. Keep the provider snapshot pristine because Kubernetes forwards + // it to the process sidecar for later exec/editor/SFTP children. // Shared agent-proposals feature flag. Seed from the same initial settings // snapshot that produced the policy so networking and process setup agree @@ -287,6 +375,10 @@ pub async fn run_sandbox( let process_control_writer = process_control_connection .as_ref() .map(|connection| connection.writer.clone()); + let process_exit_ack = Arc::new(tokio::sync::Mutex::new(None)); + let initial_provider_env_generation = sidecar_bootstrap + .as_ref() + .map_or(0, |bootstrap| bootstrap.provider_env_generation); let mut process_control_closed = None; if let Some(connection) = process_control_connection { process_control_closed = Some(connection.closed); @@ -294,6 +386,8 @@ pub async fn run_sandbox( connection.updates, provider_credentials.clone(), agent_proposals.clone(), + Arc::clone(&process_exit_ack), + initial_provider_env_generation, ); } @@ -313,6 +407,87 @@ pub async fn run_sandbox( None }; + #[cfg(target_os = "linux")] + let transparent_tcp_requested = opa_engine + .as_ref() + .map(|engine| engine.policy_dns_eligibility_snapshot()) + .transpose()? + .is_some_and(|snapshot| !snapshot.endpoints.is_empty()); + #[cfg(target_os = "linux")] + let runtime_capabilities = + std::env::var(openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES).ok(); + #[cfg(target_os = "linux")] + let transparent_tcp_capable = has_network_runtime_capability( + runtime_capabilities.as_deref(), + openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY, + ); + #[cfg(not(target_os = "linux"))] + let transparent_tcp_capable = false; + #[cfg(target_os = "linux")] + let transparent_runtime = if transparent_tcp_requested { + if !transparent_tcp_capable { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .state(StateId::Disabled, "unsupported_runtime") + .message( + "Policy DNS and transparent TCP unavailable: runtime capability is missing" + ) + .build() + ); + return Err(miette::miette!( + "policy contains protocol: tcp endpoints, but the selected runtime does not advertise policy DNS and transparent TCP support" + )); + } + if sidecar_network_enforcement { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .state(StateId::Disabled, "unsupported_topology") + .message("Policy DNS and transparent TCP unavailable: sidecar topology is unsupported") + .build() + ); + return Err(miette::miette!( + "policy DNS and transparent TCP are not yet supported by the sidecar topology" + )); + } + let namespace = netns.as_ref().ok_or_else(|| { + miette::miette!("policy DNS and transparent TCP require a workload network namespace") + })?; + let listeners = namespace + .bind_transparent_tcp_listeners() + .await + .into_diagnostic() + .wrap_err("failed to bind transparent TCP listeners")?; + let (dns_udp, dns_tcp) = namespace + .bind_policy_dns_sockets() + .await + .into_diagnostic() + .wrap_err("failed to bind policy DNS listeners")?; + let proxy_port = policy + .network + .proxy + .as_ref() + .and_then(|proxy| proxy.http_addr) + .map_or(3128, |address| address.port()); + let runtime = openshell_supervisor_network::run::TransparentRuntimeSetup::new( + listeners, + dns_udp, + dns_tcp, + sandbox_id.as_deref(), + )?; + let (ipv4_cidr, ipv6_cidr) = runtime.synthetic_cidrs(); + namespace.install_transparent_tcp_rules(proxy_port, &ipv4_cidr, &ipv6_cidr)?; + Some(runtime) + } else { + None + }; + #[cfg(target_os = "linux")] + let transparent_tcp_substrate_ready = transparent_runtime.is_some(); + #[cfg(not(target_os = "linux"))] + let transparent_tcp_substrate_ready = false; // The denial channel is owned by the orchestrator: the proxy (in the // networking leaf) and the bypass monitor (in the process leaf) both // produce DenialEvents that the denial aggregator (orchestrator-side) @@ -352,7 +527,7 @@ pub async fn run_sandbox( // API read the current value so proposals target the correct workspace. let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); - let networking = if network_enabled { + let mut networking = if network_enabled { #[cfg(target_os = "linux")] let proxy_bind_ip = netns .as_ref() @@ -378,6 +553,8 @@ pub async fn run_sandbox( agent_proposals.clone(), workspace_rx.clone(), &upstream_proxy_args, + #[cfg(target_os = "linux")] + transparent_runtime, ) .await?, ) @@ -409,6 +586,7 @@ pub async fn run_sandbox( sidecar_control::BootstrapData { policy_proto: proto.clone(), provider_env_revision: provider_credentials.snapshot().revision, + provider_env_generation: 0, provider_child_env: provider_env.clone(), agent_proposals_enabled: agent_proposals.enabled(), proxy_ca_cert_path: ca_paths.as_ref().map(|paths| paths.0.clone()), @@ -444,12 +622,15 @@ pub async fn run_sandbox( sidecar_control_task = Some(connection_task); spawn_sidecar_entrypoint_handler( entrypoint_rx, - entrypoint_pid.clone(), - opa_engine.clone(), - retained_proto.clone(), - openshell_endpoint.clone(), - sandbox_id.clone(), - std::path::PathBuf::from(trusted_ssh_socket_path), + SidecarEntrypointHandler { + entrypoint_pid: entrypoint_pid.clone(), + opa_engine: opa_engine.clone(), + retained_proto: retained_proto.clone(), + openshell_endpoint: openshell_endpoint.clone(), + sandbox_id: sandbox_id.clone(), + trusted_ssh_socket_path: std::path::PathBuf::from(trusted_ssh_socket_path), + control_publisher: sidecar_control_publisher.clone(), + }, ); } @@ -584,6 +765,13 @@ pub async fn run_sandbox( middleware_registry_status, sidecar_control_publisher: sidecar_control_publisher.clone(), workspace_tx, + extension_credentials: extension_credentials.clone(), + extension_authentication_enabled: initial_extension_authentication_enabled, + middleware_connector: default_middleware_connector(), + transparent_tcp: TransparentTcpReloadState { + capable: transparent_tcp_capable, + substrate_ready: transparent_tcp_substrate_ready, + }, }; tokio::spawn(async move { @@ -640,6 +828,7 @@ pub async fn run_sandbox( } let process_policy = process_policy_for_topology(&policy, sidecar_network_enforcement)?; + let main_env = provider_env.clone(); let sidecar_bootstrap_ca_file_paths = sidecar_bootstrap.as_ref().and_then(|bootstrap| { bootstrap .proxy_ca_cert_path @@ -647,6 +836,19 @@ pub async fn run_sandbox( .zip(bootstrap.proxy_ca_bundle_path.clone()) }); + let proxy_exited: Pin + Send>> = if let Some(rx) = networking + .as_mut() + .and_then(|n| n.proxy.as_mut()) + .and_then(ProxyHandle::take_exit_receiver) + { + Box::pin(async { + let _ = rx.await; + }) + } else { + Box::pin(std::future::pending()) + }; + tokio::pin!(proxy_exited); + let exit_code = if process_enabled { let ca_file_paths = networking .as_ref() @@ -661,14 +863,30 @@ pub async fn run_sandbox( } }); + let (ssh_exit_tx, ssh_exit_rx) = if ssh_socket_path.is_some() { + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + let ssh_exited: Pin + Send>> = if let Some(rx) = ssh_exit_rx { + Box::pin(async { + let _ = rx.await; + }) + } else { + Box::pin(std::future::pending()) + }; + tokio::pin!(ssh_exited); + let entrypoint_started_tx = if process_uses_sidecar_control && let Some(writer) = process_control_writer.clone() { let (tx, rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { match rx.await { - Ok(pid) => { + Ok((pid, instance_id)) => { if let Err(err) = - sidecar_control::send_entrypoint_started(&writer, pid).await + sidecar_control::send_entrypoint_started(&writer, pid, instance_id) + .await { warn!(error = %err, "Failed to send sidecar entrypoint event"); } @@ -682,24 +900,75 @@ pub async fn run_sandbox( } else { None }; + let sidecar_exit_tx = if process_uses_sidecar_control + && let Some(writer) = process_control_writer.clone() + { + let exit_ack = Arc::clone(&process_exit_ack); + let (tx, mut rx) = tokio::sync::mpsc::channel::< + openshell_supervisor_process::run::SidecarExitReport, + >(1); + tokio::spawn(async move { + while let Some(report) = rx.recv().await { + match report { + openshell_supervisor_process::run::SidecarExitReport::Exited { + instance_id, + exit_code, + ack, + } => { + let (durable_tx, durable_rx) = tokio::sync::oneshot::channel(); + *exit_ack.lock().await = Some((instance_id.clone(), durable_tx)); + let result = match sidecar_control::send_main_process_exited( + &writer, + instance_id, + exit_code, + ) + .await + { + Ok(()) => durable_rx.await.map_err(|_| { + "sidecar durable exit acknowledgement closed".to_string() + }), + Err(error) => Err(error.to_string()), + }; + let _ = ack.send(result); + } + openshell_supervisor_process::run::SidecarExitReport::Finalized { + instance_id, + ack, + } => { + let result = + sidecar_control::send_main_process_finalized(&writer, instance_id) + .await + .map_err(|error| error.to_string()); + let _ = ack.send(result); + } + } + } + }); + Some(tx) + } else { + None + }; let process = openshell_supervisor_process::run::run_process( program, args, - workdir.as_deref(), + workspace, timeout_secs, interactive, + await_main_process_attachment, sandbox_id.as_deref(), openshell_endpoint.as_deref(), ssh_socket_path, sidecar_network_enforcement, + ssh_exit_tx, &process_policy, resolved_process_identity, process_enforcement_mode, entrypoint_pid, entrypoint_started_tx, + sidecar_exit_tx, provider_credentials, - provider_env, + main_env, ca_file_paths, agent_proposals.clone(), #[cfg(target_os = "linux")] @@ -728,9 +997,71 @@ pub async fn run_sandbox( "authoritative network-sidecar control channel closed" )); } + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } + () = &mut ssh_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "SSH accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "SSH accept loop exited unexpectedly" + )); + } } } else { - process.await? + tokio::select! { + result = process => result?, + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } + () = &mut ssh_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "SSH accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "SSH accept loop exited unexpectedly" + )); + } + } } } else { // Network-only sidecar mode: keep the proxy and its background @@ -746,15 +1077,62 @@ pub async fn run_sandbox( warn!(?result, "Authoritative sidecar control channel exited; restarting sidecar"); 1 } + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } } } else { - wait_for_shutdown_signal().await; - 0 + tokio::select! { + () = wait_for_shutdown_signal() => 0, + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } + } } #[cfg(not(target_os = "linux"))] { - wait_for_shutdown_signal().await; - 0 + tokio::select! { + () = wait_for_shutdown_signal() => 0, + () = &mut proxy_exited => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Proxy accept loop exited unexpectedly; terminating sandbox" + ) + .build() + ); + return Err(miette::miette!( + "proxy accept loop exited unexpectedly" + )); + } + } } }; @@ -844,6 +1222,9 @@ type LoadedPolicyBundle = ( LoadedPolicyOrigin, ); +type MainProcessExitAckWaiter = + Arc)>>>; + fn load_policy_from_sidecar_bootstrap( bootstrap: &sidecar_control::BootstrapData, ) -> Result { @@ -866,25 +1247,30 @@ fn spawn_sidecar_control_update_watcher( mut updates: tokio::sync::mpsc::UnboundedReceiver, provider_credentials: ProviderCredentialState, agent_proposals: AgentProposals, + exit_ack: MainProcessExitAckWaiter, + mut provider_env_generation: u64, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { while let Some(update) = updates.recv().await { match update { sidecar_control::ControlUpdate::ProviderEnv { revision, + generation, provider_child_env, } => { - if revision <= provider_credentials.snapshot().revision { + if generation <= provider_env_generation { continue; } let env_count = provider_credentials .install_child_env_snapshot(revision, provider_child_env); + provider_env_generation = generation; ocsf_emit!( ConfigStateChangeBuilder::new(ocsf_ctx()) .severity(SeverityId::Informational) .status(StatusId::Success) .state(StateId::Enabled, "loaded") .unmapped("provider_env_revision", serde_json::json!(revision)) + .unmapped("provider_env_generation", serde_json::json!(generation)) .message(format!( "Sidecar provider environment refreshed [revision:{revision} env_count:{env_count}]" )) @@ -916,26 +1302,108 @@ fn spawn_sidecar_control_update_watcher( skills::install_static_skills, ); } - } + sidecar_control::ControlUpdate::MainProcessExitAck { instance_id } => { + let mut waiter = exit_ack.lock().await; + if waiter + .as_ref() + .is_some_and(|(expected, _)| expected == &instance_id) + && let Some((_, ack)) = waiter.take() + { + let _ = ack.send(()); + } + } + } } }) } #[cfg(target_os = "linux")] -fn spawn_sidecar_entrypoint_handler( - mut entrypoint_rx: tokio::sync::mpsc::Receiver, +struct SidecarEntrypointHandler { entrypoint_pid: Arc, opa_engine: Option>, retained_proto: Option, openshell_endpoint: Option, sandbox_id: Option, trusted_ssh_socket_path: std::path::PathBuf, + control_publisher: Option, +} + +#[cfg(target_os = "linux")] +fn spawn_sidecar_entrypoint_handler( + mut entrypoint_rx: tokio::sync::mpsc::Receiver, + handler: SidecarEntrypointHandler, ) { tokio::spawn(async move { + let SidecarEntrypointHandler { + entrypoint_pid, + opa_engine, + retained_proto, + openshell_endpoint, + sandbox_id, + trusted_ssh_socket_path, + control_publisher, + } = handler; let mut session_started = false; + let mut session_task: Option> = None; let mut trusted_supervisor_pid = None; let terminating = Arc::new(AtomicBool::new(false)); while let Some(started) = entrypoint_rx.recv().await { + if started.finalized { + if let (Some(endpoint), Some(id)) = + (openshell_endpoint.as_ref(), sandbox_id.as_ref()) + { + let mut delay = Duration::from_millis(250); + loop { + match openshell_supervisor_process::supervisor_session::finalize_main_process_exit( + endpoint, + id, + &started.instance_id, + ) + .await + { + Ok(()) => break, + Err(error) => { + warn!(%error, "sidecar main-process finalization failed; retrying"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(2)); + } + } + } + } + terminating.store(true, Ordering::Release); + if let Some(task) = session_task.take() { + task.abort(); + } + break; + } + if let Some(exit_code) = started.exit_code { + if let (Some(endpoint), Some(id)) = + (openshell_endpoint.as_ref(), sandbox_id.as_ref()) + { + let mut delay = Duration::from_millis(250); + loop { + match openshell_supervisor_process::supervisor_session::report_main_process_exit( + endpoint, + id, + &started.instance_id, + exit_code, + ) + .await + { + Ok(()) => break, + Err(error) => { + warn!(%error, "sidecar main-process exit report failed; retrying"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(2)); + } + } + } + if let Some(publisher) = control_publisher.as_ref() { + publisher.publish_main_process_exit_ack(started.instance_id.clone()); + } + } + continue; + } entrypoint_pid.store(started.pid, Ordering::Release); if started.start_session { info!( @@ -977,14 +1445,15 @@ fn spawn_sidecar_entrypoint_handler( ); continue; }; - openshell_supervisor_process::supervisor_session::spawn( + session_task = Some(openshell_supervisor_process::supervisor_session::spawn( endpoint.clone(), id.clone(), trusted_ssh_socket_path.clone(), None, Some(supervisor_pid), Arc::clone(&terminating), - ); + started.instance_id.clone(), + )); session_started = true; info!("sidecar supervisor session task spawned"); } @@ -1157,9 +1626,9 @@ const PROXY_BASELINE_READ_ONLY: &[&str] = &[ "/dev/urandom", ]; -/// Minimum read-write paths required for a proxy-mode sandbox child process: -/// user working directory and temporary files. -const PROXY_BASELINE_READ_WRITE: &[&str] = &["/sandbox", "/tmp"]; +/// Minimum read-write paths required for a proxy-mode sandbox child process. +/// The active workspace is granted separately through `include_workdir`. +const PROXY_BASELINE_READ_WRITE: &[&str] = &["/tmp"]; /// GPU read-only paths. /// @@ -1473,6 +1942,7 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { mod baseline_tests { use super::*; use openshell_core::policy::{FilesystemPolicy, LandlockPolicy, ProcessPolicy}; + use std::path::PathBuf; #[test] fn proc_not_in_both_read_only_and_read_write_when_gpu_present() { @@ -1508,10 +1978,10 @@ mod baseline_tests { } #[test] - fn baseline_read_write_always_includes_sandbox_and_tmp() { + fn baseline_read_write_does_not_hardcode_sandbox() { let (_ro, rw) = baseline_enrichment_paths(); - assert!(rw.contains(&"/sandbox".to_string())); assert!(rw.contains(&"/tmp".to_string())); + assert!(!rw.contains(&"/sandbox".to_string())); } #[test] @@ -1704,7 +2174,7 @@ mod baseline_tests { let mut policy = SandboxPolicy { version: 1, filesystem: FilesystemPolicy { - read_only: vec![std::path::PathBuf::from("/tmp")], + read_only: vec![PathBuf::from("/tmp")], read_write: vec![], include_workdir: false, }, @@ -1719,17 +2189,14 @@ mod baseline_tests { enrich_sandbox_baseline_paths(&mut policy); assert!( - policy - .filesystem - .read_only - .contains(&std::path::PathBuf::from("/tmp")), + policy.filesystem.read_only.contains(&PathBuf::from("/tmp")), "explicit read_only baseline path should be preserved" ); assert!( !policy .filesystem .read_write - .contains(&std::path::PathBuf::from("/tmp")), + .contains(&PathBuf::from("/tmp")), "baseline enrichment must not promote explicit read_only /tmp to read_write" ); } @@ -1837,6 +2304,7 @@ async fn load_policy( openshell_endpoint: Option, policy_rules: Option, policy_data: Option, + extension_credentials: &openshell_extension_core::ExtensionCredentialStore, ) -> Result<( SandboxPolicy, Option>, @@ -1844,6 +2312,7 @@ async fn load_policy( MiddlewareRegistryStatus, LoadedPolicyOrigin, bool, + bool, )> { // File mode: load OPA engine from rego rules + YAML data (dev override) if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) { @@ -1893,6 +2362,7 @@ async fn load_policy( MiddlewareRegistryStatus::Synchronized, LoadedPolicyOrigin::LocalOverride, false, + false, )); } @@ -2055,10 +2525,32 @@ async fn load_policy( let middleware_registry_status = if middleware_services.is_empty() { MiddlewareRegistryStatus::Synchronized } else if let Err(error) = grpc_retry("Middleware connect", || { - openshell_supervisor_middleware::MiddlewareRegistry::connect_services( - openshell_supervisor_middleware_builtins::services(), - middleware_services.clone(), - ) + let middleware_services = middleware_services.clone(); + let extension_credentials = extension_credentials.clone(); + let extension_authentication_enabled = snapshot.extension_authentication_enabled; + async move { + let credentials = if extension_authentication_enabled { + // Share the supervisor's store so the slots installed here + // are the ones the policy poll loop later rotates in place. + openshell_core::grpc_client::CachedOpenShellClient::connect_with_credentials( + endpoint, + extension_credentials, + ) + .await? + .refresh_extension_credentials(&middleware_services) + .await? + } else { + std::collections::HashMap::new() + }; + connect_middleware_registry( + &middleware_services, + &MiddlewareAuthentication { + credentials, + enabled: extension_authentication_enabled, + }, + ) + .await + } }) .await .and_then(|registry| engine.replace_middleware_registry(registry)) @@ -2101,6 +2593,7 @@ async fn load_policy( has_last_valid_policy, }, agent_proposals_enabled_from_settings(&snapshot.settings), + snapshot.extension_authentication_enabled, )); } @@ -2229,12 +2722,14 @@ enum MiddlewareRegistryStatus { #[derive(Debug)] enum GatewayRuntimeReloadError { PolicyValidation(miette::Report), + TransparentTcpPrerequisite(miette::Report), MiddlewareRegistry(miette::Report), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum GatewayRuntimeFailureClass { PolicyValidation, + TransparentTcpPrerequisite, MiddlewareRegistry, } @@ -2242,6 +2737,9 @@ impl GatewayRuntimeReloadError { fn class(&self) -> GatewayRuntimeFailureClass { match self { Self::PolicyValidation(_) => GatewayRuntimeFailureClass::PolicyValidation, + Self::TransparentTcpPrerequisite(_) => { + GatewayRuntimeFailureClass::TransparentTcpPrerequisite + } Self::MiddlewareRegistry(_) => GatewayRuntimeFailureClass::MiddlewareRegistry, } } @@ -2264,18 +2762,46 @@ impl FailedRuntimeRevision { } } +struct MiddlewareReloadContext<'a> { + desired_services: &'a [openshell_core::proto::SupervisorMiddlewareService], + authentication: &'a MiddlewareAuthentication, + registry_changed: bool, + connector: &'a MiddlewareConnector, +} + async fn reload_gateway_policy_runtime( engine: &OpaEngine, policy: Option<&openshell_core::proto::SandboxPolicy>, entrypoint_pid: u32, - desired_services: &[openshell_core::proto::SupervisorMiddlewareService], - middleware_registry_changed: bool, + middleware: MiddlewareReloadContext<'_>, + transparent_tcp: TransparentTcpReloadState, ) -> std::result::Result<(), GatewayRuntimeReloadError> { + if let Some(policy) = policy + && policy_contains_explicit_tcp(policy) + { + if !transparent_tcp.capable { + return Err(GatewayRuntimeReloadError::TransparentTcpPrerequisite( + miette::miette!( + "candidate policy introduces protocol: tcp, but the runtime does not advertise transparent TCP support; previous policy remains active" + ), + )); + } + if !transparent_tcp.substrate_ready { + return Err(GatewayRuntimeReloadError::TransparentTcpPrerequisite( + miette::miette!( + "candidate policy introduces protocol: tcp, but this sandbox started without the transparent TCP substrate; recreate the sandbox to enable TCP; previous policy remains active" + ), + )); + } + } match policy { - Some(policy) if middleware_registry_changed => { - let registry = connect_middleware_registry(desired_services) - .await - .map_err(GatewayRuntimeReloadError::MiddlewareRegistry)?; + Some(policy) if middleware.registry_changed => { + let registry = (middleware.connector)( + middleware.desired_services.to_vec(), + middleware.authentication.clone(), + ) + .await + .map_err(GatewayRuntimeReloadError::MiddlewareRegistry)?; engine .reload_policy_and_middleware_from_proto_with_pid(policy, entrypoint_pid, registry) .map_err(GatewayRuntimeReloadError::PolicyValidation) @@ -2292,6 +2818,20 @@ async fn reload_gateway_policy_runtime( } } +fn policy_contains_explicit_tcp(policy: &openshell_core::proto::SandboxPolicy) -> bool { + policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.protocol.eq_ignore_ascii_case("tcp")) + }) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct TransparentTcpReloadState { + capable: bool, + substrate_ready: bool, +} + /// True when the installed middleware registry no longer matches the desired /// service set and must be rebuilt (reconnecting every delivered service). /// @@ -2392,7 +2932,13 @@ struct PolicyStatusUpdate { version: u32, loaded: bool, error: String, - initial_policy_hash: Option, + success_event: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PolicyStatusSuccessEvent { + InitialAcknowledgement { policy_hash: String }, + UnchangedAcknowledgement { policy_hash: String }, } impl PolicyStatusUpdate { @@ -2401,7 +2947,9 @@ impl PolicyStatusUpdate { version: ack.version, loaded: true, error: String::new(), - initial_policy_hash: Some(ack.policy_hash.clone()), + success_event: Some(PolicyStatusSuccessEvent::InitialAcknowledgement { + policy_hash: ack.policy_hash.clone(), + }), } } @@ -2410,7 +2958,16 @@ impl PolicyStatusUpdate { version, loaded: true, error: String::new(), - initial_policy_hash: None, + success_event: None, + } + } + + fn unchanged_loaded(version: u32, policy_hash: String) -> Self { + Self { + version, + loaded: true, + error: String::new(), + success_event: Some(PolicyStatusSuccessEvent::UnchangedAcknowledgement { policy_hash }), } } @@ -2419,7 +2976,7 @@ impl PolicyStatusUpdate { version, loaded: false, error, - initial_policy_hash: None, + success_event: None, } } } @@ -2480,18 +3037,167 @@ fn initial_poll_disposition( } } +fn unchanged_policy_revision_candidate( + reloads_gateway_policy: bool, + recovering_rejected_policy: bool, + current_policy_version: u32, + current_policy_hash: &str, + result: &openshell_core::grpc_client::SettingsPollResult, +) -> Option { + (reloads_gateway_policy + && !recovering_rejected_policy + && !current_policy_hash.is_empty() + && result.policy_source == openshell_core::proto::PolicySource::Sandbox + && result.version > current_policy_version + && result.policy_hash == current_policy_hash) + .then_some(result.version) +} + +fn unchanged_policy_revision_ready_to_ack( + candidate: Option, + policy_runtime_changed: bool, + policy_runtime_reconciled: bool, +) -> Option { + candidate.filter(|_| !policy_runtime_changed || policy_runtime_reconciled) +} + +/// Whether the credential-provenance gates cannot apply to the loaded policy. +/// +/// The gateway derives `provider_credentialed` and deliberately keeps it out of +/// the policy YAML schema, so a local-file policy never carries it and never +/// will: gateway revisions are observed for settings and providers but must not +/// replace the local OPA policy. Provider credentials still arrive from the +/// gateway on that path, so the raw-tunnel and WebSocket binary-frame refusals +/// have nothing to match on. The request-body backstop is unaffected because it +/// keys off the secret resolver rather than endpoint provenance. +fn credential_gating_unavailable( + origin: &LoadedPolicyOrigin, + has_resolver: bool, + network_enabled: bool, +) -> bool { + network_enabled && has_resolver && matches!(origin, LoadedPolicyOrigin::LocalOverride) +} + +/// Report that credential provenance is unavailable for the loaded policy. +/// +/// Carries no credential name, host, or value: the finding states which +/// controls are inactive, nothing about what they would have protected. +fn report_credential_gating_unavailable() { + ocsf_emit!( + DetectionFindingBuilder::new(ocsf_ctx()) + .activity(ActivityId::Open) + .severity(SeverityId::High) + .confidence(ConfidenceId::High) + .is_alert(true) + .finding_info( + FindingInfo::new( + "credential-gating-unavailable", + "Credential Provenance Unavailable", + ) + .with_desc( + "Provider credentials are injected, but the loaded policy comes from local \ + files and carries no gateway-derived credential provenance. Uninspected \ + credentialed tunnels and WebSocket binary frames are not refused. Load \ + policy from the gateway to enable these controls." + ), + ) + .evidence_pairs(&[ + ("policy_source", "local-override"), + ("uninspected_connect_gate", "inactive"), + ("websocket_binary_gate", "inactive"), + ("request_body_backstop", "active"), + ]) + .remediation( + "Remove the local policy override so the gateway-delivered effective policy \ + applies, or detach provider credentials from this sandbox." + ) + .message( + "Credential provenance unavailable for local-file policy; uninspected credential gates inactive" + ) + .build() + ); +} + /// Deliver policy status updates independently from policy reconciliation. /// /// The channel is FIFO, so a delayed older status can never arrive after a /// newer status and move the gateway's active version backward. Delivery uses /// the existing bounded retry, but failures never delay policy enforcement. -async fn run_policy_status_reporter( - client: openshell_core::grpc_client::CachedOpenShellClient, +#[tonic::async_trait] +trait PolicyGatewayClient: Clone + Send + Sync + 'static { + async fn poll_settings( + &self, + sandbox_id: &str, + ) -> Result; + + async fn report_policy_status( + &self, + sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()>; + + async fn refresh_installed_extension_credentials(&self) -> Result<()> { + Ok(()) + } + + async fn extension_credentials_for( + &self, + _services: &[openshell_core::proto::SupervisorMiddlewareService], + ) -> Result> { + Ok(std::collections::HashMap::new()) + } + + fn workspace(&self) -> String; +} + +#[tonic::async_trait] +impl PolicyGatewayClient for openshell_core::grpc_client::CachedOpenShellClient { + async fn poll_settings( + &self, + sandbox_id: &str, + ) -> Result { + self.poll_settings(sandbox_id).await + } + + async fn report_policy_status( + &self, + sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()> { + self.report_policy_status(sandbox_id, version, loaded, error) + .await + } + + async fn refresh_installed_extension_credentials(&self) -> Result<()> { + self.refresh_installed_extension_credentials().await + } + + async fn extension_credentials_for( + &self, + services: &[openshell_core::proto::SupervisorMiddlewareService], + ) -> Result> { + self.extension_credentials_for(services).await + } + + fn workspace(&self) -> String { + self.workspace() + } +} + +async fn run_policy_status_reporter( + client: C, sandbox_id: String, mut updates: tokio::sync::mpsc::UnboundedReceiver, ) { 'updates: while let Some(update) = updates.recv().await { - let operation = if update.initial_policy_hash.is_some() { + let operation = if matches!( + update.success_event, + Some(PolicyStatusSuccessEvent::InitialAcknowledgement { .. }) + ) { "Initial policy acknowledgement" } else { "Policy status report" @@ -2531,7 +3237,23 @@ async fn run_policy_status_reporter( } } - if let Some(policy_hash) = update.initial_policy_hash { + if let Some(event) = update.success_event { + let (policy_hash, message) = match event { + PolicyStatusSuccessEvent::InitialAcknowledgement { policy_hash } => ( + policy_hash, + format!( + "Acknowledged initial policy revision as loaded [version:{}]", + update.version + ), + ), + PolicyStatusSuccessEvent::UnchangedAcknowledgement { policy_hash } => ( + policy_hash, + format!( + "Acknowledged unchanged policy revision as loaded [version:{}]", + update.version + ), + ), + }; ocsf_emit!( ConfigStateChangeBuilder::new(ocsf_ctx()) .severity(SeverityId::Informational) @@ -2539,10 +3261,7 @@ async fn run_policy_status_reporter( .state(StateId::Enabled, "loaded") .unmapped("version", serde_json::json!(update.version)) .unmapped("policy_hash", serde_json::json!(policy_hash)) - .message(format!( - "Acknowledged initial policy revision as loaded [version:{}]", - update.version - )) + .message(message) .build() ); } @@ -2626,16 +3345,57 @@ struct PolicyPollLoopContext { middleware_registry_status: MiddlewareRegistryStatus, sidecar_control_publisher: Option, workspace_tx: tokio::sync::watch::Sender, + extension_credentials: openshell_extension_core::ExtensionCredentialStore, + extension_authentication_enabled: bool, + middleware_connector: MiddlewareConnector, + /// Immutable driver capability and startup substrate state. + transparent_tcp: TransparentTcpReloadState, +} + +type MiddlewareConnector = Arc< + dyn Fn( + Vec, + MiddlewareAuthentication, + ) -> Pin< + Box< + dyn std::future::Future< + Output = Result, + > + Send, + >, + > + Send + + Sync, +>; + +#[derive(Clone, Default)] +struct MiddlewareAuthentication { + credentials: std::collections::HashMap, + enabled: bool, +} + +fn default_middleware_connector() -> MiddlewareConnector { + Arc::new(|services, authentication| { + Box::pin(async move { connect_middleware_registry(&services, &authentication).await }) + }) } async fn connect_middleware_registry( services: &[openshell_core::proto::SupervisorMiddlewareService], + authentication: &MiddlewareAuthentication, ) -> Result { - openshell_supervisor_middleware::MiddlewareRegistry::connect_services( - openshell_supervisor_middleware_builtins::services(), - services.to_vec(), - ) - .await + if authentication.enabled { + openshell_supervisor_middleware::MiddlewareRegistry::connect_services_authenticated( + openshell_supervisor_middleware_builtins::services(), + services.to_vec(), + &authentication.credentials, + ) + .await + } else { + openshell_supervisor_middleware::MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + services.to_vec(), + ) + .await + } } async fn install_builtin_middleware_registry(opa_engine: &OpaEngine) -> Result<()> { @@ -2647,26 +3407,76 @@ async fn install_builtin_middleware_registry(opa_engine: &OpaEngine) -> Result<( opa_engine.replace_middleware_registry(registry) } +/// Wait the configured poll interval, but never past the point at which an +/// installed extension credential must be rotated. +fn next_poll_delay( + store: &openshell_extension_core::ExtensionCredentialStore, + interval: Duration, +) -> Duration { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| { + i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX) + }); + store.next_refresh_delay(interval, now_ms) +} + +/// Drop credentials for services no longer in the installed registry. +/// +/// Call only after a registry swap succeeds, so a failed candidate cannot +/// invalidate the last-known-good clients. +fn retain_extension_credentials( + store: &openshell_extension_core::ExtensionCredentialStore, + installed: &[openshell_core::proto::SupervisorMiddlewareService], + extension_authentication_enabled: bool, +) { + let retained = if extension_authentication_enabled { + installed + .iter() + .map(|service| service.name.as_str()) + .collect() + } else { + std::collections::HashSet::default() + }; + store.retain(&retained); +} + +struct MiddlewareRegistryReconciliation<'a> { + desired_services: &'a [openshell_core::proto::SupervisorMiddlewareService], + authentication: MiddlewareAuthentication, + registry_changed: bool, + extension_credentials: &'a openshell_extension_core::ExtensionCredentialStore, + current_services: &'a mut Vec, + status: &'a mut MiddlewareRegistryStatus, +} + async fn reconcile_middleware_registry( opa_engine: &OpaEngine, - desired_services: &[openshell_core::proto::SupervisorMiddlewareService], - current_services: &mut Vec, - status: &mut MiddlewareRegistryStatus, + middleware_connector: &MiddlewareConnector, + reconciliation: MiddlewareRegistryReconciliation<'_>, ) { - if *status == MiddlewareRegistryStatus::Synchronized - && desired_services == current_services.as_slice() - { + if !reconciliation.registry_changed { return; } - match connect_middleware_registry(desired_services) - .await - .and_then(|registry| opa_engine.replace_middleware_registry(registry)) + match middleware_connector( + reconciliation.desired_services.to_vec(), + reconciliation.authentication.clone(), + ) + .await + .and_then(|registry| opa_engine.replace_middleware_registry(registry)) { Ok(()) => { - current_services.clear(); - current_services.extend_from_slice(desired_services); - *status = MiddlewareRegistryStatus::Synchronized; + retain_extension_credentials( + reconciliation.extension_credentials, + reconciliation.desired_services, + reconciliation.authentication.enabled, + ); + reconciliation.current_services.clear(); + reconciliation + .current_services + .extend_from_slice(reconciliation.desired_services); + *reconciliation.status = MiddlewareRegistryStatus::Synchronized; ocsf_emit!( ConfigStateChangeBuilder::new(ocsf_ctx()) .severity(SeverityId::Informational) @@ -2674,11 +3484,11 @@ async fn reconcile_middleware_registry( .state(StateId::Enabled, "loaded") .unmapped( "supervisor_middleware_service_count", - serde_json::json!(current_services.len()) + serde_json::json!(reconciliation.current_services.len()) ) .message(format!( "Supervisor middleware registry reloaded [service_count:{}]", - current_services.len() + reconciliation.current_services.len() )) .build() ); @@ -2686,7 +3496,7 @@ async fn reconcile_middleware_registry( Err(error) => { // Emit only on the transition into the failed state to avoid // repeating the same finding on every poll during an outage. - if *status == MiddlewareRegistryStatus::Synchronized { + if *reconciliation.status == MiddlewareRegistryStatus::Synchronized { ocsf_emit!( ConfigStateChangeBuilder::new(ocsf_ctx()) .severity(SeverityId::Medium) @@ -2698,7 +3508,7 @@ async fn reconcile_middleware_registry( .build() ); } - *status = MiddlewareRegistryStatus::NeedsReconciliation; + *reconciliation.status = MiddlewareRegistryStatus::NeedsReconciliation; } } } @@ -2726,6 +3536,10 @@ enum GatewayRuntimeFailureDisposition { MiddlewareUnavailable { error: String, }, + TransparentTcpExpansionRejected { + error: String, + active_generation: u64, + }, } fn apply_gateway_runtime_reload_failure( @@ -2747,6 +3561,12 @@ fn apply_gateway_runtime_reload_failure( )?; Ok(GatewayRuntimeFailureDisposition::PolicyRejected { error, disposition }) } + GatewayRuntimeReloadError::TransparentTcpPrerequisite(error) => Ok( + GatewayRuntimeFailureDisposition::TransparentTcpExpansionRejected { + error: error.to_string(), + active_generation: engine.current_generation(), + }, + ), GatewayRuntimeReloadError::MiddlewareRegistry(error) => { Ok(GatewayRuntimeFailureDisposition::MiddlewareUnavailable { error: error.to_string(), @@ -2755,6 +3575,30 @@ fn apply_gateway_runtime_reload_failure( } } +fn emit_transparent_tcp_expansion_rejection( + version: u32, + policy_hash: &str, + active_generation: u64, + error: &str, +) { + let message = format!( + "Transparent TCP policy expansion rejected; previous policy IS active [version:{version} active_generation:{active_generation} error:{error}]" + ); + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Enabled, "retained_previous_policy") + .unmapped("candidate_version", serde_json::json!(version)) + .unmapped("candidate_policy_hash", serde_json::json!(policy_hash)) + .unmapped("previous_policy_active", serde_json::json!(true)) + .unmapped("active_generation", serde_json::json!(active_generation)) + .unmapped("validation_error", serde_json::json!(error)) + .message(message) + .build() + ); +} + fn apply_policy_validation_failure( engine: &OpaEngine, configured_mode: PolicyValidationFailureMode, @@ -2885,11 +3729,21 @@ fn emit_policy_validation_failure( } async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { - use openshell_core::grpc_client::CachedOpenShellClient; + let client = openshell_core::grpc_client::CachedOpenShellClient::connect_with_credentials( + &ctx.endpoint, + ctx.extension_credentials.clone(), + ) + .await?; + run_policy_poll_loop_with_client(ctx, client).await +} + +async fn run_policy_poll_loop_with_client( + ctx: PolicyPollLoopContext, + client: C, +) -> Result<()> { use openshell_core::proto::PolicySource; use std::sync::atomic::Ordering; - let client = CachedOpenShellClient::connect(&ctx.endpoint).await?; let (status_sender, status_receiver) = tokio::sync::mpsc::unbounded_channel(); tokio::spawn(run_policy_status_reporter( client.clone(), @@ -2899,8 +3753,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { let mut current_config_revision: u64 = 0; let mut current_provider_env_revision: u64 = ctx.provider_credentials.snapshot().revision; + let mut current_policy_version: u32 = 0; let mut current_policy_hash = String::new(); let mut current_middleware_services = Vec::new(); + let mut current_extension_authentication_enabled = ctx.extension_authentication_enabled; let mut middleware_registry_status = ctx.middleware_registry_status; let mut current_settings: std::collections::HashMap< String, @@ -2934,8 +3790,11 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { skills::install_static_skills, ); current_config_revision = candidate.config_revision; + current_policy_version = candidate.version; current_policy_hash.clone_from(&candidate.policy_hash); current_middleware_services = result.supervisor_middleware_services; + current_extension_authentication_enabled = + result.extension_authentication_enabled; current_settings = result.settings; enqueue_policy_status( &status_sender, @@ -2960,6 +3819,8 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { current_config_revision = result.config_revision; current_policy_hash = result.policy_hash.clone(); current_middleware_services = result.supervisor_middleware_services; + current_extension_authentication_enabled = + result.extension_authentication_enabled; current_settings = result.settings; debug!( config_revision = current_config_revision, @@ -2978,7 +3839,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { let result = if let Some(result) = pending_result.take() { result } else { - tokio::time::sleep(interval).await; + tokio::time::sleep(next_poll_delay(&ctx.extension_credentials, interval)).await; match client.poll_settings(&ctx.sandbox_id).await { Ok(result) => { let _ = ctx.workspace_tx.send(client.workspace()); @@ -2986,19 +3847,50 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } Err(e) => { debug!(error = %e, "Settings poll: server unreachable, will retry"); + if current_extension_authentication_enabled + && let Err(refresh_error) = + client.refresh_installed_extension_credentials().await + { + warn!( + error = %refresh_error, + "Settings poll: extension credential refresh failed while configuration was unavailable" + ); + } continue; } } }; + // Reuse installed per-service credentials, rotating only when one is + // missing or due. Rotation happens on the existing gateway channel and + // updates slots in place, so it is independent of config revision and + // registry equality. + let middleware_credentials = if result.extension_authentication_enabled { + match client + .extension_credentials_for(&result.supervisor_middleware_services) + .await + { + Ok(credentials) => credentials, + Err(error) => { + warn!(error = %error, "Settings poll: extension credential refresh failed"); + std::collections::HashMap::new() + } + } + } else { + std::collections::HashMap::new() + }; + let config_changed = result.config_revision != current_config_revision; let provider_env_changed = result.provider_env_revision != current_provider_env_revision; let policy_changed = result.policy_hash != current_policy_hash; - let middleware_registry_changed = middleware_registry_needs_rebuild( - middleware_registry_status, - ¤t_middleware_services, - &result.supervisor_middleware_services, - ); + let extension_authentication_changed = + current_extension_authentication_enabled != result.extension_authentication_enabled; + let middleware_registry_changed = extension_authentication_changed + || middleware_registry_needs_rebuild( + middleware_registry_status, + ¤t_middleware_services, + &result.supervisor_middleware_services, + ); // A valid candidate may intentionally restore byte-for-byte policy // content that was active before a rejected update. Its hash then // equals `current_policy_hash`, but the runtime is still quarantined @@ -3008,6 +3900,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { .as_ref() .is_some_and(|rejected| rejected.policy_hash != result.policy_hash); let policy_runtime_changed = recovering_rejected_policy + || extension_authentication_changed || gateway_policy_runtime_needs_reconciliation( reloads_gateway_policy, ¤t_policy_hash, @@ -3016,6 +3909,17 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { &result.supervisor_middleware_services, middleware_registry_status, ); + // Recovery already has its own acknowledgement path below. Giving it + // precedence here prevents a restored last-known-good policy from + // also being acknowledged as an ordinary same-hash revision. + let unchanged_policy_revision = unchanged_policy_revision_candidate( + reloads_gateway_policy, + recovering_rejected_policy, + current_policy_version, + ¤t_policy_hash, + &result, + ); + let mut policy_runtime_reconciled = false; // A local policy override is not coupled to the gateway policy // snapshot, so its service registry can still be reconciled alone. @@ -3024,14 +3928,30 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { if !reloads_gateway_policy { reconcile_middleware_registry( &ctx.opa_engine, - &result.supervisor_middleware_services, - &mut current_middleware_services, - &mut middleware_registry_status, + &ctx.middleware_connector, + MiddlewareRegistryReconciliation { + desired_services: &result.supervisor_middleware_services, + authentication: MiddlewareAuthentication { + credentials: middleware_credentials.clone(), + enabled: result.extension_authentication_enabled, + }, + registry_changed: middleware_registry_changed, + extension_credentials: &ctx.extension_credentials, + current_services: &mut current_middleware_services, + status: &mut middleware_registry_status, + }, ) .await; + if middleware_registry_status == MiddlewareRegistryStatus::Synchronized { + current_extension_authentication_enabled = result.extension_authentication_enabled; + } } - if !config_changed && !provider_env_changed && !policy_runtime_changed { + if !config_changed + && !provider_env_changed + && !policy_runtime_changed + && unchanged_policy_revision.is_none() + { continue; } @@ -3086,42 +4006,67 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { .await { Ok(env_result) => { - ctx.provider_credentials.install_environment( - env_result.provider_env_revision, + let provider_env_revision = env_result.provider_env_revision; + let install_result = ctx.provider_credentials.install_bound_environment( + provider_env_revision, env_result.environment, env_result.credential_expires_at_ms, env_result.dynamic_credentials, + env_result.static_credential_bindings, + env_result.non_secret_environment_keys, ); - let child_env = ctx.provider_credentials.child_env_with_gcp_resolved(); - let env_count = child_env.len(); - if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { - publisher.publish_provider_env( - env_result.provider_env_revision, - child_env.clone(), + if let Err(error) = install_result { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message(format!( + "Rejected provider environment refresh; static provider credentials were revoked; fetched dynamic token grants remain active: {error}" + )) + .build() + ); + } else { + let child_env = ctx.provider_credentials.child_env_with_gcp_resolved(); + let env_count = child_env.len(); + if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { + publisher + .publish_provider_env(provider_env_revision, child_env.clone()); + } + current_provider_env_revision = provider_env_revision; + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped( + "provider_env_revision", + serde_json::json!(provider_env_revision) + ) + .message(format!( + "Provider environment refreshed [revision:{provider_env_revision} env_count:{env_count}]" + )) + .build() ); } - current_provider_env_revision = env_result.provider_env_revision; - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .unmapped( - "provider_env_revision", - serde_json::json!(env_result.provider_env_revision) - ) - .message(format!( - "Provider environment refreshed [revision:{} env_count:{env_count}]", - env_result.provider_env_revision - )) - .build() - ); } Err(e) => { + ctx.provider_credentials + .revoke_static_provider_environment(result.provider_env_revision); warn!( error = %e, provider_env_revision = result.provider_env_revision, - "Settings poll: failed to refresh provider environment" + "Settings poll: failed to refresh provider environment; static provider credentials were revoked; previous dynamic token grants remain active" + ); + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message( + "Provider environment refresh failed; static provider credentials were revoked; previous dynamic token grants remain active" + ) + .build() ); } } @@ -3133,13 +4078,22 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { &ctx.opa_engine, result.policy.as_ref(), pid, - &result.supervisor_middleware_services, - middleware_registry_changed, + MiddlewareReloadContext { + desired_services: &result.supervisor_middleware_services, + authentication: &MiddlewareAuthentication { + credentials: middleware_credentials.clone(), + enabled: result.extension_authentication_enabled, + }, + registry_changed: middleware_registry_changed, + connector: &ctx.middleware_connector, + }, + ctx.transparent_tcp, ) .await; match runtime_result { Ok(()) => { + policy_runtime_reconciled = true; let policy = result .policy .as_ref() @@ -3189,6 +4143,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { &status_sender, PolicyStatusUpdate::loaded(result.version), ); + current_policy_version = result.version; } } else if recovering_rejected_policy && result.version > 0 @@ -3210,6 +4165,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { &status_sender, PolicyStatusUpdate::loaded(result.version), ); + current_policy_version = result.version; } if middleware_registry_changed { @@ -3230,6 +4186,13 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { current_policy_hash.clone_from(&result.policy_hash); current_middleware_services.clone_from(&result.supervisor_middleware_services); + current_extension_authentication_enabled = + result.extension_authentication_enabled; + retain_extension_credentials( + &ctx.extension_credentials, + &result.supervisor_middleware_services, + result.extension_authentication_enabled, + ); middleware_registry_status = MiddlewareRegistryStatus::Synchronized; last_failed_runtime_revision = None; } @@ -3288,6 +4251,26 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { )) .build()); } + GatewayRuntimeFailureDisposition::TransparentTcpExpansionRejected { + error, + active_generation, + } => { + emit_transparent_tcp_expansion_rejection( + result.version, + &result.policy_hash, + active_generation, + &error, + ); + if policy_changed + && result.version > 0 + && result.policy_source == PolicySource::Sandbox + { + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::failed(result.version, error), + ); + } + } } } last_failed_runtime_revision = Some(failed_revision); @@ -3299,6 +4282,18 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } } + if let Some(version) = unchanged_policy_revision_ready_to_ack( + unchanged_policy_revision, + policy_runtime_changed, + policy_runtime_reconciled, + ) { + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::unchanged_loaded(version, result.policy_hash.clone()), + ); + current_policy_version = version; + } + // Apply OCSF JSON toggle from the `ocsf_json_enabled` setting. apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); @@ -3479,6 +4474,21 @@ fn format_setting_value(es: &openshell_core::proto::EffectiveSetting) -> String )] mod tests { use super::*; + + #[test] + fn transparent_tcp_capability_requires_exact_driver_marker() { + let required = openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY; + assert!(!has_network_runtime_capability(None, required)); + assert!(!has_network_runtime_capability(Some(""), required)); + assert!(!has_network_runtime_capability( + Some("policy-dns-transparent-tcp-extra"), + required + )); + assert!(has_network_runtime_capability( + Some("other, policy-dns-transparent-tcp"), + required + )); + } use openshell_core::policy::{ FilesystemPolicy, LandlockPolicy, NetworkMode, NetworkPolicy, ProcessPolicy, ProxyPolicy, }; @@ -3548,20 +4558,24 @@ mod tests { } #[tokio::test] - async fn sidecar_control_provider_env_update_installs_newer_revision() { + async fn sidecar_control_provider_env_update_orders_by_generation() { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let provider_credentials = ProviderCredentialState::from_child_env_snapshot( - 1, + u64::MAX, std::collections::HashMap::from([("TOKEN".to_string(), "old".to_string())]), ); + let agent_proposals = AgentProposals::new(true); let handle = spawn_sidecar_control_update_watcher( rx, provider_credentials.clone(), - AgentProposals::default(), + agent_proposals.clone(), + Arc::new(tokio::sync::Mutex::new(None)), + 10, ); tx.send(sidecar_control::ControlUpdate::ProviderEnv { - revision: 2, + revision: 1, + generation: 11, provider_child_env: std::collections::HashMap::from([( "TOKEN".to_string(), "new".to_string(), @@ -3571,7 +4585,7 @@ mod tests { timeout(Duration::from_secs(1), async { loop { - if provider_credentials.snapshot().revision == 2 { + if provider_credentials.snapshot().revision == 1 { break; } tokio::time::sleep(Duration::from_millis(10)).await; @@ -3580,21 +4594,33 @@ mod tests { .await .unwrap(); let snapshot = provider_credentials.snapshot(); - assert_eq!(snapshot.revision, 2); + assert_eq!(snapshot.revision, 1); assert_eq!( snapshot.child_env.get("TOKEN").map(String::as_str), Some("new") ); tx.send(sidecar_control::ControlUpdate::ProviderEnv { - revision: 1, + revision: 2, + generation: 11, provider_child_env: std::collections::HashMap::from([( "TOKEN".to_string(), - "stale".to_string(), + "duplicate-generation".to_string(), )]), }) .unwrap(); - tokio::time::sleep(Duration::from_millis(20)).await; + tx.send(sidecar_control::ControlUpdate::AgentProposals { + enabled: false, + config_revision: 1, + }) + .unwrap(); + timeout(Duration::from_secs(1), async { + while agent_proposals.enabled() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); assert_eq!( provider_credentials .snapshot() @@ -3603,6 +4629,54 @@ mod tests { .map(String::as_str), Some("new") ); + + tx.send(sidecar_control::ControlUpdate::ProviderEnv { + revision: 2, + generation: 12, + provider_child_env: std::collections::HashMap::from([( + "TOKEN".to_string(), + "newest".to_string(), + )]), + }) + .unwrap(); + timeout(Duration::from_secs(1), async { + loop { + if provider_credentials.snapshot().revision == 2 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + + tx.send(sidecar_control::ControlUpdate::ProviderEnv { + revision: u64::MAX, + generation: 11, + provider_child_env: std::collections::HashMap::from([( + "TOKEN".to_string(), + "stale".to_string(), + )]), + }) + .unwrap(); + tx.send(sidecar_control::ControlUpdate::AgentProposals { + enabled: true, + config_revision: 2, + }) + .unwrap(); + timeout(Duration::from_secs(1), async { + while !agent_proposals.enabled() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let snapshot = provider_credentials.snapshot(); + assert_eq!(snapshot.revision, 2); + assert_eq!( + snapshot.child_env.get("TOKEN").map(String::as_str), + Some("newest") + ); handle.abort(); } @@ -3612,8 +4686,13 @@ mod tests { let provider_credentials = ProviderCredentialState::from_child_env_snapshot(0, std::collections::HashMap::new()); let agent_proposals = AgentProposals::new(true); - let handle = - spawn_sidecar_control_update_watcher(rx, provider_credentials, agent_proposals.clone()); + let handle = spawn_sidecar_control_update_watcher( + rx, + provider_credentials, + agent_proposals.clone(), + Arc::new(tokio::sync::Mutex::new(None)), + 0, + ); tx.send(sidecar_control::ControlUpdate::AgentProposals { enabled: false, @@ -3810,6 +4889,24 @@ filesystem_policy: openshell_policy::restrictive_default_policy() } + fn proto_tcp_policy_fixture() -> openshell_core::proto::SandboxPolicy { + openshell_policy::parse_sandbox_policy( + r#" +version: 1 +network_policies: + redis: + name: redis + endpoints: + - host: redis.example.com + port: 6379 + protocol: tcp + binaries: + - path: /usr/bin/redis-cli +"#, + ) + .expect("parse TCP policy") + } + fn settings_poll_result( policy: Option, version: u32, @@ -3827,7 +4924,600 @@ filesystem_policy: supervisor_middleware_services: Vec::new(), workspace: String::new(), policy_validation_failure_mode: PolicyValidationFailureMode::default(), + extension_authentication_enabled: false, + } + } + + #[derive(Clone)] + struct ScriptedPolicyGateway { + polls: Arc< + tokio::sync::Mutex< + tokio::sync::mpsc::UnboundedReceiver< + openshell_core::grpc_client::SettingsPollResult, + >, + >, + >, + reports: UnboundedSender<(u32, bool, String)>, + } + + #[tonic::async_trait] + impl PolicyGatewayClient for ScriptedPolicyGateway { + async fn poll_settings( + &self, + _sandbox_id: &str, + ) -> Result { + self.polls + .lock() + .await + .recv() + .await + .ok_or_else(|| miette::miette!("scripted policy poll channel closed")) + } + + async fn report_policy_status( + &self, + _sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()> { + self.reports + .send((version, loaded, error.to_string())) + .map_err(|_| miette::miette!("scripted policy report channel closed")) + } + + fn workspace(&self) -> String { + "test-workspace".to_string() + } + } + + #[derive(Clone)] + struct CredentialRejectingPolicyGateway { + inner: ScriptedPolicyGateway, + credential_requests: Arc, + } + + #[tonic::async_trait] + impl PolicyGatewayClient for CredentialRejectingPolicyGateway { + async fn poll_settings( + &self, + sandbox_id: &str, + ) -> Result { + self.inner.poll_settings(sandbox_id).await + } + + async fn report_policy_status( + &self, + sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()> { + self.inner + .report_policy_status(sandbox_id, version, loaded, error) + .await + } + + async fn extension_credentials_for( + &self, + _services: &[openshell_core::proto::SupervisorMiddlewareService], + ) -> Result> + { + self.credential_requests.fetch_add(1, Ordering::SeqCst); + Err(miette::miette!( + "gateway extension authentication is unavailable" + )) + } + + fn workspace(&self) -> String { + self.inner.workspace() + } + } + + fn scripted_policy_gateway() -> ( + ScriptedPolicyGateway, + UnboundedSender, + tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, + ) { + let (poll_tx, poll_rx) = tokio::sync::mpsc::unbounded_channel(); + let (report_tx, report_rx) = tokio::sync::mpsc::unbounded_channel(); + ( + ScriptedPolicyGateway { + polls: Arc::new(tokio::sync::Mutex::new(poll_rx)), + reports: report_tx, + }, + poll_tx, + report_rx, + ) + } + + fn policy_poll_test_context( + opa_engine: Arc, + loaded_policy_origin: LoadedPolicyOrigin, + middleware_connector: MiddlewareConnector, + ) -> PolicyPollLoopContext { + let (workspace_tx, _workspace_rx) = tokio::sync::watch::channel(String::new()); + PolicyPollLoopContext { + endpoint: String::new(), + sandbox_id: "sandbox-test".to_string(), + opa_engine, + loaded_policy_origin, + entrypoint_pid: Arc::new(AtomicU32::new(0)), + interval_secs: 0, + ocsf_enabled: Arc::new(AtomicBool::new(false)), + provider_credentials: ProviderCredentialState::from_child_env_snapshot( + 0, + std::collections::HashMap::new(), + ), + policy_local_ctx: None, + agent_proposals: AgentProposals::default(), + middleware_registry_status: MiddlewareRegistryStatus::Synchronized, + sidecar_control_publisher: None, + workspace_tx, + extension_credentials: openshell_extension_core::ExtensionCredentialStore::new(), + extension_authentication_enabled: false, + middleware_connector, + transparent_tcp: TransparentTcpReloadState::default(), + } + } + + async fn expect_policy_report( + reports: &mut tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, + version: u32, + ) { + let report = timeout(Duration::from_secs(1), reports.recv()) + .await + .expect("policy report timed out") + .expect("policy reporter stopped"); + assert_eq!(report, (version, true, String::new())); + } + + async fn expect_no_policy_report( + reports: &mut tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, + ) { + assert!( + timeout(Duration::from_millis(50), reports.recv()) + .await + .is_err(), + "unexpected policy status report" + ); + } + + #[tokio::test] + async fn same_hash_poll_revision_is_acknowledged_once_without_opa_reload() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(v1).unwrap(); + + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + + polls.send(v2.clone()).unwrap(); + expect_policy_report(&mut reports, 2).await; + polls.send(v2).unwrap(); + expect_no_policy_report(&mut reports).await; + + assert_eq!( + engine.current_generation(), + 0, + "same-hash acknowledgement must not reload OPA" + ); + handle.abort(); + } + + #[tokio::test] + async fn poll_rejects_first_tcp_expansion_and_reports_previous_policy_active() { + let v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let v2 = settings_poll_result( + Some(proto_tcp_policy_fixture()), + 2, + openshell_core::proto::PolicySource::Sandbox, + ); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let active_generation = engine.current_generation(); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let mut ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + ctx.transparent_tcp = TransparentTcpReloadState { + capable: true, + substrate_ready: false, + }; + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(v1).unwrap(); + + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + polls.send(v2).unwrap(); + let report = timeout(Duration::from_secs(1), reports.recv()) + .await + .expect("TCP rejection report timed out") + .expect("policy reporter stopped"); + + assert_eq!(report.0, 2); + assert!(!report.1); + assert!(report.2.contains("recreate the sandbox"), "{}", report.2); + assert!(report.2.contains("previous policy remains active")); + assert_eq!(engine.current_generation(), active_generation); + assert!(engine.fail_closed_reason().is_none()); + handle.abort(); + } + + #[tokio::test] + async fn same_hash_ack_waits_for_failed_middleware_reconciliation_and_retries_once() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + v2.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "scripted-guard".to_string(), + grpc_endpoint: "http://scripted.invalid".to_string(), + ..Default::default() + }]; + + let connector_attempts = Arc::new(AtomicUsize::new(0)); + let (attempt_tx, mut attempt_rx) = tokio::sync::mpsc::unbounded_channel(); + let middleware_connector: MiddlewareConnector = { + let connector_attempts = connector_attempts.clone(); + Arc::new(move |_services, _authentication| { + let attempt = connector_attempts.fetch_add(1, Ordering::SeqCst) + 1; + attempt_tx.send(attempt).unwrap(); + Box::pin(async move { + if attempt == 1 { + Err(miette::miette!("scripted middleware connection failure")) + } else { + connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await + } + }) + }) + }; + + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + middleware_connector, + ); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(v1).unwrap(); + + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + + polls.send(v2.clone()).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), attempt_rx.recv()) + .await + .unwrap(), + Some(1) + ); + expect_no_policy_report(&mut reports).await; + assert_eq!(engine.current_generation(), 0); + + polls.send(v2.clone()).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), attempt_rx.recv()) + .await + .unwrap(), + Some(2) + ); + expect_policy_report(&mut reports, 2).await; + assert_eq!(engine.current_generation(), 1); + + polls.send(v2).unwrap(); + expect_no_policy_report(&mut reports).await; + assert_eq!(connector_attempts.load(Ordering::SeqCst), 2); + handle.abort(); + } + + #[tokio::test] + async fn no_signer_capability_uses_legacy_middleware_connector_without_credentials() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + v2.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "legacy-guard".to_string(), + grpc_endpoint: "http://legacy.invalid".to_string(), + ..Default::default() + }]; + assert!(!v2.extension_authentication_enabled); + + let (inner, polls, mut reports) = scripted_policy_gateway(); + let credential_requests = Arc::new(AtomicUsize::new(0)); + let client = CredentialRejectingPolicyGateway { + inner, + credential_requests: credential_requests.clone(), + }; + let (connector_tx, mut connector_rx) = tokio::sync::mpsc::unbounded_channel(); + let connector: MiddlewareConnector = Arc::new(move |_services, authentication| { + connector_tx + .send((authentication.credentials.len(), authentication.enabled)) + .unwrap(); + Box::pin(async move { + connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await + }) + }); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + connector, + ); + + polls.send(v1).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + polls.send(v2).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), connector_rx.recv()) + .await + .unwrap(), + Some((0, false)) + ); + expect_policy_report(&mut reports, 2).await; + assert_eq!(credential_requests.load(Ordering::SeqCst), 0); + handle.abort(); + } + + #[tokio::test] + async fn enabled_extension_authentication_keeps_credential_failure_fail_closed() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + v2.extension_authentication_enabled = true; + v2.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "authenticated-guard".to_string(), + grpc_endpoint: "https://guard.invalid".to_string(), + ..Default::default() + }]; + + let (inner, polls, mut reports) = scripted_policy_gateway(); + let credential_requests = Arc::new(AtomicUsize::new(0)); + let client = CredentialRejectingPolicyGateway { + inner, + credential_requests: credential_requests.clone(), + }; + let (connector_tx, mut connector_rx) = tokio::sync::mpsc::unbounded_channel(); + let connector: MiddlewareConnector = Arc::new(move |_services, authentication| { + connector_tx + .send((authentication.credentials.len(), authentication.enabled)) + .unwrap(); + Box::pin(async move { + if authentication.enabled && authentication.credentials.is_empty() { + Err(miette::miette!( + "missing authenticated middleware credential" + )) + } else { + connect_middleware_registry(&[], &authentication).await + } + }) + }); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + connector, + ); + + polls.send(v1).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + polls.send(v2).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), connector_rx.recv()) + .await + .unwrap(), + Some((0, true)) + ); + expect_no_policy_report(&mut reports).await; + assert_eq!(credential_requests.load(Ordering::SeqCst), 1); + handle.abort(); + } + + async fn assert_poll_does_not_use_same_hash_acknowledgement( + initial: openshell_core::grpc_client::SettingsPollResult, + next: openshell_core::grpc_client::SettingsPollResult, + origin: LoadedPolicyOrigin, + initial_report: Option, + ) { + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context(engine.clone(), origin, default_middleware_connector()); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(initial).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + + if let Some(version) = initial_report { + expect_policy_report(&mut reports, version).await; + } else { + expect_no_policy_report(&mut reports).await; } + + polls.send(next).unwrap(); + expect_no_policy_report(&mut reports).await; + assert_eq!( + engine.current_generation(), + 0, + "negative same-hash scope must not reload OPA" + ); + handle.abort(); + } + + #[tokio::test] + async fn same_hash_ack_poll_loop_rejects_local_global_empty_equal_and_older_scopes() { + let mut sandbox_v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + sandbox_v1.policy_hash = "same-policy".to_string(); + let loaded_v1 = LoadedPolicyRevision::from_snapshot(&sandbox_v1); + let mut sandbox_v2 = sandbox_v1.clone(); + sandbox_v2.version = 2; + sandbox_v2.config_revision = 200; + + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v1.clone(), + sandbox_v2.clone(), + LoadedPolicyOrigin::LocalOverride, + None, + ) + .await; + + let mut global_v2 = sandbox_v2.clone(); + global_v2.policy_source = openshell_core::proto::PolicySource::Global; + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v1.clone(), + global_v2, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_v1.clone()), + has_last_valid_policy: true, + }, + Some(1), + ) + .await; + + let mut empty_v1 = sandbox_v1.clone(); + empty_v1.policy_hash.clear(); + let empty_loaded = LoadedPolicyRevision::from_snapshot(&empty_v1); + let mut empty_v2 = sandbox_v2.clone(); + empty_v2.policy_hash.clear(); + assert_poll_does_not_use_same_hash_acknowledgement( + empty_v1, + empty_v2, + LoadedPolicyOrigin::Gateway { + revision: Some(empty_loaded), + has_last_valid_policy: true, + }, + Some(1), + ) + .await; + + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v1.clone(), + sandbox_v1.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_v1.clone()), + has_last_valid_policy: true, + }, + Some(1), + ) + .await; + + let loaded_v2 = LoadedPolicyRevision::from_snapshot(&sandbox_v2); + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v2, + sandbox_v1, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_v2), + has_last_valid_policy: true, + }, + Some(2), + ) + .await; + } + + #[tokio::test] + async fn changed_hash_poll_uses_normal_opa_reload_and_status_path() { + let v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let v2 = settings_poll_result( + Some(proto_policy_fixture()), + 2, + openshell_core::proto::PolicySource::Sandbox, + ); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(v1).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + + expect_policy_report(&mut reports, 1).await; + polls.send(v2).unwrap(); + expect_policy_report(&mut reports, 2).await; + assert_eq!( + engine.current_generation(), + 1, + "changed policy content must still reload OPA" + ); + handle.abort(); } #[tokio::test] @@ -3842,10 +5532,10 @@ filesystem_policy: let invalid_external = openshell_core::proto::SupervisorMiddlewareService { name: "unavailable-guard".into(), grpc_endpoint: "http://127.0.0.1:1".into(), - max_body_bytes: 1024, + max_payload_bytes: 1024, ..Default::default() }; - connect_middleware_registry(&[invalid_external]) + connect_middleware_registry(&[invalid_external], &MiddlewareAuthentication::default()) .await .expect_err("unavailable external service must not replace built-ins"); @@ -3862,7 +5552,7 @@ filesystem_policy: let unavailable_service = openshell_core::proto::SupervisorMiddlewareService { name: "unavailable-guard".into(), grpc_endpoint: "http://127.0.0.1:1".into(), - max_body_bytes: 1024, + max_payload_bytes: 1024, ..Default::default() }; @@ -3870,8 +5560,13 @@ filesystem_policy: &engine, Some(&proto_policy_fixture()), 0, - &[unavailable_service], - true, + MiddlewareReloadContext { + desired_services: &[unavailable_service], + authentication: &MiddlewareAuthentication::default(), + registry_changed: true, + connector: &default_middleware_connector(), + }, + TransparentTcpReloadState::default(), ) .await .expect_err("unavailable middleware must fail candidate preparation"); @@ -3892,6 +5587,74 @@ filesystem_policy: assert!(engine.fail_closed_reason().is_none()); } + #[tokio::test] + async fn tcp_policy_reload_without_startup_substrate_is_rejected_and_keeps_previous_policy() { + let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); + let active_generation = engine.current_generation(); + + let failure = reload_gateway_policy_runtime( + &engine, + Some(&proto_tcp_policy_fixture()), + 0, + MiddlewareReloadContext { + desired_services: &[], + authentication: &MiddlewareAuthentication::default(), + registry_changed: false, + connector: &default_middleware_connector(), + }, + TransparentTcpReloadState { + capable: true, + substrate_ready: false, + }, + ) + .await + .expect_err("TCP expansion must require startup substrate"); + let disposition = apply_gateway_runtime_reload_failure( + &engine, + failure, + PolicyValidationFailureMode::FailClosed, + true, + 2, + ) + .expect("runtime prerequisite failure handling must succeed"); + + assert!(matches!( + disposition, + GatewayRuntimeFailureDisposition::TransparentTcpExpansionRejected { + active_generation: generation, + .. + } if generation == active_generation + )); + assert_eq!(engine.current_generation(), active_generation); + assert!(engine.fail_closed_reason().is_none()); + } + + #[tokio::test] + async fn tcp_policy_reload_on_unsupported_runtime_is_rejected() { + let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); + + let failure = reload_gateway_policy_runtime( + &engine, + Some(&proto_tcp_policy_fixture()), + 0, + MiddlewareReloadContext { + desired_services: &[], + authentication: &MiddlewareAuthentication::default(), + registry_changed: false, + connector: &default_middleware_connector(), + }, + TransparentTcpReloadState::default(), + ) + .await + .expect_err("unsupported runtime must reject TCP expansion"); + + assert!(matches!( + failure, + GatewayRuntimeReloadError::TransparentTcpPrerequisite(_) + )); + assert_eq!(engine.current_generation(), 0); + } + #[test] fn policy_rejection_after_middleware_outage_is_not_deduplicated() { let engine = OpaEngine::from_strings( @@ -4185,6 +5948,120 @@ filesystem_policy: assert!(origin.allows_gateway_policy_reload()); } + #[test] + fn unchanged_sandbox_policy_revision_candidate_is_strictly_scoped() { + let sandbox_result = openshell_core::grpc_client::SettingsPollResult { + policy_hash: "same-policy".to_string(), + ..settings_poll_result( + Some(proto_policy_fixture()), + 2, + openshell_core::proto::PolicySource::Sandbox, + ) + }; + + assert_eq!( + unchanged_policy_revision_candidate(true, false, 1, "same-policy", &sandbox_result), + Some(2) + ); + assert_eq!( + unchanged_policy_revision_candidate(true, false, 2, "same-policy", &sandbox_result), + None + ); + assert_eq!( + unchanged_policy_revision_candidate( + true, + false, + 1, + "different-policy", + &sandbox_result, + ), + None + ); + assert_eq!( + unchanged_policy_revision_candidate(false, false, 1, "same-policy", &sandbox_result), + None + ); + assert_eq!( + unchanged_policy_revision_candidate(true, false, 1, "", &sandbox_result), + None + ); + assert_eq!( + unchanged_policy_revision_candidate(true, true, 1, "same-policy", &sandbox_result), + None + ); + + let global_result = openshell_core::grpc_client::SettingsPollResult { + policy_hash: "same-policy".to_string(), + ..settings_poll_result( + Some(proto_policy_fixture()), + 2, + openshell_core::proto::PolicySource::Global, + ) + }; + assert_eq!( + unchanged_policy_revision_candidate(true, false, 1, "same-policy", &global_result), + None + ); + } + + #[test] + fn unchanged_policy_revision_waits_for_required_runtime_reconciliation() { + assert_eq!( + unchanged_policy_revision_ready_to_ack(Some(2), false, false), + Some(2), + "a same-hash revision needs no OPA reload" + ); + assert_eq!( + unchanged_policy_revision_ready_to_ack(Some(2), true, false), + None, + "failed runtime reconciliation must keep the revision pending" + ); + assert_eq!( + unchanged_policy_revision_ready_to_ack(Some(2), true, true), + Some(2), + "successful runtime reconciliation permits acknowledgement" + ); + assert_eq!( + unchanged_policy_revision_ready_to_ack(None, false, true), + None, + "runtime success cannot manufacture a revision candidate" + ); + } + + #[test] + fn credential_gating_unavailable_for_local_override_with_credentials() { + assert!(credential_gating_unavailable( + &LoadedPolicyOrigin::LocalOverride, + true, + true + )); + } + + #[test] + fn credential_gating_available_without_local_override_or_credentials() { + // A gateway policy is stamped with provenance, so the gates apply. + assert!(!credential_gating_unavailable( + &LoadedPolicyOrigin::Gateway { + revision: None, + has_last_valid_policy: true, + }, + true, + true + )); + // No provider credentials means there is nothing to leak. + assert!(!credential_gating_unavailable( + &LoadedPolicyOrigin::LocalOverride, + false, + true + )); + // Without networking the proxy never evaluates endpoint provenance. + assert!(!credential_gating_unavailable( + &LoadedPolicyOrigin::LocalOverride, + true, + false + )); + } + #[test] fn policy_status_outbox_preserves_all_revision_order() { let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); @@ -4346,12 +6223,22 @@ filesystem_policy: "fail_closed" ); assert_eq!(config["unmapped"]["previous_policy_active"], false); + assert_eq!( + config["unmapped"]["validation_error"], + "conflicting tls metadata" + ); assert!( config["message"] .as_str() .unwrap() .contains("previous policy IS NOT active") ); + assert!( + config["message"] + .as_str() + .unwrap() + .contains("error:conflicting tls metadata") + ); let finding = finding.to_json().unwrap(); assert_eq!(finding["class_uid"], 2004); diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 62ae37b5a1..e7a0948718 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -32,13 +32,14 @@ const COPY_SELF_SUBCOMMAND: &str = "copy-self"; /// run `openshell-sandbox debug-rpc get-sandbox-config --sandbox-id ` /// to confirm the cross-sandbox IDOR guard fires. const DEBUG_RPC_SUBCOMMAND: &str = "debug-rpc"; +const VALIDATE_WORKSPACE_SUBCOMMAND: &str = "validate-workspace"; /// Default `--mode` value: run both supervisor leaves in a single binary. const DEFAULT_MODE: &str = "network,process"; -const SIDECAR_STATE_DIR: &str = "/run/openshell-sidecar"; -const SIDECAR_TLS_DIR: &str = "/etc/openshell-tls/proxy"; +const SIDECAR_STATE_DIR: &str = openshell_core::container_paths::SIDECAR_RUN_ROOT; +const SIDECAR_TLS_DIR: &str = openshell_core::container_paths::SIDECAR_TLS_DIR; #[cfg(target_os = "linux")] -const CLIENT_TLS_DIR: &str = "/etc/openshell-tls/client"; +const CLIENT_TLS_DIR: &str = openshell_core::container_paths::CLIENT_TLS_DIR; #[cfg(target_os = "linux")] const SIDECAR_CLIENT_TLS_SUBDIR: &str = "client"; #[cfg(target_os = "linux")] @@ -110,8 +111,9 @@ impl std::str::FromStr for Mode { #[command(about = "Process sandbox and monitor", long_about = None)] struct Args { /// Command to execute in the sandbox. - /// Can also be provided via `OPENSHELL_SANDBOX_COMMAND` environment variable. - /// Defaults to `/bin/bash` if neither is provided. + /// Defaults to a login shell if neither this nor the driver specification is + /// provided: `/bin/bash -l` when available, otherwise a shell detected in the + /// sandbox image (e.g. `/bin/sh` on Alpine). #[arg(trailing_var_arg = true)] command: Vec, @@ -229,6 +231,56 @@ struct Args { /// (for proxies whose ACLs filter on hostnames). #[arg(long)] upstream_proxy_connect_by_hostname: bool, + + /// Path to a PEM CA bundle trusted for the corporate proxy: the TLS + /// handshake with an `https://` proxy and, for TLS-intercepting proxies, + /// re-signed upstream certificates and the sandbox trust bundle. + #[arg(long)] + upstream_proxy_ca_bundle: Option, +} + +/// Internal one-shot command used by the privileged supervisor to validate an +/// image-provided workdir as the final sandbox identity. +#[derive(Parser, Debug)] +#[command(name = "validate-workspace", hide = true)] +struct ValidateWorkspaceArgs { + #[arg(long)] + workdir: String, + #[arg(long)] + expected_uid: u32, + #[arg(long)] + expected_gid: u32, +} + +#[cfg(target_os = "linux")] +fn validate_workspace(args: &[String]) -> Result<()> { + let args = ValidateWorkspaceArgs::try_parse_from( + std::iter::once(VALIDATE_WORKSPACE_SUBCOMMAND.to_string()).chain(args.iter().cloned()), + ) + .into_diagnostic()?; + let actual = ( + nix::unistd::geteuid().as_raw(), + nix::unistd::getegid().as_raw(), + ); + if actual != (args.expected_uid, args.expected_gid) { + return Err(miette::miette!( + "workspace validator privilege drop failed: expected {}:{}, got {}:{}", + args.expected_uid, + args.expected_gid, + actual.0, + actual.1 + )); + } + openshell_supervisor_process::process::validate_oci_workspace_as_effective_identity(Path::new( + &args.workdir, + )) +} + +#[cfg(not(target_os = "linux"))] +fn validate_workspace(_args: &[String]) -> Result<()> { + Err(miette::miette!( + "workspace validation is only supported on Unix" + )) } /// Copy the running executable to `dest`, creating parent directories as @@ -427,16 +479,23 @@ fn run_network_init( #[cfg(target_os = "linux")] fn validate_network_init_ids(proxy_user_id: u32, proxy_primary_group_id: u32) -> Result<()> { - if proxy_user_id != 0 && proxy_user_id < openshell_policy::MIN_SANDBOX_UID { + if proxy_user_id != 0 + && !(openshell_policy::MIN_SANDBOX_PROXY_UID..=openshell_policy::MAX_SANDBOX_UID) + .contains(&proxy_user_id) + { return Err(miette::miette!( - "--proxy-uid must be 0 or at least {}", - openshell_policy::MIN_SANDBOX_UID + "--proxy-uid must be 0 or in range [{}, {}]", + openshell_policy::MIN_SANDBOX_PROXY_UID, + openshell_policy::MAX_SANDBOX_UID, )); } - if proxy_primary_group_id < openshell_policy::MIN_SANDBOX_UID { + if !(openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) + .contains(&proxy_primary_group_id) + { return Err(miette::miette!( - "--proxy-gid must be at least {}", - openshell_policy::MIN_SANDBOX_UID + "--proxy-gid must be in range [{}, {}]", + openshell_policy::MIN_SANDBOX_UID, + openshell_policy::MAX_SANDBOX_UID, )); } Ok(()) @@ -479,6 +538,9 @@ fn main() -> Result<()> { std::process::exit(exit); }); } + if raw_args.get(1).map(String::as_str) == Some(VALIDATE_WORKSPACE_SUBCOMMAND) { + return validate_workspace(&raw_args[2..]); + } let args = Args::parse(); @@ -595,16 +657,35 @@ fn main() -> Result<()> { (None, None) }; - // Get command - either from CLI args, environment variable, or default to /bin/bash - let command = if !args.command.is_empty() { - args.command - } else if let Ok(c) = std::env::var(openshell_core::sandbox_env::SANDBOX_COMMAND) { - // Simple shell-like splitting on whitespace - c.split_whitespace().map(String::from).collect() + // Resolve an exact canonical process. Explicit offline/test argv wins; + // drivers otherwise provide a versioned JSON transport so argument + // boundaries are never reconstructed with shell parsing. + let workdir = args.workdir.clone(); + let (command, interactive, await_main_process_attachment) = if !args.command.is_empty() { + (args.command, args.interactive, false) + } else if let Ok(json) = std::env::var(openshell_core::sandbox_env::MAIN_PROCESS_SPEC) { + let config = openshell_core::sandbox_env::MainProcessConfig::decode(&json) + .map_err(|error| miette::miette!("{error}"))?; + ( + config.command, + config.tty, + config.await_main_process_attachment, + ) } else { - vec!["/bin/bash".to_string()] + let config = openshell_core::sandbox_env::MainProcessConfig::scratch(); + ( + config.command, + config.tty, + config.await_main_process_attachment, + ) }; + // An omitted command (the gateway leaves the default empty rather than + // baking a shell it cannot verify) is resolved to a login shell here, in + // the supervisor, so it matches the sandbox image: bash when present, + // otherwise /bin/sh (e.g. Alpine). An explicit command is used verbatim. + let command = resolve_default_command(command); + info!(command = ?command, "Starting sandbox"); // Note: "Starting sandbox" stays as plain info!() since the OCSF context // is not yet initialized at this point (run_sandbox hasn't been called). @@ -616,13 +697,15 @@ fn main() -> Result<()> { proxy_auth_file: args.upstream_proxy_auth_file, proxy_auth_allow_insecure: args.upstream_proxy_auth_allow_insecure, proxy_connect_by_hostname: args.upstream_proxy_connect_by_hostname, + proxy_ca_bundle: args.upstream_proxy_ca_bundle, }; run_sandbox( command, - args.workdir, + workdir, args.timeout, - args.interactive, + interactive, + await_main_process_attachment, args.sandbox_id, args.sandbox, args.openshell_endpoint, @@ -643,11 +726,50 @@ fn main() -> Result<()> { std::process::exit(exit_code); } +/// Resolve an omitted canonical command to a login shell that exists in this +/// sandbox image. Empty means "use the default": the gateway leaves an omitted +/// command empty rather than persisting a shell it cannot verify, so the +/// supervisor picks one here against the real sandbox filesystem (bash when +/// present, otherwise `/bin/sh`). An explicit command is returned unchanged. +fn resolve_default_command(command: Vec) -> Vec { + if !command.is_empty() { + return command; + } + let shell = openshell_core::shell::detect_login_shell(); + info!(shell = %shell, "no command specified; resolved default login shell"); + vec![shell, "-l".to_string()] +} + #[cfg(test)] mod tests { use super::*; use std::os::unix::fs::PermissionsExt; + #[cfg(target_os = "linux")] + #[test] + fn workspace_validation_subcommand_uses_final_policy_identity() { + let uid = nix::unistd::geteuid().as_raw(); + let gid = nix::unistd::getegid().as_raw(); + if uid < 1000 || gid < 1000 { + return; + } + let dir = tempfile::tempdir_in("/tmp").unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); + let root = dir.path().canonicalize().unwrap().join("workspace"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let args = vec![ + "--workdir".to_string(), + root.display().to_string(), + "--expected-uid".to_string(), + uid.to_string(), + "--expected-gid".to_string(), + gid.to_string(), + ]; + + validate_workspace(&args).expect("current identity should retain workspace authority"); + } + /// Drives `copy_self`'s file-copy logic against an arbitrary source path /// so tests don't depend on `current_exe()`. fn copy_executable(src: &Path, dest: &Path) -> Result<()> { @@ -733,17 +855,23 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn network_init_accepts_root_proxy_uid_for_binary_aware_sidecar() { - validate_network_init_ids(0, openshell_policy::MIN_SANDBOX_UID).unwrap(); + validate_network_init_ids(0, 30).unwrap(); } #[cfg(target_os = "linux")] #[test] - fn network_init_still_rejects_low_non_root_proxy_ids() { + fn network_init_still_rejects_low_non_root_proxy_uid_and_root_gid() { let uid_err = validate_network_init_ids(999, openshell_policy::MIN_SANDBOX_UID).unwrap_err(); assert!(uid_err.to_string().contains("--proxy-uid")); - let gid_err = validate_network_init_ids(0, 999).unwrap_err(); + let gid_err = validate_network_init_ids(0, 0).unwrap_err(); assert!(gid_err.to_string().contains("--proxy-gid")); } + + #[cfg(target_os = "linux")] + #[test] + fn network_init_accepts_non_root_system_proxy_group() { + validate_network_init_ids(openshell_policy::MIN_SANDBOX_PROXY_UID, 30).unwrap(); + } } diff --git a/crates/openshell-sandbox/src/mechanistic_mapper.rs b/crates/openshell-sandbox/src/mechanistic_mapper.rs index 8ee2fc37f9..9be5f8e438 100644 --- a/crates/openshell-sandbox/src/mechanistic_mapper.rs +++ b/crates/openshell-sandbox/src/mechanistic_mapper.rs @@ -228,6 +228,7 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { binary: binary.clone(), validation_result: String::new(), rejection_reason: String::new(), + ..Default::default() }); } diff --git a/crates/openshell-sandbox/src/metadata_server.rs b/crates/openshell-sandbox/src/metadata_server.rs index cba614e496..dcfe3e439a 100644 --- a/crates/openshell-sandbox/src/metadata_server.rs +++ b/crates/openshell-sandbox/src/metadata_server.rs @@ -12,6 +12,7 @@ //! that needs an instance metadata emulator can implement the trait. use miette::Result; +use openshell_core::net::set_tcp_nodelay_best_effort; use std::future::Future; use std::net::SocketAddr; use std::sync::Arc; @@ -70,6 +71,9 @@ pub async fn run( match listener.accept().await { Ok((stream, _addr)) => { + // Small-request IMDS-style endpoint an agent polls for + // credentials/identity — disable Nagle to avoid delayed-ACK stalls. + set_tcp_nodelay_best_effort(&stream); let handler = handler.clone(); tokio::spawn(async move { if let Err(e) = handle_connection(handler.as_ref(), stream).await { diff --git a/crates/openshell-sandbox/src/sidecar_control.rs b/crates/openshell-sandbox/src/sidecar_control.rs index 8cc2854e41..fdf88f050b 100644 --- a/crates/openshell-sandbox/src/sidecar_control.rs +++ b/crates/openshell-sandbox/src/sidecar_control.rs @@ -24,6 +24,7 @@ use tracing::{debug, info, warn}; pub struct BootstrapData { pub policy_proto: openshell_core::proto::SandboxPolicy, pub provider_env_revision: u64, + pub provider_env_generation: u64, pub provider_child_env: HashMap, pub agent_proposals_enabled: bool, pub proxy_ca_cert_path: Option, @@ -35,6 +36,9 @@ pub struct BootstrapData { pub struct EntrypointStarted { pub pid: u32, pub start_session: bool, + pub instance_id: String, + pub exit_code: Option, + pub finalized: bool, } #[derive(Debug, Clone, Copy)] @@ -47,6 +51,7 @@ pub struct ExpectedPeer { pub enum ControlUpdate { ProviderEnv { revision: u64, + generation: u64, provider_child_env: HashMap, }, Policy { @@ -58,6 +63,9 @@ pub enum ControlUpdate { enabled: bool, config_revision: u64, }, + MainProcessExitAck { + instance_id: String, + }, } #[derive(Clone)] @@ -68,17 +76,22 @@ pub struct Publisher { impl Publisher { pub fn publish_provider_env(&self, revision: u64, provider_child_env: HashMap) { - { - let mut state = self.state.write().expect("sidecar control state poisoned"); - if revision <= state.provider_env_revision { - return; - } - state.provider_env_revision = revision; - state.provider_child_env.clone_from(&provider_child_env); + let mut state = self.state.write().expect("sidecar control state poisoned"); + if revision == state.provider_env_revision { + return; } - + state.provider_env_revision = revision; + state.provider_env_generation = state + .provider_env_generation + .checked_add(1) + .expect("sidecar provider environment generation overflow"); + state.provider_child_env.clone_from(&provider_child_env); + + // Keep generation assignment, bootstrap state, and publication under + // one lock so cloned publishers cannot emit generations out of order. let _ = self.updates.send(WireServerMessage::ProviderEnvUpdated { revision, + generation: state.provider_env_generation, provider_child_env, }); } @@ -115,6 +128,13 @@ impl Publisher { config_revision, }); } + + #[cfg(any(target_os = "linux", test))] + pub fn publish_main_process_exit_ack(&self, instance_id: String) { + let _ = self + .updates + .send(WireServerMessage::MainProcessExitAck { instance_id }); + } } pub struct ServerHandle { @@ -155,7 +175,9 @@ pub struct ProcessConnection { #[serde(tag = "type", rename_all = "snake_case")] enum WireClientMessage { BootstrapRequest { supervisor_pid: u32 }, - EntrypointStarted { pid: u32 }, + EntrypointStarted { pid: u32, instance_id: String }, + MainProcessExited { instance_id: String, exit_code: i32 }, + MainProcessFinalized { instance_id: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -164,6 +186,7 @@ enum WireServerMessage { BootstrapResponse { policy_proto: Vec, provider_env_revision: u64, + provider_env_generation: u64, provider_child_env: HashMap, agent_proposals_enabled: bool, proxy_ca_cert_path: Option, @@ -171,6 +194,7 @@ enum WireServerMessage { }, ProviderEnvUpdated { revision: u64, + generation: u64, provider_child_env: HashMap, }, PolicyUpdated { @@ -182,6 +206,9 @@ enum WireServerMessage { enabled: bool, config_revision: u64, }, + MainProcessExitAck { + instance_id: String, + }, } impl BootstrapData { @@ -190,6 +217,7 @@ impl BootstrapData { WireServerMessage::BootstrapResponse { policy_proto: self.policy_proto.encode_to_vec(), provider_env_revision: self.provider_env_revision, + provider_env_generation: self.provider_env_generation, provider_child_env: self.provider_child_env.clone(), agent_proposals_enabled: self.agent_proposals_enabled, proxy_ca_cert_path: self @@ -211,6 +239,7 @@ impl TryFrom for BootstrapData { let WireServerMessage::BootstrapResponse { policy_proto, provider_env_revision, + provider_env_generation, provider_child_env, agent_proposals_enabled, proxy_ca_cert_path, @@ -229,6 +258,7 @@ impl TryFrom for BootstrapData { Ok(Self { policy_proto, provider_env_revision, + provider_env_generation, provider_child_env, agent_proposals_enabled, proxy_ca_cert_path: proxy_ca_cert_path.map(PathBuf::from), @@ -244,9 +274,11 @@ impl TryFrom for ControlUpdate { match message { WireServerMessage::ProviderEnvUpdated { revision, + generation, provider_child_env, } => Ok(Self::ProviderEnv { revision, + generation, provider_child_env, }), WireServerMessage::PolicyUpdated { @@ -271,6 +303,9 @@ impl TryFrom for ControlUpdate { enabled, config_revision, }), + WireServerMessage::MainProcessExitAck { instance_id } => { + Ok(Self::MainProcessExitAck { instance_id }) + } WireServerMessage::BootstrapResponse { .. } => Err(miette::miette!( "unexpected sidecar bootstrap response after initial handshake" )), @@ -431,11 +466,16 @@ async fn handle_connection( .send(EntrypointStarted { pid: supervisor_pid, start_session: false, + instance_id: String::new(), + exit_code: None, + finalized: false, }) .await .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; } - WireClientMessage::EntrypointStarted { .. } => { + WireClientMessage::EntrypointStarted { .. } + | WireClientMessage::MainProcessExited { .. } + | WireClientMessage::MainProcessFinalized { .. } => { return Err(miette::miette!( "sidecar control client sent entrypoint event before bootstrap" )); @@ -462,7 +502,7 @@ async fn handle_connection( WireClientMessage::BootstrapRequest { .. } => { debug!("Ignoring duplicate sidecar bootstrap request"); } - WireClientMessage::EntrypointStarted { pid } => { + WireClientMessage::EntrypointStarted { pid, instance_id } => { if pid == 0 { warn!("Ignoring sidecar entrypoint event with pid=0"); continue; @@ -471,6 +511,36 @@ async fn handle_connection( .send(EntrypointStarted { pid, start_session: true, + instance_id, + exit_code: None, + finalized: false, + }) + .await + .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; + } + WireClientMessage::MainProcessExited { + instance_id, + exit_code, + } => { + entrypoint_tx + .send(EntrypointStarted { + pid: 0, + start_session: false, + instance_id, + exit_code: Some(exit_code), + finalized: false, + }) + .await + .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; + } + WireClientMessage::MainProcessFinalized { instance_id } => { + entrypoint_tx + .send(EntrypointStarted { + pid: 0, + start_session: false, + instance_id, + exit_code: None, + finalized: true, }) .await .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; @@ -565,8 +635,34 @@ async fn connect_with_retry(path: &Path, timeout: Duration) -> Result>, pid: u32) -> Result<()> { - let message = WireClientMessage::EntrypointStarted { pid }; +pub async fn send_entrypoint_started( + writer: &Arc>, + pid: u32, + instance_id: String, +) -> Result<()> { + let message = WireClientMessage::EntrypointStarted { pid, instance_id }; + let mut writer = writer.lock().await; + write_json_line(&mut *writer, &message).await +} + +pub async fn send_main_process_exited( + writer: &Arc>, + instance_id: String, + exit_code: i32, +) -> Result<()> { + let message = WireClientMessage::MainProcessExited { + instance_id, + exit_code, + }; + let mut writer = writer.lock().await; + write_json_line(&mut *writer, &message).await +} + +pub async fn send_main_process_finalized( + writer: &Arc>, + instance_id: String, +) -> Result<()> { + let message = WireClientMessage::MainProcessFinalized { instance_id }; let mut writer = writer.lock().await; write_json_line(&mut *writer, &message).await } @@ -620,6 +716,7 @@ mod tests { ..SandboxPolicy::default() }, provider_env_revision: 3, + provider_env_generation: 0, provider_child_env: env.clone(), agent_proposals_enabled: true, proxy_ca_cert_path: Some(PathBuf::from("/tmp/ca.pem")), @@ -633,6 +730,7 @@ mod tests { assert_eq!(received.policy_proto.version, 7); assert_eq!(received.provider_env_revision, 3); + assert_eq!(received.provider_env_generation, 0); assert_eq!(received.provider_child_env, env); assert!(received.agent_proposals_enabled); assert_eq!( @@ -645,6 +743,90 @@ mod tests { ); } + #[tokio::test] + async fn provider_env_updates_use_generation_not_fingerprint_order() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_server( + &socket, + BootstrapData { + policy_proto: SandboxPolicy::default(), + provider_env_revision: u64::MAX, + provider_env_generation: 7, + provider_child_env: HashMap::from([("TOKEN".to_string(), "first".to_string())]), + agent_proposals_enabled: false, + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }, + current_peer(), + ) + .unwrap(); + let publisher = server.publisher(); + let (_bootstrap, mut connection) = connect_process_client(&socket, Duration::from_secs(1)) + .await + .unwrap(); + + publisher.publish_provider_env( + 1, + HashMap::from([("TOKEN".to_string(), "second".to_string())]), + ); + + let update = tokio::time::timeout(Duration::from_secs(1), connection.updates.recv()) + .await + .unwrap() + .unwrap(); + match update { + ControlUpdate::ProviderEnv { + revision, + generation, + provider_child_env, + } => { + assert_eq!(revision, 1); + assert_eq!(generation, 8); + assert_eq!( + provider_child_env.get("TOKEN").map(String::as_str), + Some("second") + ); + } + other => panic!("unexpected sidecar update: {other:?}"), + } + + publisher.publish_provider_env( + 1, + HashMap::from([("TOKEN".to_string(), "duplicate".to_string())]), + ); + assert!( + tokio::time::timeout(Duration::from_millis(50), connection.updates.recv()) + .await + .is_err(), + "an identical fingerprint must remain a no-op" + ); + + publisher.publish_provider_env( + u64::MAX, + HashMap::from([("TOKEN".to_string(), "third".to_string())]), + ); + let update = tokio::time::timeout(Duration::from_secs(1), connection.updates.recv()) + .await + .unwrap() + .unwrap(); + match update { + ControlUpdate::ProviderEnv { + revision, + generation, + provider_child_env, + } => { + assert_eq!(revision, u64::MAX); + assert_eq!(generation, 9); + assert_eq!( + provider_child_env.get("TOKEN").map(String::as_str), + Some("third") + ); + } + other => panic!("unexpected sidecar update: {other:?}"), + } + } + #[tokio::test] async fn agent_proposals_update_is_delivered_to_process_client() { let dir = tempfile::tempdir().unwrap(); @@ -654,6 +836,7 @@ mod tests { BootstrapData { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, + provider_env_generation: 0, provider_child_env: HashMap::new(), agent_proposals_enabled: false, proxy_ca_cert_path: None, @@ -694,6 +877,7 @@ mod tests { BootstrapData { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, + provider_env_generation: 0, provider_child_env: HashMap::new(), agent_proposals_enabled: false, proxy_ca_cert_path: None, @@ -702,8 +886,9 @@ mod tests { current_peer(), ) .unwrap(); + let publisher = server.publisher(); let mut entrypoint_rx = server.into_entrypoint_receiver(); - let (_bootstrap, connection) = connect_process_client(&socket, Duration::from_secs(1)) + let (_bootstrap, mut connection) = connect_process_client(&socket, Duration::from_secs(1)) .await .unwrap(); @@ -714,7 +899,7 @@ mod tests { assert_eq!(anchor.pid, std::process::id()); assert!(!anchor.start_session); - send_entrypoint_started(&connection.writer, 4242) + send_entrypoint_started(&connection.writer, 4242, "instance-1".to_string()) .await .unwrap(); @@ -724,6 +909,44 @@ mod tests { .unwrap(); assert_eq!(started.pid, 4242); assert!(started.start_session); + assert_eq!(started.instance_id, "instance-1"); + assert!(started.exit_code.is_none()); + + send_main_process_exited(&connection.writer, "instance-1".to_string(), 0) + .await + .unwrap(); + let terminal = tokio::time::timeout(Duration::from_secs(1), entrypoint_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(terminal.exit_code, Some(0)); + assert!(!terminal.finalized); + + assert!( + tokio::time::timeout(Duration::from_millis(20), connection.updates.recv()) + .await + .is_err(), + "process side must not observe a durable ACK before gateway persistence" + ); + publisher.publish_main_process_exit_ack("instance-1".to_string()); + let ack = tokio::time::timeout(Duration::from_secs(1), connection.updates.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + ack, + ControlUpdate::MainProcessExitAck { instance_id } if instance_id == "instance-1" + )); + + send_main_process_finalized(&connection.writer, "instance-1".to_string()) + .await + .unwrap(); + let delivered = tokio::time::timeout(Duration::from_secs(1), entrypoint_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(delivered.exit_code.is_none()); + assert!(delivered.finalized); } #[tokio::test] @@ -735,6 +958,7 @@ mod tests { BootstrapData { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, + provider_env_generation: 0, provider_child_env: HashMap::new(), agent_proposals_enabled: false, proxy_ca_cert_path: None, @@ -769,6 +993,7 @@ mod tests { BootstrapData { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, + provider_env_generation: 0, provider_child_env: HashMap::new(), agent_proposals_enabled: false, proxy_ca_cert_path: None, @@ -798,6 +1023,7 @@ mod tests { BootstrapData { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, + provider_env_generation: 0, provider_child_env: HashMap::new(), agent_proposals_enabled: false, proxy_ca_cert_path: None, @@ -828,6 +1054,7 @@ mod tests { BootstrapData { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, + provider_env_generation: 0, provider_child_env: HashMap::new(), agent_proposals_enabled: false, proxy_ca_cert_path: None, diff --git a/crates/openshell-sdk/BUILD.bazel b/crates/openshell-sdk/BUILD.bazel deleted file mode 100644 index 59415a51df..0000000000 --- a/crates/openshell-sdk/BUILD.bazel +++ /dev/null @@ -1,39 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-sdk", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-sdk_test", - crate = ":openshell-sdk", - deps = all_crate_deps(normal_dev = True), -) - -rust_test( - name = "client_mock_integration_test", - srcs = ["tests/client_mock.rs"], - aliases = aliases(), - crate_root = "tests/client_mock.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-sdk"], -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":client_mock_integration_test", - ":openshell-sdk", - ":openshell-sdk_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-sdk/Cargo.toml b/crates/openshell-sdk/Cargo.toml index a1016baec0..8d80beaa74 100644 --- a/crates/openshell-sdk/Cargo.toml +++ b/crates/openshell-sdk/Cargo.toml @@ -17,7 +17,7 @@ futures = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } miette = { workspace = true } -oauth2 = "5" +oauth2 = { version = "5", default-features = false, features = ["reqwest"] } reqwest = { workspace = true } rustls = { workspace = true } serde = { workspace = true } diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 93afc5359e..cb42e12dc1 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -9,7 +9,8 @@ gateway-name resolution. ## Two layers - `OpenShellClient` — the curated, sandbox-focused surface: health, sandbox - CRUD, readiness/deletion waits, and non-streaming exec. + CRUD, reusable sandbox template CRUD, readiness/deletion waits, and + non-streaming exec. - `raw` — direct access to the generated tonic clients for RPCs the curated surface doesn't yet cover (inference, providers, policy, logs, settings, SSH, forwarding). @@ -44,10 +45,53 @@ mTLS (client certificates) is not supported. `OpenShellClient::connect(ClientConfig)` returns a connected client exposing `health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `delete_sandbox`, +`create_sandbox_from_template`, `create_sandbox_template`, +`get_sandbox_template`, `list_sandbox_templates`, `delete_sandbox_template`, `wait_ready`, `wait_deleted`, and `exec`. Curated types (`SandboxSpec`, -`SandboxRef`, `Health`, `ListOptions`, `ExecOptions`, `SandboxPhase`) use -SDK-shaped enums rather than raw proto integers. Failures map to a typed -`SdkError` with a discriminable kind. +`SandboxRef`, `Health`, `ListOptions`, `SandboxTemplateListOptions`, +`ExecOptions`, `SandboxPhase`) use SDK-shaped enums rather than raw proto +integers where practical. Reusable template resources are exposed as +`SandboxWorkloadTemplate` proto aliases so callers can populate the full +portable workload shape and driver config. Failures map to a typed `SdkError` +with a discriminable kind. + +```rust +use openshell_sdk::{ + ClientConfig, OpenShellClient, SandboxTemplateCreateSpec, + SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, +}; + +# async fn run() -> Result<(), openshell_sdk::SdkError> { +let client = OpenShellClient::connect(ClientConfig::new("http://127.0.0.1:8080")).await?; +client + .create_sandbox_template(SandboxWorkloadTemplate { + metadata: Some(openshell_sdk::raw::proto::datamodel::v1::ObjectMeta { + name: "python".to_string(), + ..Default::default() + }), + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + image: "ghcr.io/nvidia/openshell-community/sandboxes/python:latest".to_string(), + ..Default::default() + }), + ..Default::default() + }), + }) + .await?; + +let _sandbox = client + .create_sandbox_from_template(SandboxTemplateCreateSpec { + template_name: "python".to_string(), + policy: Some(openshell_sdk::raw::proto::SandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }) + .await?; +# Ok(()) +# } +``` ## Modules diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 0924ae4d62..b5486812f7 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -16,7 +16,7 @@ use crate::refresh::{RefreshedToken, TokenSource}; use crate::transport; use crate::types::{ ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec, - WorkspaceRef, + SandboxTemplateCreateSpec, SandboxTemplateListOptions, SandboxWorkloadTemplate, WorkspaceRef, }; use futures::StreamExt; use openshell_core::proto; @@ -163,6 +163,86 @@ impl OpenShellClient { sandbox_from_response(response.sandbox) } + /// Create a new sandbox from a workspace-scoped workload template name. + pub async fn create_sandbox_from_template( + &self, + spec: SandboxTemplateCreateSpec, + ) -> Result { + let request = create_sandbox_from_template_request(spec); + let response = self + .unary(|mut grpc| { + let request = request.clone(); + async move { grpc.create_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Create a reusable sandbox template in the default workspace. + pub async fn create_sandbox_template( + &self, + template: SandboxWorkloadTemplate, + ) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::CreateSandboxTemplateRequest { + template: Some(template.clone()), + workspace: String::new(), + }; + async move { grpc.create_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// Fetch a reusable sandbox template by name from the default workspace. + pub async fn get_sandbox_template(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::GetSandboxTemplateRequest { + name: name.to_string(), + workspace: String::new(), + }; + async move { grpc.get_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// List reusable sandbox templates in the default workspace or across all workspaces. + pub async fn list_sandbox_templates( + &self, + opts: SandboxTemplateListOptions, + ) -> Result> { + let response = self + .unary(|mut grpc| { + let request = proto::ListSandboxTemplatesRequest { + limit: opts.limit, + offset: opts.offset, + workspace: String::new(), + all_workspaces: opts.all_workspaces, + label_selector: opts.label_selector.clone(), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(response.templates) + } + + /// Delete a reusable sandbox template by name from the default workspace. + pub async fn delete_sandbox_template(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::DeleteSandboxTemplateRequest { + name: name.to_string(), + workspace: String::new(), + }; + async move { grpc.delete_sandbox_template(request).await } + }) + .await?; + Ok(response.deleted) + } + /// Fetch a sandbox by name. pub async fn get_sandbox(&self, name: &str) -> Result { let response = self @@ -217,16 +297,48 @@ impl OpenShellClient { Ok(response.deleted) } + /// Stop a sandbox by name. + pub async fn stop_sandbox(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::StopSandboxRequest { + name: name.to_string(), + workspace: String::new(), + }; + async move { grpc.stop_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Start a stopped sandbox by name. + pub async fn start_sandbox(&self, name: &str) -> Result { + let response = self + .unary(|mut grpc| { + let request = proto::StartSandboxRequest { + name: name.to_string(), + workspace: String::new(), + }; + async move { grpc.start_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + /// Poll [`OpenShellClient::get_sandbox`] until the sandbox reaches - /// [`SandboxPhase::Ready`] or the `timeout` elapses. + /// [`SandboxPhase::Ready`] or successful [`SandboxPhase::Completed`], or + /// until the `timeout` elapses. /// /// Returns the terminal sandbox snapshot on success. Returns an /// [`SdkError::Connect`] when the timeout expires, or whatever error /// the gateway returns if the sandbox transitions into - /// [`SandboxPhase::Error`]. + /// [`SandboxPhase::Stopped`] or [`SandboxPhase::Error`]. pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result { self.wait_for(name, timeout, |phase| match phase { - SandboxPhase::Ready => Some(Ok(())), + SandboxPhase::Ready | SandboxPhase::Completed => Some(Ok(())), + SandboxPhase::Stopped => Some(Err(SdkError::connect(format!( + "sandbox '{name}' main process failed" + )))), SandboxPhase::Error => Some(Err(SdkError::connect(format!( "sandbox '{name}' entered error phase" )))), @@ -379,6 +491,7 @@ impl OpenShellClient { tty: false, cols: 0, rows: 0, + no_login_shell: opts.no_login_shell, }; // Open the stream under the same OIDC-aware auth policy as unary RPCs @@ -534,6 +647,96 @@ impl WorkspaceScopedClient { sandbox_from_response(response.sandbox) } + /// Create a new sandbox from a template in this workspace. + pub async fn create_sandbox_from_template( + &self, + spec: SandboxTemplateCreateSpec, + ) -> Result { + let mut request = create_sandbox_from_template_request(spec); + request.workspace = self.workspace.clone(); + let response = self + .client + .unary(|mut grpc| { + let request = request.clone(); + async move { grpc.create_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Create a reusable sandbox template in this workspace. + pub async fn create_sandbox_template( + &self, + template: SandboxWorkloadTemplate, + ) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::CreateSandboxTemplateRequest { + template: Some(template.clone()), + workspace: self.workspace.clone(), + }; + async move { grpc.create_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// Fetch a reusable sandbox template by name in this workspace. + pub async fn get_sandbox_template(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::GetSandboxTemplateRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.get_sandbox_template(request).await } + }) + .await?; + sandbox_template_from_response(response.template) + } + + /// List reusable sandbox templates in this workspace, or across all workspaces. + pub async fn list_sandbox_templates( + &self, + opts: SandboxTemplateListOptions, + ) -> Result> { + let response = self + .client + .unary(|mut grpc| { + let request = proto::ListSandboxTemplatesRequest { + limit: opts.limit, + offset: opts.offset, + workspace: if opts.all_workspaces { + String::new() + } else { + self.workspace.clone() + }, + all_workspaces: opts.all_workspaces, + label_selector: opts.label_selector.clone(), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(response.templates) + } + + /// Delete a reusable sandbox template by name in this workspace. + pub async fn delete_sandbox_template(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::DeleteSandboxTemplateRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.delete_sandbox_template(request).await } + }) + .await?; + Ok(response.deleted) + } + /// Fetch a sandbox by name in this workspace. pub async fn get_sandbox(&self, name: &str) -> Result { let response = self @@ -586,15 +789,52 @@ impl WorkspaceScopedClient { Ok(response.deleted) } - /// Poll until the sandbox reaches [`SandboxPhase::Ready`] or the timeout - /// elapses. + /// Stop a sandbox by name in this workspace. + pub async fn stop_sandbox(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::StopSandboxRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.stop_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Start a stopped sandbox by name in this workspace. + pub async fn start_sandbox(&self, name: &str) -> Result { + let response = self + .client + .unary(|mut grpc| { + let request = proto::StartSandboxRequest { + name: name.to_string(), + workspace: self.workspace.clone(), + }; + async move { grpc.start_sandbox(request).await } + }) + .await?; + sandbox_from_response(response.sandbox) + } + + /// Poll until the sandbox reaches [`SandboxPhase::Ready`] or successful + /// [`SandboxPhase::Completed`], or the timeout elapses. pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result { let deadline = Instant::now() + timeout; let mut delay = Duration::from_millis(250); loop { let snapshot = self.get_sandbox(name).await?; match snapshot.phase { - SandboxPhase::Ready => return Ok(snapshot), + SandboxPhase::Ready | SandboxPhase::Completed => return Ok(snapshot), + SandboxPhase::Stopped => { + let detail = snapshot.exit_code.map_or_else( + || "stopped before becoming ready".to_string(), + |code| format!("main process failed with status {code}"), + ); + return Err(SdkError::connect(format!("sandbox '{name}' {detail}"))); + } SandboxPhase::Error => { return Err(SdkError::connect(format!( "sandbox '{name}' entered error phase" @@ -647,6 +887,7 @@ impl WorkspaceScopedClient { tty: false, cols: 0, rows: 0, + no_login_shell: opts.no_login_shell, }; let mut stream = self @@ -741,6 +982,8 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { environment, providers, gpu, + command, + tty, } = spec; let template = image.map(|image| proto::SandboxTemplate { image, @@ -755,12 +998,45 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { template, providers, resource_requirements, + command, + tty, ..proto::SandboxSpec::default() }), name: name.unwrap_or_default(), labels, annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + } +} + +fn create_sandbox_from_template_request( + spec: SandboxTemplateCreateSpec, +) -> proto::CreateSandboxRequest { + let SandboxTemplateCreateSpec { + name, + template_name, + labels, + providers, + command, + tty, + policy, + } = spec; + proto::CreateSandboxRequest { + spec: Some(proto::SandboxSpec { + providers, + command, + tty, + policy, + ..proto::SandboxSpec::default() + }), + name: name.unwrap_or_default(), + labels, + annotations: HashMap::new(), + workspace: String::new(), + workload_template_name: template_name, + await_main_process_attachment: false, } } @@ -770,6 +1046,13 @@ fn sandbox_from_response(sandbox: Option) -> Result .ok_or_else(|| SdkError::invalid_config("sandbox missing from gateway response")) } +fn sandbox_template_from_response( + template: Option, +) -> Result { + template + .ok_or_else(|| SdkError::invalid_config("sandbox template missing from gateway response")) +} + fn map_status(status: tonic::Status) -> SdkError { let message = status.message().to_string(); match status.code() { @@ -934,4 +1217,17 @@ mod tests { let current = slot.read().unwrap().clone().unwrap(); assert_eq!(current.to_str().unwrap(), "Bearer good-token"); } + + #[test] + fn create_request_preserves_canonical_main_process() { + let request = create_sandbox_request(SandboxSpec { + command: vec!["/opt/agent binary".into(), "--serve exactly".into()], + tty: false, + ..SandboxSpec::default() + }); + + let spec = request.spec.expect("sandbox spec should be present"); + assert_eq!(spec.command, ["/opt/agent binary", "--serve exactly"]); + assert!(!spec.tty); + } } diff --git a/crates/openshell-sdk/src/edge_tunnel.rs b/crates/openshell-sdk/src/edge_tunnel.rs index 5ced5fc354..ac7d8d4e9b 100644 --- a/crates/openshell-sdk/src/edge_tunnel.rs +++ b/crates/openshell-sdk/src/edge_tunnel.rs @@ -26,6 +26,7 @@ use crate::error::{Result, SdkError}; use futures::stream::{SplitSink, SplitStream}; use futures::{SinkExt, StreamExt}; +use openshell_core::net::set_tcp_nodelay_best_effort; use std::net::SocketAddr; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -100,6 +101,7 @@ async fn accept_loop(listener: TcpListener, config: Arc) { match listener.accept().await { Ok((stream, peer)) => { debug!(peer = %peer, "accepted local tunnel connection"); + set_tcp_nodelay_best_effort(&stream); let config = Arc::clone(&config); tokio::spawn(async move { if let Err(e) = handle_connection(stream, &config).await { @@ -175,6 +177,20 @@ async fn open_ws(config: &TunnelConfig) -> Result Some(tcp), + MaybeTlsStream::Rustls(tls) => Some(tls.get_ref().0), + // `MaybeTlsStream` is #[non_exhaustive]; surface any future/unknown + // variant so a silent TCP_NODELAY miss doesn't go unnoticed. + _ => { + debug!("edge tunnel: unrecognized MaybeTlsStream variant; skipping TCP_NODELAY"); + None + } + }; + if let Some(tcp) = tcp { + set_tcp_nodelay_best_effort(tcp); + } + debug!( status = %response.status(), "WebSocket connected to edge" diff --git a/crates/openshell-sdk/src/lib.rs b/crates/openshell-sdk/src/lib.rs index dbf2524a2a..985c7ecc05 100644 --- a/crates/openshell-sdk/src/lib.rs +++ b/crates/openshell-sdk/src/lib.rs @@ -6,7 +6,8 @@ //! Two layers: //! //! - [`OpenShellClient`] — the high-level sandbox-focused MVP surface: -//! health, sandbox CRUD, readiness/deletion waits, non-streaming exec. +//! health, sandbox CRUD, reusable sandbox templates, readiness/deletion +//! waits, and non-streaming exec. //! - [`raw`] — direct access to the generated tonic clients for RPCs the //! curated surface doesn't yet cover (inference, providers, policy, logs, //! settings, SSH, forwarding). @@ -46,6 +47,8 @@ pub use config::{AuthConfig, ClientConfig}; pub use error::SdkError; pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource}; pub use types::{ - ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec, - ServiceStatus, WorkspaceRef, + ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxResources, + SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec, + SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate, + SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceStatus, WorkspaceRef, }; diff --git a/crates/openshell-sdk/src/raw.rs b/crates/openshell-sdk/src/raw.rs index 80d7453602..35d91f3325 100644 --- a/crates/openshell-sdk/src/raw.rs +++ b/crates/openshell-sdk/src/raw.rs @@ -22,11 +22,15 @@ pub use openshell_core::proto; pub use openshell_core::proto::inference_client::InferenceClient; pub use openshell_core::proto::open_shell_client::OpenShellClient as GrpcClient; pub use openshell_core::proto::{ - CreateSandboxRequest, CreateWorkspaceRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, - ExecSandboxRequest, GetSandboxRequest, GetWorkspaceRequest, HealthRequest, - ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest, Sandbox, - SandboxPhase as ProtoSandboxPhase, SandboxSpec as ProtoSandboxSpec, SandboxTemplate, - ServiceStatus as ProtoServiceStatus, Workspace, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateWorkspaceRequest, + DeleteSandboxRequest, DeleteSandboxTemplateRequest, DeleteWorkspaceRequest, ExecSandboxRequest, + GetSandboxRequest, GetSandboxTemplateRequest, GetWorkspaceRequest, HealthRequest, + ListProvidersRequest, ListSandboxTemplatesRequest, ListSandboxesRequest, ListWorkspacesRequest, + Sandbox, SandboxPhase as ProtoSandboxPhase, SandboxResources, SandboxServiceLevel, + SandboxSpec as ProtoSandboxSpec, SandboxStartup, SandboxTemplate, SandboxTemplateResponse, + SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, + SandboxWorkloadTemplateSpec, ServiceStatus as ProtoServiceStatus, StartSandboxRequest, + StopSandboxRequest, Workspace, }; /// Type alias for the gRPC client wrapped in the SDK's auth interceptor. diff --git a/crates/openshell-sdk/src/transport.rs b/crates/openshell-sdk/src/transport.rs index f5610db6a2..9b25ced28c 100644 --- a/crates/openshell-sdk/src/transport.rs +++ b/crates/openshell-sdk/src/transport.rs @@ -10,6 +10,7 @@ use crate::config::{AuthConfig, ClientConfig}; use crate::edge_tunnel; use crate::error::{Result, SdkError}; +use openshell_core::net::set_tcp_nodelay_best_effort; use rustls::{ client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, pki_types::{CertificateDer, ServerName, UnixTime}, @@ -232,6 +233,7 @@ impl tower::Service for InsecureTlsConnector { let port = uri.port_u16().unwrap_or(443); let addr = format!("{host}:{port}"); let tcp = tokio::net::TcpStream::connect(addr).await?; + set_tcp_nodelay_best_effort(&tcp); let server_name = ServerName::try_from(host)?; let tls_stream = tls_connector.connect(server_name, tcp).await?; Ok(hyper_util::rt::TokioIo::new(tls_stream)) diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 2eb4ef0a92..db2944474b 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -62,6 +62,10 @@ pub enum SandboxPhase { Error, Deleting, Unknown, + Stopping, + Stopped, + Starting, + Completed, } impl From for SandboxPhase { @@ -73,6 +77,10 @@ impl From for SandboxPhase { proto::SandboxPhase::Error => Self::Error, proto::SandboxPhase::Deleting => Self::Deleting, proto::SandboxPhase::Unknown => Self::Unknown, + proto::SandboxPhase::Stopping => Self::Stopping, + proto::SandboxPhase::Stopped => Self::Stopped, + proto::SandboxPhase::Starting => Self::Starting, + proto::SandboxPhase::Completed => Self::Completed, } } } @@ -103,6 +111,64 @@ pub struct SandboxSpec { /// Request a GPU. Driver-specific device selection is configured via /// driver config on the raw proto surface (see [`crate::raw`]). pub gpu: bool, + /// Exact canonical command. Empty selects the gateway's scratch login shell. + pub command: Vec, + /// Allocate a retained pseudo-terminal for the canonical command. + pub tty: bool, +} + +/// Caller intent for creating a sandbox from a named workload template. +#[derive(Clone, Debug, Default)] +pub struct SandboxTemplateCreateSpec { + /// Optional user-supplied sandbox name. When empty the server generates one. + pub name: Option, + /// Workspace-scoped template name to resolve at creation time. + pub template_name: String, + /// Labels attached to the sandbox. + pub labels: HashMap, + /// Provider names to attach. + pub providers: Vec, + /// Exact canonical command. Empty selects the gateway's scratch login shell. + pub command: Vec, + /// Allocate a retained pseudo-terminal for the canonical command. + pub tty: bool, + /// Create-time sandbox policy. The named workload template supplies runtime + /// workload fields; policy remains part of the sandbox's governance spec. + pub policy: Option, +} + +/// Reusable sandbox workload template resource. +/// +/// This is a raw proto alias because template specs intentionally expose the +/// full portable workload shape plus driver-owned config. +pub type SandboxWorkloadTemplate = proto::SandboxWorkloadTemplate; + +/// Desired reusable workload shape for a [`SandboxWorkloadTemplate`]. +pub type SandboxWorkloadTemplateSpec = proto::SandboxWorkloadTemplateSpec; + +/// Portable sandbox workload configuration for template-backed sandboxes. +pub type SandboxWorkloadConfig = proto::SandboxWorkloadConfig; + +/// Portable resource requirements for template-backed sandboxes. +pub type SandboxResources = proto::SandboxResources; + +/// Desired service level for sandboxes created from a template. +pub type SandboxServiceLevel = proto::SandboxServiceLevel; + +/// Startup service-level settings for template-backed sandboxes. +pub type SandboxStartup = proto::SandboxStartup; + +/// Options for listing reusable sandbox templates. +#[derive(Clone, Debug, Default)] +pub struct SandboxTemplateListOptions { + /// Maximum templates to return. `0` defers to the server default. + pub limit: u32, + /// Offset into the result list. + pub offset: u32, + /// Optional label selector in `key=value,key2=value2` form. + pub label_selector: String, + /// List templates across all workspaces. + pub all_workspaces: bool, } /// Reference to a sandbox owned by the gateway. @@ -115,11 +181,29 @@ pub struct SandboxRef { pub phase: SandboxPhase, pub labels: HashMap, pub resource_version: u64, + pub exit_code: Option, + pub created_from_workload_template: Option, +} + +/// Reusable workload template revision used to create a sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct SandboxWorkloadTemplateProvenance { + pub name: String, + pub resource_version: String, } impl SandboxRef { pub(crate) fn from_proto(sandbox: proto::Sandbox) -> Self { let phase = sandbox.phase().into(); + let exit_code = sandbox.status.as_ref().and_then(|status| status.exit_code); + let created_from_workload_template = + sandbox + .created_from_workload_template + .map(|p| SandboxWorkloadTemplateProvenance { + name: p.name, + resource_version: p.resource_version, + }); let meta = sandbox.metadata.unwrap_or_default(); Self { id: meta.id, @@ -128,6 +212,8 @@ impl SandboxRef { phase, labels: meta.labels, resource_version: meta.resource_version, + exit_code, + created_from_workload_template, } } } @@ -182,6 +268,9 @@ pub struct ExecOptions { pub timeout: Option, /// Optional stdin payload. pub stdin: Option>, + /// Skip sourcing shell login/profile startup files before the command. + /// Default (`false`) preserves login-shell behavior. + pub no_login_shell: bool, } /// Result of a non-streaming exec call. diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 4ff23d74d1..58633ceb17 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -12,7 +12,8 @@ use openshell_core::proto; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_sdk::{ AuthConfig, ClientConfig, ExecOptions, ListOptions, OpenShellClient, Refresh, RefreshError, - RefreshedToken, SandboxPhase, SandboxSpec, ServiceStatus as SdkServiceStatus, + RefreshedToken, SandboxPhase, SandboxSpec, SandboxTemplateCreateSpec, + SandboxTemplateListOptions, ServiceStatus as SdkServiceStatus, }; use std::collections::HashMap; use std::sync::Arc; @@ -29,8 +30,14 @@ struct MockState { last_get_name: Mutex>, last_get_workspace: Mutex>, last_create: Mutex>, + last_template_create: Mutex>, + last_template_get: Mutex>, + last_template_list: Mutex>, + last_template_delete: Mutex>, last_delete_name: Mutex>, last_delete_workspace: Mutex>, + last_stop: Mutex>, + last_start: Mutex>, last_list_request: Mutex>, last_exec_request: Mutex>, last_workspace_request: Mutex>, @@ -59,6 +66,11 @@ fn sandbox_with_phase_ws( phase: proto::SandboxPhase, workspace: &str, ) -> proto::Sandbox { + let created_from_workload_template = + (name == "from-template").then(|| proto::SandboxWorkloadTemplateProvenance { + name: "python".to_string(), + resource_version: "7".to_string(), + }); proto::Sandbox { metadata: Some(proto::datamodel::v1::ObjectMeta { id: format!("id-{name}"), @@ -75,6 +87,7 @@ fn sandbox_with_phase_ws( phase: phase.into(), ..Default::default() }), + created_from_workload_template, } } @@ -96,8 +109,50 @@ fn workspace_proto(name: &str, phase: proto::datamodel::v1::WorkspacePhase) -> p } } +fn workload_template_proto(name: &str, workspace: &str) -> proto::SandboxWorkloadTemplate { + proto::SandboxWorkloadTemplate { + metadata: Some(proto::datamodel::v1::ObjectMeta { + id: format!("template-{workspace}-{name}"), + name: name.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 1, + deletion_timestamp_ms: 0, + workspace: workspace.to_string(), + }), + spec: Some(proto::SandboxWorkloadTemplateSpec { + workload: Some(proto::SandboxWorkloadConfig { + image: format!("ghcr.io/test/{name}:latest"), + environment: HashMap::new(), + resources: Some(proto::SandboxResources { + cpu: "1".to_string(), + memory: "512Mi".to_string(), + ..proto::SandboxResources::default() + }), + }), + driver_config: None, + desired_service_level: None, + }), + } +} + #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -161,6 +216,94 @@ impl OpenShell for TestOpenShell { })) } + async fn create_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let template = request + .template + .clone() + .ok_or_else(|| Status::invalid_argument("missing template"))?; + *self.state.last_template_create.lock().await = Some(request); + Ok(Response::new(proto::SandboxTemplateResponse { + template: Some(template), + })) + } + + async fn get_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let workspace = if request.workspace.is_empty() { + "default" + } else { + &request.workspace + }; + let template = workload_template_proto(&request.name, workspace); + *self.state.last_template_get.lock().await = Some(request); + Ok(Response::new(proto::SandboxTemplateResponse { + template: Some(template), + })) + } + + async fn list_sandbox_templates( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + *self.state.last_template_list.lock().await = Some(request); + Ok(Response::new(proto::ListSandboxTemplatesResponse { + templates: vec![ + workload_template_proto("python", "default"), + workload_template_proto("cuda", "gpu"), + ], + })) + } + + async fn delete_sandbox_template( + &self, + request: tonic::Request, + ) -> Result, Status> { + *self.state.last_template_delete.lock().await = Some(request.into_inner()); + Ok(Response::new(proto::DeleteSandboxTemplateResponse { + deleted: true, + })) + } + + async fn stop_sandbox( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let sandbox = sandbox_with_phase_ws( + &request.name, + proto::SandboxPhase::Stopped, + &request.workspace, + ); + *self.state.last_stop.lock().await = Some(request); + Ok(Response::new(proto::SandboxResponse { + sandbox: Some(sandbox), + })) + } + + async fn start_sandbox( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let sandbox = sandbox_with_phase_ws( + &request.name, + proto::SandboxPhase::Starting, + &request.workspace, + ); + *self.state.last_start.lock().await = Some(request); + Ok(Response::new(proto::SandboxResponse { + sandbox: Some(sandbox), + })) + } + async fn get_sandbox( &self, request: tonic::Request, @@ -458,6 +601,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(proto::GetGatewayConfigResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn update_config( &self, _: tonic::Request, @@ -762,6 +912,92 @@ async fn create_sandbox_passes_spec_through() { ); } +#[tokio::test] +async fn create_sandbox_from_template_passes_template_name() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let sandbox = client + .create_sandbox_from_template(SandboxTemplateCreateSpec { + name: Some("from-template".to_string()), + template_name: "python".to_string(), + providers: vec!["openai".to_string()], + command: vec!["python".to_string(), "-m".to_string(), "agent".to_string()], + tty: false, + policy: Some(proto::SandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(sandbox.name, "from-template"); + + let observed = state.last_create.lock().await.clone().unwrap(); + assert_eq!(observed.name, "from-template"); + assert_eq!(observed.workload_template_name, "python"); + let observed_spec = observed.spec.unwrap(); + assert_eq!(observed_spec.providers, vec!["openai".to_string()]); + assert_eq!(observed_spec.command, vec!["python", "-m", "agent"]); + assert!(!observed_spec.tty); + assert_eq!(observed_spec.policy.unwrap().version, 1); +} + +#[tokio::test] +async fn sandbox_template_crud_uses_default_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let created = client + .create_sandbox_template(workload_template_proto("python", "")) + .await + .unwrap(); + assert_eq!(created.metadata.as_ref().unwrap().name, "python"); + + let observed_create = state.last_template_create.lock().await.clone().unwrap(); + assert!(observed_create.workspace.is_empty()); + assert_eq!( + observed_create + .template + .as_ref() + .and_then(|template| template.metadata.as_ref()) + .unwrap() + .name, + "python" + ); + + let fetched = client.get_sandbox_template("python").await.unwrap(); + assert_eq!(fetched.metadata.as_ref().unwrap().name, "python"); + let observed_get = state.last_template_get.lock().await.clone().unwrap(); + assert_eq!(observed_get.name, "python"); + assert!(observed_get.workspace.is_empty()); + + let listed = client + .list_sandbox_templates(SandboxTemplateListOptions { + limit: 10, + offset: 2, + label_selector: String::new(), + all_workspaces: true, + }) + .await + .unwrap(); + assert_eq!(listed.len(), 2); + let observed_list = state.last_template_list.lock().await.clone().unwrap(); + assert_eq!(observed_list.limit, 10); + assert_eq!(observed_list.offset, 2); + assert!(observed_list.workspace.is_empty()); + assert!(observed_list.all_workspaces); + + let deleted = client.delete_sandbox_template("python").await.unwrap(); + assert!(deleted); + let observed_delete = state.last_template_delete.lock().await.clone().unwrap(); + assert_eq!(observed_delete.name, "python"); + assert!(observed_delete.workspace.is_empty()); +} + #[tokio::test] async fn get_sandbox_sends_name_and_maps_phase() { let state = Arc::new(MockState { @@ -780,6 +1016,21 @@ async fn get_sandbox_sends_name_and_maps_phase() { assert_eq!(observed.as_deref(), Some("my-box")); } +#[tokio::test] +async fn get_sandbox_preserves_workload_template_provenance() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let sandbox = client.get_sandbox("from-template").await.unwrap(); + + let provenance = sandbox + .created_from_workload_template + .expect("template provenance"); + assert_eq!(provenance.name, "python"); + assert_eq!(provenance.resource_version, "7"); +} + #[tokio::test] async fn list_sandboxes_propagates_filters() { let state = Arc::new(MockState::default()); @@ -816,6 +1067,29 @@ async fn delete_sandbox_returns_server_ack() { assert_eq!(observed.as_deref(), Some("doomed")); } +#[tokio::test] +async fn stop_and_start_map_requests_and_phases() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let stopped = client.stop_sandbox("sleepy").await.unwrap(); + assert_eq!(stopped.phase, SandboxPhase::Stopped); + let stop = state.last_stop.lock().await.clone().unwrap(); + assert_eq!(stop.name, "sleepy"); + assert!(stop.workspace.is_empty()); + + let started = client + .workspace("team-a") + .start_sandbox("sleepy") + .await + .unwrap(); + assert_eq!(started.phase, SandboxPhase::Starting); + let start = state.last_start.lock().await.clone().unwrap(); + assert_eq!(start.name, "sleepy"); + assert_eq!(start.workspace, "team-a"); +} + #[tokio::test] async fn wait_ready_transitions_through_phases() { let state = Arc::new(MockState { @@ -837,6 +1111,41 @@ async fn wait_ready_transitions_through_phases() { assert!(state.get_calls.load(Ordering::SeqCst) >= 3); } +#[tokio::test] +async fn wait_ready_accepts_successful_completion() { + let state = Arc::new(MockState { + phase_sequence: vec![ + proto::SandboxPhase::Provisioning, + proto::SandboxPhase::Completed, + ], + ..Default::default() + }); + let endpoint = start_mock(state).await; + let client = connect(&endpoint).await; + + let sandbox = client + .wait_ready("short-job", std::time::Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(sandbox.phase, SandboxPhase::Completed); +} + +#[tokio::test] +async fn wait_ready_surfaces_stopped_phase_without_timing_out() { + let state = Arc::new(MockState { + phase_sequence: vec![proto::SandboxPhase::Stopped], + ..Default::default() + }); + let endpoint = start_mock(state).await; + let client = connect(&endpoint).await; + + let err = client + .wait_ready("failed-job", std::time::Duration::from_secs(5)) + .await + .unwrap_err(); + assert_eq!(err.code(), "connect"); +} + #[tokio::test] async fn wait_ready_surfaces_error_phase() { let state = Arc::new(MockState { @@ -1024,6 +1333,33 @@ async fn workspace_scoped_create_passes_workspace() { assert_eq!(observed.workspace, "staging"); } +#[tokio::test] +async fn workspace_scoped_create_from_template_passes_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let sandbox = client + .workspace("staging") + .create_sandbox_from_template(SandboxTemplateCreateSpec { + name: Some("from-template".to_string()), + template_name: "python".to_string(), + policy: Some(proto::SandboxPolicy { + version: 2, + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(sandbox.name, "from-template"); + + let observed = state.last_create.lock().await.clone().unwrap(); + assert_eq!(observed.workspace, "staging"); + assert_eq!(observed.workload_template_name, "python"); + assert_eq!(observed.spec.unwrap().policy.unwrap().version, 2); +} + #[tokio::test] async fn workspace_scoped_get_passes_workspace() { let state = Arc::new(MockState { @@ -1056,6 +1392,50 @@ async fn workspace_scoped_list_passes_workspace() { assert!(!observed.all_workspaces); } +#[tokio::test] +async fn workspace_scoped_sandbox_template_crud_passes_workspace() { + let state = Arc::new(MockState::default()); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + let ws = client.workspace("staging"); + + ws.create_sandbox_template(workload_template_proto("python", "staging")) + .await + .unwrap(); + let observed_create = state.last_template_create.lock().await.clone().unwrap(); + assert_eq!(observed_create.workspace, "staging"); + + ws.get_sandbox_template("python").await.unwrap(); + let observed_get = state.last_template_get.lock().await.clone().unwrap(); + assert_eq!(observed_get.name, "python"); + assert_eq!(observed_get.workspace, "staging"); + + let listed = ws + .list_sandbox_templates(SandboxTemplateListOptions::default()) + .await + .unwrap(); + assert_eq!(listed.len(), 2); + let observed_list = state.last_template_list.lock().await.clone().unwrap(); + assert_eq!(observed_list.workspace, "staging"); + assert!(!observed_list.all_workspaces); + + ws.list_sandbox_templates(SandboxTemplateListOptions { + all_workspaces: true, + ..Default::default() + }) + .await + .unwrap(); + let observed_all = state.last_template_list.lock().await.clone().unwrap(); + assert!(observed_all.workspace.is_empty()); + assert!(observed_all.all_workspaces); + + let deleted = ws.delete_sandbox_template("python").await.unwrap(); + assert!(deleted); + let observed_delete = state.last_template_delete.lock().await.clone().unwrap(); + assert_eq!(observed_delete.name, "python"); + assert_eq!(observed_delete.workspace, "staging"); +} + #[tokio::test] async fn workspace_scoped_delete_passes_workspace() { let state = Arc::new(MockState::default()); diff --git a/crates/openshell-server-macros/BUILD.bazel b/crates/openshell-server-macros/BUILD.bazel deleted file mode 100644 index 3379813bfc..0000000000 --- a/crates/openshell-server-macros/BUILD.bazel +++ /dev/null @@ -1,17 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_proc_macro.bzl", "rust_proc_macro") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_proc_macro( - name = "openshell-server-macros", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [":openshell-server-macros"], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-server/BUILD.bazel b/crates/openshell-server/BUILD.bazel deleted file mode 100644 index 3c3293c720..0000000000 --- a/crates/openshell-server/BUILD.bazel +++ /dev/null @@ -1,148 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_binary.bzl", "rust_binary") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-server", - srcs = glob( - ["src/**/*.rs"], - exclude = ["src/main.rs"], - ), - aliases = aliases(), - compile_data = glob(["migrations/**/*"]), - crate_features = ["telemetry"], - rustc_env = { - "CARGO_MANIFEST_DIR": "crates/openshell-server", - }, - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_binary( - name = "openshell-gateway", - srcs = ["src/main.rs"], - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True) + [":openshell-server"], -) - -rust_test( - name = "openshell-server_test", - crate = ":openshell-server", - data = ["//:deploy/rpm/gateway.toml.default"], - rustc_env = { - "CARGO_MANIFEST_DIR": "crates/openshell-server", - }, - deps = all_crate_deps(normal_dev = True), -) - -rust_test( - name = "health_endpoint_integration_test", - srcs = ["tests/health_endpoint_integration.rs"], - aliases = aliases(), - crate_root = "tests/health_endpoint_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-server"], -) - -rust_test( - name = "multiplex_integration_test", - srcs = [ - "tests/common/mod.rs", - "tests/multiplex_integration.rs", - ], - aliases = aliases(), - crate_root = "tests/multiplex_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-server"], -) - -rust_test( - name = "auth_endpoint_integration_test", - srcs = [ - "tests/auth_endpoint_integration.rs", - "tests/common/mod.rs", - ], - aliases = aliases(), - crate_root = "tests/auth_endpoint_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-server"], -) - -rust_test( - name = "supervisor_relay_integration_test", - srcs = ["tests/supervisor_relay_integration.rs"], - aliases = aliases(), - crate_root = "tests/supervisor_relay_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-server"], -) - -rust_test( - name = "ws_tunnel_integration_test", - srcs = [ - "tests/common/mod.rs", - "tests/ws_tunnel_integration.rs", - ], - aliases = aliases(), - crate_root = "tests/ws_tunnel_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-server"], -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":auth_endpoint_integration_test", - ":edge_tunnel_auth_integration_test", - ":health_endpoint_integration_test", - ":multiplex_integration_test", - ":multiplex_tls_integration_test", - ":openshell-gateway", - ":openshell-server", - ":openshell-server_test", - ":supervisor_relay_integration_test", - ":ws_tunnel_integration_test", - ], - visibility = ["//crates:__pkg__"], -) - -rust_test( - name = "multiplex_tls_integration_test", - srcs = [ - "tests/common/mod.rs", - "tests/multiplex_tls_integration.rs", - ], - aliases = aliases(), - crate_root = "tests/multiplex_tls_integration.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-server"], -) - -rust_test( - name = "edge_tunnel_auth_integration_test", - srcs = [ - "tests/common/mod.rs", - "tests/edge_tunnel_auth.rs", - ], - aliases = aliases(), - crate_root = "tests/edge_tunnel_auth.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-server"], -) diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 26568fd79c..2619fee5cc 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -10,16 +10,13 @@ rust-version.workspace = true license.workspace = true repository.workspace = true -[[bin]] -name = "openshell-gateway" -path = "src/main.rs" - [dependencies] openshell-bootstrap = { path = "../openshell-bootstrap" } -openshell-core = { path = "../openshell-core", default-features = false } -openshell-driver-docker = { path = "../openshell-driver-docker" } -openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } -openshell-driver-podman = { path = "../openshell-driver-podman" } +openshell-core = { path = "../openshell-core", default-features = false, features = ["oauth"] } +openshell-driver-db-credstore = { path = "../openshell-driver-db-credstore" } +openshell-driver-kubernetes-secrets = { path = "../openshell-driver-kubernetes-secrets" } +openshell-driver-vault = { path = "../openshell-driver-vault" } +openshell-extension-core = { path = "../openshell-extension-core" } openshell-gateway-interceptors = { path = "../openshell-gateway-interceptors" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-otel = { path = "../openshell-otel" } @@ -30,7 +27,8 @@ openshell-router = { path = "../openshell-router" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } -# Kubernetes client (used by the `generate-certs` subcommand) +# Kubernetes client used by ServiceAccount bootstrap authentication and the +# `generate-certs` subcommand. kube = { workspace = true } k8s-openapi = { workspace = true } @@ -82,9 +80,11 @@ metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } # Utilities +base64 = { workspace = true } futures = { workspace = true } bytes = { workspace = true } pin-project-lite = { workspace = true } +ring = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } @@ -97,11 +97,12 @@ uuid = { workspace = true } hmac = "0.12" sha2 = { workspace = true } jsonwebtoken = { workspace = true } +spiffe = { workspace = true } async-trait = "0.1" url = { workspace = true } glob = { workspace = true } hex = "0.4" -russh = "0.61" +russh = "0.62" rand = { workspace = true } petname = "2" ipnet = "2" @@ -121,8 +122,10 @@ bundled-z3 = ["openshell-prover/bundled-z3"] test-support = [] [dev-dependencies] -hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "ring", "webpki-tokio"] } +base64 = { workspace = true } +hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "ring"] } rcgen = { version = "0.13", features = ["crypto", "pem"] } +rsa = { version = "0.9", features = ["pem"] } tokio-tungstenite = { workspace = true } futures-util = "0.3" wiremock = "0.6" diff --git a/crates/openshell-server/src/auth/authenticator.rs b/crates/openshell-server/src/auth/authenticator.rs index f5d5c7b2af..5511ef79db 100644 --- a/crates/openshell-server/src/auth/authenticator.rs +++ b/crates/openshell-server/src/auth/authenticator.rs @@ -14,8 +14,8 @@ //! //! Live authenticators slotting into the chain: //! - [`super::sandbox_jwt::SandboxJwtAuthenticator`] — gateway-minted JWTs -//! - [`super::k8s_sa::K8sServiceAccountAuthenticator`] — K8s projected SA -//! tokens (path-scoped to `IssueSandboxToken`) +//! - [`super::compute_driver::ComputeDriverAuthenticator`] — driver-native +//! sandbox bootstrap credentials (path-scoped to `IssueSandboxToken`) //! - [`super::oidc::OidcAuthenticator`] — user OIDC Bearer tokens use super::principal::Principal; use async_trait::async_trait; diff --git a/crates/openshell-server/src/auth/compute_driver.rs b/crates/openshell-server/src/auth/compute_driver.rs new file mode 100644 index 0000000000..04caee61b1 --- /dev/null +++ b/crates/openshell-server/src/auth/compute_driver.rs @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Compute-driver delegated sandbox bootstrap authentication. + +use super::authenticator::Authenticator; +use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; +use crate::compute::ComputeRuntime; +use async_trait::async_trait; +use tonic::Status; + +/// The only public gateway method on which driver-native credentials apply. +pub const ISSUE_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/IssueSandboxToken"; + +#[derive(Clone, Debug)] +pub struct ComputeDriverAuthenticator { + compute: ComputeRuntime, +} + +impl ComputeDriverAuthenticator { + pub fn new(compute: ComputeRuntime) -> Self { + Self { compute } + } +} + +#[async_trait] +impl Authenticator for ComputeDriverAuthenticator { + async fn authenticate( + &self, + headers: &http::HeaderMap, + path: &str, + ) -> Result, Status> { + if path != ISSUE_SANDBOX_TOKEN_PATH { + return Ok(None); + } + + let Some(credential) = headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + else { + return Ok(None); + }; + + let sandbox_id = self.compute.authenticate_sandbox(credential).await?; + if sandbox_id.is_empty() { + return Err(Status::permission_denied( + "compute driver returned an empty sandbox identity", + )); + } + + Ok(Some(Principal::Sandbox(SandboxPrincipal { + sandbox_id, + source: SandboxIdentitySource::ComputeDriver { + driver_name: self.compute.configured_driver_name().to_string(), + }, + trust_domain: Some("openshell".to_string()), + }))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::principal::SandboxIdentitySource; + use crate::compute::{NoopTestDriver, new_test_runtime_with_driver}; + use crate::persistence::Store; + use std::sync::Arc; + use tonic::Code; + + fn bearer_headers(token: &str) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::AUTHORIZATION, + http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + headers + } + + async fn authenticator(driver: NoopTestDriver) -> ComputeDriverAuthenticator { + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let compute = + new_test_runtime_with_driver(store, "external-kubernetes", Arc::new(driver)).await; + ComputeDriverAuthenticator::new(compute) + } + + #[tokio::test] + async fn authenticates_driver_credential_on_issue_path() { + let auth = authenticator(NoopTestDriver::authenticating_sandbox("sandbox-a")).await; + + let principal = auth + .authenticate( + &bearer_headers("driver-credential"), + ISSUE_SANDBOX_TOKEN_PATH, + ) + .await + .unwrap() + .expect("driver credential should authenticate"); + + let Principal::Sandbox(principal) = principal else { + panic!("expected sandbox principal"); + }; + assert_eq!(principal.sandbox_id, "sandbox-a"); + assert!(matches!( + principal.source, + SandboxIdentitySource::ComputeDriver { ref driver_name } + if driver_name == "external-kubernetes" + )); + } + + #[tokio::test] + async fn authenticator_is_scoped_to_issue_path() { + let auth = authenticator(NoopTestDriver::failing_sandbox_authentication( + Code::Unavailable, + "driver must not be called", + )) + .await; + + let result = auth + .authenticate( + &bearer_headers("driver-credential"), + "/openshell.v1.OpenShell/GetSandboxConfig", + ) + .await + .unwrap(); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn missing_bearer_credential_falls_through() { + let auth = authenticator(NoopTestDriver::authenticating_sandbox("sandbox-a")).await; + + let result = auth + .authenticate(&http::HeaderMap::new(), ISSUE_SANDBOX_TOKEN_PATH) + .await + .unwrap(); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn empty_driver_identity_is_rejected() { + let auth = authenticator(NoopTestDriver::authenticating_sandbox("")).await; + + let error = auth + .authenticate( + &bearer_headers("driver-credential"), + ISSUE_SANDBOX_TOKEN_PATH, + ) + .await + .expect_err("empty identity must fail closed"); + + assert_eq!(error.code(), Code::PermissionDenied); + } + + #[tokio::test] + async fn driver_authentication_error_propagates() { + let auth = authenticator(NoopTestDriver::failing_sandbox_authentication( + Code::Unavailable, + "driver unavailable", + )) + .await; + + let error = auth + .authenticate( + &bearer_headers("driver-credential"), + ISSUE_SANDBOX_TOKEN_PATH, + ) + .await + .expect_err("driver errors must propagate"); + + assert_eq!(error.code(), Code::Unavailable); + assert_eq!(error.message(), "driver unavailable"); + } +} diff --git a/crates/openshell-server/src/auth/extension_mint_limit.rs b/crates/openshell-server/src/auth/extension_mint_limit.rs new file mode 100644 index 0000000000..0495cf0347 --- /dev/null +++ b/crates/openshell-server/src/auth/extension_mint_limit.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-sandbox bound on extension credential minting. +//! +//! Minting resolves the sandbox's effective policy to decide which extension +//! registrations the caller may hold credentials for. That resolution reads +//! policy history, settings, and the provider profile catalog, so an +//! unbounded caller inside a sandbox could impose real gateway cost by +//! requesting credentials in a loop. +//! +//! A well-behaved supervisor rotates at roughly 80% of the credential +//! lifetime — about once every twelve minutes for the fifteen-minute default — +//! plus a small burst at startup and on retry. The default bound leaves ample +//! headroom for that while capping what a compromised sandbox can drive. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const DEFAULT_WINDOW: Duration = Duration::from_secs(60); +const DEFAULT_MAX_PER_WINDOW: u32 = 10; + +/// Number of tracked sandboxes above which expired windows are pruned. +const PRUNE_THRESHOLD: usize = 1_024; + +struct Window { + started: Instant, + count: u32, +} + +pub struct ExtensionMintLimiter { + window: Duration, + max_per_window: u32, + windows: Mutex>, +} + +impl Default for ExtensionMintLimiter { + fn default() -> Self { + Self::new(DEFAULT_WINDOW, DEFAULT_MAX_PER_WINDOW) + } +} + +impl std::fmt::Debug for ExtensionMintLimiter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExtensionMintLimiter") + .field("window", &self.window) + .field("max_per_window", &self.max_per_window) + .finish_non_exhaustive() + } +} + +impl ExtensionMintLimiter { + #[must_use] + pub fn new(window: Duration, max_per_window: u32) -> Self { + Self { + window, + max_per_window, + windows: Mutex::new(HashMap::new()), + } + } + + /// Record one minting request, returning `false` when the sandbox has + /// exhausted its window. + pub fn try_acquire(&self, sandbox_id: &str) -> bool { + self.try_acquire_at(sandbox_id, Instant::now()) + } + + fn try_acquire_at(&self, sandbox_id: &str, now: Instant) -> bool { + if self.max_per_window == 0 { + return true; + } + let mut windows = self + .windows + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if windows.len() >= PRUNE_THRESHOLD { + windows.retain(|_, window| now.duration_since(window.started) < self.window); + } + + let window = windows.entry(sandbox_id.to_string()).or_insert(Window { + started: now, + count: 0, + }); + if now.duration_since(window.started) >= self.window { + window.started = now; + window.count = 0; + } + if window.count >= self.max_per_window { + return false; + } + window.count += 1; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_a_burst_then_refuses_within_the_window() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 3); + let start = Instant::now(); + + for _ in 0..3 { + assert!(limiter.try_acquire_at("sandbox-a", start)); + } + assert!(!limiter.try_acquire_at("sandbox-a", start)); + } + + #[test] + fn window_rollover_restores_capacity() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 1); + let start = Instant::now(); + + assert!(limiter.try_acquire_at("sandbox-a", start)); + assert!(!limiter.try_acquire_at("sandbox-a", start + Duration::from_secs(59))); + assert!(limiter.try_acquire_at("sandbox-a", start + Duration::from_secs(60))); + } + + #[test] + fn sandboxes_are_limited_independently() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 1); + let start = Instant::now(); + + assert!(limiter.try_acquire_at("sandbox-a", start)); + assert!(!limiter.try_acquire_at("sandbox-a", start)); + // One noisy sandbox must not deny credentials to any other. + assert!(limiter.try_acquire_at("sandbox-b", start)); + } + + #[test] + fn legitimate_rotation_cadence_stays_well_inside_the_default_bound() { + let limiter = ExtensionMintLimiter::default(); + let start = Instant::now(); + + // Startup acquires once, then the poll loop rotates at ~80% of a + // 15-minute credential. Even compressed into a single window that is + // far below the bound. + for minute in 0..12 { + assert!(limiter.try_acquire_at("sandbox-a", start + Duration::from_secs(minute * 60))); + } + } + + #[test] + fn a_zero_bound_disables_limiting() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 0); + let start = Instant::now(); + for _ in 0..1_000 { + assert!(limiter.try_acquire_at("sandbox-a", start)); + } + } +} diff --git a/crates/openshell-server/src/auth/http.rs b/crates/openshell-server/src/auth/http.rs index f0e7011658..df95f35fd7 100644 --- a/crates/openshell-server/src/auth/http.rs +++ b/crates/openshell-server/src/auth/http.rs @@ -18,7 +18,7 @@ use axum::{ Json, Router, extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, Uri}, response::{Html, IntoResponse}, routing::get, }; @@ -59,9 +59,104 @@ pub fn router(state: Arc) -> Router { Router::new() .route("/auth/connect", get(auth_connect)) .route("/auth/oidc-config", get(oidc_config_handler)) + .route("/.well-known/jwks.json", get(gateway_jwks_handler)) + .route( + "/.well-known/openid-configuration", + get(gateway_discovery_handler), + ) .with_state(state) } +/// Publish OIDC-shaped discovery metadata for extension services. +/// +/// This lets an extension be configured with only the trusted gateway URL: +/// it learns the exact expected `iss` and the `jwks_uri` from one document +/// instead of having both provisioned separately. +/// +/// Like the equivalent Kubernetes endpoint, this is OIDC-shaped rather than +/// OIDC-compliant: `issuer` is the gateway identity (`openshell-gateway:`), +/// not the URL this document is served from. Verifiers must compare `iss` +/// against that value, and must not infer trust from the document's location +/// alone. The serving TLS connection is what authenticates the gateway. +async fn gateway_discovery_handler( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> impl IntoResponse { + gateway_discovery_response( + state.sandbox_jwt_authenticator.as_deref(), + &document_base_url(&headers, &uri), + ) +} + +/// Reconstruct the externally visible base URL for absolute discovery links. +/// +/// HTTP/1.1 carries `Host`; HTTP/2 carries `:authority`, which axum surfaces +/// on the request URI. A terminating proxy may report the original scheme in +/// `X-Forwarded-Proto`; otherwise assume TLS, since the extension contract +/// requires fetching this document over an authenticated connection. +fn document_base_url(headers: &HeaderMap, uri: &Uri) -> Option { + let authority = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .filter(|host| !host.is_empty()) + .map(ToString::to_string) + .or_else(|| uri.authority().map(ToString::to_string))?; + let scheme = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .and_then(|proto| proto.split(',').next()) + .map(str::trim) + .filter(|proto| !proto.is_empty()) + .unwrap_or("https"); + Some(format!("{scheme}://{authority}")) +} + +fn gateway_discovery_response( + authenticator: Option<&crate::auth::sandbox_jwt::SandboxJwtAuthenticator>, + base_url: &Option, +) -> axum::response::Response { + let Some(authenticator) = authenticator else { + return StatusCode::NOT_FOUND.into_response(); + }; + let Some(base_url) = base_url else { + // Without a usable authority the `jwks_uri` cannot be absolute, and a + // relative value would be worse than none: a verifier could resolve it + // against the wrong origin. + return StatusCode::BAD_REQUEST.into_response(); + }; + Json(serde_json::json!({ + "issuer": authenticator.issuer(), + "jwks_uri": format!("{base_url}/.well-known/jwks.json"), + "id_token_signing_alg_values_supported": ["EdDSA"], + "response_types_supported": ["id_token"], + "subject_types_supported": ["public"], + "scopes_supported": ["openid"], + "claims_supported": [ + "iss", "aud", "sub", "iat", "exp", "jti", "caller_kind", "sandbox_id" + ], + })) + .into_response() +} + +/// Publish the gateway's JWT verification key for extension services. +/// +/// Public keys are not secret. The HTTPS connection authenticates the +/// gateway from which an integration bootstraps this document; integrations +/// then cache keys by `kid` and refresh when an unfamiliar `kid` appears. +async fn gateway_jwks_handler(State(state): State>) -> impl IntoResponse { + gateway_jwks_response(state.sandbox_jwt_authenticator.as_deref()) +} + +fn gateway_jwks_response( + authenticator: Option<&crate::auth::sandbox_jwt::SandboxJwtAuthenticator>, +) -> axum::response::Response { + authenticator.map_or_else( + || StatusCode::NOT_FOUND.into_response(), + |authenticator| Json(authenticator.jwks()).into_response(), + ) +} + /// OIDC configuration discovery endpoint. /// /// Returns the OIDC issuer and audience when OIDC is configured on the server, @@ -474,6 +569,8 @@ fn render_waiting_page(callback_port: u16, code: &str) -> String { #[cfg(test)] mod tests { use super::*; + use axum::body::to_bytes; + use openshell_bootstrap::jwt::generate_jwt_key; #[test] fn extract_cookie_finds_value() { @@ -586,4 +683,109 @@ mod tests { assert_eq!(html_escape("a\"b"), "a"b"); assert_eq!(html_escape("a'b"), "a'b"); } + + #[test] + fn jwks_response_publishes_configured_gateway_key() { + let material = generate_jwt_key().expect("key"); + let authenticator = crate::auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem( + material.public_key_pem.as_bytes(), + material.kid.clone(), + "gateway-a", + ) + .expect("authenticator"); + + let response = gateway_jwks_response(Some(&authenticator)); + assert_eq!(response.status(), StatusCode::OK); + let key = &authenticator.jwks().keys[0]; + assert_eq!(key.kid, material.kid); + assert_eq!(key.kty, "OKP"); + assert_eq!(key.crv, "Ed25519"); + assert_eq!(key.alg, "EdDSA"); + assert_eq!(key.key_use, "sig"); + } + + #[test] + fn jwks_response_is_not_found_without_gateway_key() { + assert_eq!(gateway_jwks_response(None).status(), StatusCode::NOT_FOUND); + } + + fn test_authenticator() -> crate::auth::sandbox_jwt::SandboxJwtAuthenticator { + let material = generate_jwt_key().expect("key"); + crate::auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem( + material.public_key_pem.as_bytes(), + material.kid, + "gateway-a", + ) + .expect("authenticator") + } + + #[tokio::test] + async fn discovery_document_advertises_issuer_identity_and_absolute_jwks_uri() { + let authenticator = test_authenticator(); + let response = gateway_discovery_response( + Some(&authenticator), + &Some("https://gateway.example:8443".to_string()), + ); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("body"); + let document: serde_json::Value = serde_json::from_slice(&body).expect("json"); + + // The issuer is the gateway identity, not the serving URL. Extensions + // compare `iss` against this exact value. + assert_eq!(document["issuer"], "openshell-gateway:gateway-a"); + assert_eq!( + document["jwks_uri"], + "https://gateway.example:8443/.well-known/jwks.json" + ); + assert_eq!( + document["id_token_signing_alg_values_supported"][0], + "EdDSA" + ); + } + + #[test] + fn discovery_document_requires_a_key_and_a_resolvable_authority() { + assert_eq!( + gateway_discovery_response(None, &Some("https://gateway.example".to_string())).status(), + StatusCode::NOT_FOUND + ); + // A relative jwks_uri could be resolved against the wrong origin, so + // refuse to emit a document rather than emit an ambiguous one. + assert_eq!( + gateway_discovery_response(Some(&test_authenticator()), &None).status(), + StatusCode::BAD_REQUEST + ); + } + + #[test] + fn base_url_prefers_host_header_and_honours_forwarded_scheme() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "gateway.example:8443".parse().unwrap()); + assert_eq!( + document_base_url(&headers, &Uri::from_static("/")), + Some("https://gateway.example:8443".to_string()) + ); + + headers.insert("x-forwarded-proto", "http, https".parse().unwrap()); + assert_eq!( + document_base_url(&headers, &Uri::from_static("/")), + Some("http://gateway.example:8443".to_string()) + ); + + // HTTP/2 has no Host header; axum surfaces `:authority` on the URI. + assert_eq!( + document_base_url( + &HeaderMap::new(), + &Uri::from_static("https://h2.example/.well-known/openid-configuration") + ), + Some("https://h2.example".to_string()) + ); + assert_eq!( + document_base_url(&HeaderMap::new(), &Uri::from_static("/")), + None + ); + } } diff --git a/crates/openshell-server/src/auth/k8s_sa.rs b/crates/openshell-server/src/auth/k8s_sa.rs deleted file mode 100644 index eed0e5f083..0000000000 --- a/crates/openshell-server/src/auth/k8s_sa.rs +++ /dev/null @@ -1,980 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Kubernetes `ServiceAccount` bootstrap authenticator. -//! -//! Path-scoped to `IssueSandboxToken`. Validates a projected SA token -//! presented by a sandbox pod, reads the pod's `openshell.io/sandbox-id` -//! annotation, verifies the pod is controlled by the corresponding Sandbox CR, -//! and returns a [`Principal::Sandbox`] with -//! [`SandboxIdentitySource::K8sServiceAccount`]. The `IssueSandboxToken` handler -//! then mints a gateway-signed JWT for that sandbox id; subsequent gRPC calls -//! from the supervisor use the gateway-minted JWT validated by -//! [`super::sandbox_jwt::SandboxJwtAuthenticator`]. -//! -//! This is the only authenticator that talks to the K8s apiserver. It is -//! optional — the gateway boots without it in singleplayer deployments. - -use super::authenticator::Authenticator; -use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; -use async_trait::async_trait; -use k8s_openapi::api::{ - authentication::v1::{TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo}, - core::v1::Pod, -}; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; -use kube::Error as KubeError; -use kube::api::{Api, ApiResource, PostParams}; -use kube::core::{DynamicObject, gvk::GroupVersionKind}; -use std::sync::Arc; -use tonic::Status; -use tracing::{debug, info, warn}; - -/// gRPC method path that this authenticator accepts. All other paths fall -/// through (return `Ok(None)`) so a gateway-minted JWT is required there. -pub const ISSUE_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/IssueSandboxToken"; - -/// Pod annotation that binds a sandbox pod to its UUID. Set by the -/// Kubernetes compute driver at pod-create time. The gateway accepts this -/// annotation only after validating the pod's `TokenReview` binding, live UID, -/// and owning Sandbox CR. The K8s `Role` granted to the gateway must not -/// include `patch pods` (see plan §11.8). -pub const SANDBOX_ID_ANNOTATION: &str = "openshell.io/sandbox-id"; -const SANDBOX_API_GROUP: &str = "agents.x-k8s.io"; -const SANDBOX_API_VERSION_V1BETA1: &str = "v1beta1"; -const SANDBOX_API_VERSION_V1ALPHA1: &str = "v1alpha1"; -const SANDBOX_API_VERSION_FULL_V1BETA1: &str = "agents.x-k8s.io/v1beta1"; -const SANDBOX_API_VERSION_FULL_V1ALPHA1: &str = "agents.x-k8s.io/v1alpha1"; -const SANDBOX_KIND: &str = "Sandbox"; -const SANDBOX_ID_LABEL: &str = "openshell.ai/sandbox-id"; -const POD_NAME_EXTRA: &str = "authentication.kubernetes.io/pod-name"; -const POD_UID_EXTRA: &str = "authentication.kubernetes.io/pod-uid"; - -/// Resolved identity extracted from a validated SA token + pod lookup. -#[derive(Debug, Clone)] -pub struct ResolvedK8sIdentity { - pub sandbox_id: String, - pub pod_name: String, - pub pod_uid: String, -} - -/// Apiserver-facing operations the authenticator depends on. Split out so -/// tests can fake the apiserver without standing up a kube cluster. -#[async_trait] -pub trait K8sIdentityResolver: Send + Sync + 'static { - /// Validate `token` via `TokenReview` (`aud == openshell-gateway`), - /// extract the pod name/uid, then `GET` the pod and read - /// `openshell.io/sandbox-id`. Returns `Ok(None)` when the token is - /// well-formed but does not authenticate (e.g. wrong audience); returns - /// `Err` for transport/server errors. - async fn resolve(&self, token: &str) -> Result, Status>; -} - -/// Authenticator wrapper around a [`K8sIdentityResolver`]. -pub struct K8sServiceAccountAuthenticator { - resolver: Arc, -} - -impl std::fmt::Debug for K8sServiceAccountAuthenticator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("K8sServiceAccountAuthenticator") - .finish_non_exhaustive() - } -} - -impl K8sServiceAccountAuthenticator { - pub fn new(resolver: Arc) -> Self { - Self { resolver } - } -} - -#[async_trait] -impl Authenticator for K8sServiceAccountAuthenticator { - async fn authenticate( - &self, - headers: &http::HeaderMap, - path: &str, - ) -> Result, Status> { - // Scope: only the bootstrap RPC. Other paths fall through so the - // SandboxJwtAuthenticator (or OIDC) handles them. - if path != ISSUE_SANDBOX_TOKEN_PATH { - return Ok(None); - } - - let Some(token) = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - else { - return Ok(None); - }; - - let Some(resolved) = self.resolver.resolve(token).await? else { - debug!("K8s SA token did not authenticate; falling through"); - return Ok(None); - }; - - if resolved.sandbox_id.is_empty() { - warn!( - pod = %resolved.pod_name, - "pod missing openshell.io/sandbox-id annotation; rejecting" - ); - return Err(Status::permission_denied( - "pod is not bound to a sandbox identity", - )); - } - - Ok(Some(Principal::Sandbox(SandboxPrincipal { - sandbox_id: resolved.sandbox_id, - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: resolved.pod_name, - pod_uid: resolved.pod_uid, - }, - trust_domain: Some("openshell".to_string()), - }))) - } -} - -#[derive(Debug)] -struct TokenReviewIdentity { - pod_name: String, - pod_uid: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct SandboxOwnerReference { - api_version: String, - name: String, - uid: String, -} - -/// Resolver backed by the apiserver's `TokenReview` API and `kube::Client` -/// for the per-pod annotation lookup. -pub struct LiveK8sResolver { - token_reviews_api: Api, - pods_api: Api, - sandboxes_api_v1beta1: Api, - sandboxes_api_v1alpha1: Api, - expected_audience: String, - sandbox_namespace: String, - expected_service_account: String, -} - -impl LiveK8sResolver { - pub fn new( - client: kube::Client, - namespace: &str, - expected_audience: String, - expected_service_account: String, - ) -> Self { - let token_reviews_api: Api = Api::all(client.clone()); - let pods_api: Api = Api::namespaced(client.clone(), namespace); - let sandbox_gvk_v1beta1 = - GroupVersionKind::gvk(SANDBOX_API_GROUP, SANDBOX_API_VERSION_V1BETA1, SANDBOX_KIND); - let sandbox_resource_v1beta1 = ApiResource::from_gvk(&sandbox_gvk_v1beta1); - let sandbox_gvk_v1alpha1 = GroupVersionKind::gvk( - SANDBOX_API_GROUP, - SANDBOX_API_VERSION_V1ALPHA1, - SANDBOX_KIND, - ); - let sandbox_resource_v1alpha1 = ApiResource::from_gvk(&sandbox_gvk_v1alpha1); - let sandboxes_api_v1beta1: Api = - Api::namespaced_with(client.clone(), namespace, &sandbox_resource_v1beta1); - let sandboxes_api_v1alpha1: Api = - Api::namespaced_with(client, namespace, &sandbox_resource_v1alpha1); - Self { - token_reviews_api, - pods_api, - sandboxes_api_v1beta1, - sandboxes_api_v1alpha1, - expected_audience, - sandbox_namespace: namespace.to_string(), - expected_service_account, - } - } - - async fn get_sandbox_cr_for_owner( - &self, - owner: &SandboxOwnerReference, - ) -> Result, KubeError> { - let apis = if owner.api_version == SANDBOX_API_VERSION_FULL_V1ALPHA1 { - [&self.sandboxes_api_v1alpha1, &self.sandboxes_api_v1beta1] - } else { - [&self.sandboxes_api_v1beta1, &self.sandboxes_api_v1alpha1] - }; - - for api in apis { - match api.get_opt(&owner.name).await { - Ok(Some(sandbox_cr)) => return Ok(Some(sandbox_cr)), - Ok(None) => {} - Err(err) if should_try_next_sandbox_api_version(&err) => {} - Err(err) => return Err(err), - } - } - - Ok(None) - } -} - -#[async_trait] -impl K8sIdentityResolver for LiveK8sResolver { - async fn resolve(&self, token: &str) -> Result, Status> { - let review = TokenReview { - metadata: ObjectMeta::default(), - spec: TokenReviewSpec { - audiences: Some(vec![self.expected_audience.clone()]), - token: Some(token.to_string()), - }, - status: None, - }; - - let review = self - .token_reviews_api - .create(&PostParams::default(), &review) - .await - .map_err(|e| { - warn!(error = %e, "K8s TokenReview failed"); - Status::internal(format!("tokenreview failed: {e}")) - })?; - let status = review - .status - .ok_or_else(|| Status::internal("TokenReview response missing status"))?; - let Some(identity) = token_review_identity( - &status, - &self.expected_audience, - &self.sandbox_namespace, - &self.expected_service_account, - )? - else { - return Ok(None); - }; - - info!( - pod_name = %identity.pod_name, - pod_uid = %identity.pod_uid, - service_account = %self.expected_service_account, - "validated K8s SA token via TokenReview" - ); - - // Look up the pod and read its sandbox-id annotation. - let pod = self - .pods_api - .get_opt(&identity.pod_name) - .await - .map_err(|e| { - warn!( - pod = %identity.pod_name, - error = %e, - "failed to fetch sandbox pod for annotation lookup" - ); - Status::internal(format!("pod GET failed: {e}")) - })?; - let Some(pod) = pod else { - warn!( - pod = %identity.pod_name, - "sandbox pod referenced by SA token not found in this namespace" - ); - return Err(Status::not_found("sandbox pod not found")); - }; - - // Defense-in-depth: confirm the pod UID matches the SA token's - // `kubernetes.io.pod.uid`. Prevents a replayed token from a - // recreated pod with the same name. - let actual_uid = pod.metadata.uid.as_deref().unwrap_or_default(); - if actual_uid != identity.pod_uid { - warn!( - pod = %identity.pod_name, - claimed_uid = %identity.pod_uid, - actual_uid = %actual_uid, - "SA token pod UID does not match live pod; rejecting" - ); - return Err(Status::permission_denied("SA token pod UID mismatch")); - } - - let sandbox_id = pod_sandbox_id(&pod)?; - - let owner = sandbox_owner_reference(&pod)?; - let sandbox_cr = self.get_sandbox_cr_for_owner(&owner).await.map_err(|e| { - warn!( - pod = %identity.pod_name, - sandbox_owner = %owner.name, - sandbox_owner_api_version = %owner.api_version, - error = %e, - "failed to fetch owning Sandbox CR for pod identity validation" - ); - Status::internal(format!("sandbox GET failed: {e}")) - })?; - let Some(sandbox_cr) = sandbox_cr else { - warn!( - pod = %identity.pod_name, - sandbox_owner = %owner.name, - sandbox_owner_api_version = %owner.api_version, - "pod ownerReference points to a Sandbox CR that does not exist" - ); - return Err(Status::permission_denied("sandbox owner not found")); - }; - validate_sandbox_owner_reference(&owner, &sandbox_id, &sandbox_cr)?; - - Ok(Some(ResolvedK8sIdentity { - sandbox_id, - pod_name: identity.pod_name, - pod_uid: identity.pod_uid, - })) - } -} - -#[allow(clippy::result_large_err)] -fn token_review_identity( - status: &TokenReviewStatus, - expected_audience: &str, - sandbox_namespace: &str, - expected_service_account: &str, -) -> Result, Status> { - if status.authenticated != Some(true) { - debug!( - error = status.error.as_deref().unwrap_or_default(), - "K8s TokenReview did not authenticate token" - ); - return Ok(None); - } - - let audiences = status.audiences.as_deref().unwrap_or_default(); - if !audiences.iter().any(|aud| aud == expected_audience) { - warn!( - expected_audience = %expected_audience, - audiences = ?audiences, - "K8s TokenReview authenticated token without expected audience" - ); - return Err(Status::unauthenticated("SA token audience not accepted")); - } - - let user = status - .user - .as_ref() - .ok_or_else(|| Status::permission_denied("TokenReview response missing user info"))?; - let username = user - .username - .as_deref() - .ok_or_else(|| Status::permission_denied("TokenReview response missing username"))?; - let expected_username = - format!("system:serviceaccount:{sandbox_namespace}:{expected_service_account}"); - if username != expected_username { - warn!( - username = %username, - sandbox_namespace = %sandbox_namespace, - service_account = %expected_service_account, - "K8s TokenReview principal is not the configured sandbox service account" - ); - return Err(Status::permission_denied( - "SA token is not from the configured sandbox service account", - )); - } - - let pod_name = user_extra_one(user, POD_NAME_EXTRA)?; - let pod_uid = user_extra_one(user, POD_UID_EXTRA)?; - Ok(Some(TokenReviewIdentity { pod_name, pod_uid })) -} - -#[allow(clippy::result_large_err)] -fn user_extra_one(user: &UserInfo, key: &str) -> Result { - let Some(values) = user.extra.as_ref().and_then(|extra| extra.get(key)) else { - return Err(Status::permission_denied("SA token is not pod-bound")); - }; - if values.len() != 1 || values[0].is_empty() { - return Err(Status::permission_denied( - "SA token has invalid pod binding", - )); - } - Ok(values[0].clone()) -} - -#[allow(clippy::result_large_err)] -fn pod_sandbox_id(pod: &Pod) -> Result { - let sandbox_id = pod - .metadata - .annotations - .as_ref() - .and_then(|a| a.get(SANDBOX_ID_ANNOTATION)) - .cloned() - .unwrap_or_default(); - if sandbox_id.is_empty() { - return Err(Status::permission_denied( - "pod is not bound to a sandbox identity", - )); - } - Ok(sandbox_id) -} - -#[allow(clippy::result_large_err)] -fn sandbox_owner_reference(pod: &Pod) -> Result { - let owner_refs = pod.metadata.owner_references.as_deref().unwrap_or_default(); - let mut sandbox_refs = owner_refs - .iter() - .filter(|owner| is_supported_sandbox_owner_reference(owner)); - let Some(owner) = sandbox_refs.next() else { - let unsupported_sandbox_api_versions = owner_refs - .iter() - .filter(|owner| owner.kind == SANDBOX_KIND) - .map(|owner| owner.api_version.as_str()) - .collect::>(); - if !unsupported_sandbox_api_versions.is_empty() { - warn!( - api_versions = ?unsupported_sandbox_api_versions, - supported_api_versions = ?[ - SANDBOX_API_VERSION_FULL_V1BETA1, - SANDBOX_API_VERSION_FULL_V1ALPHA1, - ], - "pod Sandbox ownerReference uses unsupported apiVersion" - ); - } - return Err(Status::permission_denied( - "pod is not controlled by an OpenShell Sandbox", - )); - }; - if sandbox_refs.next().is_some() { - return Err(Status::permission_denied( - "pod has multiple OpenShell Sandbox owners", - )); - } - if owner.controller != Some(true) { - return Err(Status::permission_denied( - "pod Sandbox ownerReference is not controlling", - )); - } - if owner.name.is_empty() || owner.uid.is_empty() { - return Err(Status::permission_denied( - "pod Sandbox ownerReference is incomplete", - )); - } - Ok(SandboxOwnerReference { - api_version: owner.api_version.clone(), - name: owner.name.clone(), - uid: owner.uid.clone(), - }) -} - -fn is_supported_sandbox_owner_reference( - owner: &k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference, -) -> bool { - owner.kind == SANDBOX_KIND - && matches!( - owner.api_version.as_str(), - SANDBOX_API_VERSION_FULL_V1BETA1 | SANDBOX_API_VERSION_FULL_V1ALPHA1 - ) -} - -fn should_try_next_sandbox_api_version(err: &KubeError) -> bool { - // Kubernetes returns a structured 404 for some missing API resources and a - // raw "404 page not found" body for others. Both mean the probed - // group/version is unavailable and the next supported Sandbox API version - // should be tried. - matches!(err, KubeError::Api(api) if api.code == 404) -} - -#[allow(clippy::result_large_err)] -fn validate_sandbox_owner_reference( - owner: &SandboxOwnerReference, - sandbox_id: &str, - sandbox_cr: &DynamicObject, -) -> Result<(), Status> { - let actual_uid = sandbox_cr.metadata.uid.as_deref().unwrap_or_default(); - if actual_uid != owner.uid { - warn!( - sandbox_owner = %owner.name, - owner_uid = %owner.uid, - actual_uid = %actual_uid, - "pod Sandbox ownerReference UID does not match live Sandbox CR" - ); - return Err(Status::permission_denied("sandbox owner UID mismatch")); - } - - let actual_sandbox_id = sandbox_cr - .metadata - .labels - .as_ref() - .and_then(|labels| labels.get(SANDBOX_ID_LABEL)) - .map(String::as_str) - .unwrap_or_default(); - if actual_sandbox_id != sandbox_id { - warn!( - sandbox_owner = %owner.name, - owner_uid = %owner.uid, - pod_sandbox_id = %sandbox_id, - cr_sandbox_id = %actual_sandbox_id, - "pod sandbox annotation does not match owning Sandbox CR label" - ); - return Err(Status::permission_denied("sandbox owner ID mismatch")); - } - - Ok(()) -} - -#[cfg(test)] -pub mod test_support { - use super::*; - use std::sync::Mutex; - - /// Fake resolver for unit tests. Returns the configured outcome on - /// every call and records the tokens it observed. - pub struct FakeResolver { - pub outcome: Result, Status>, - pub seen_tokens: Mutex>, - } - - impl FakeResolver { - pub fn returning(outcome: Result, Status>) -> Self { - Self { - outcome, - seen_tokens: Mutex::new(Vec::new()), - } - } - } - - #[async_trait] - impl K8sIdentityResolver for FakeResolver { - async fn resolve(&self, token: &str) -> Result, Status> { - self.seen_tokens.lock().unwrap().push(token.to_string()); - match &self.outcome { - Ok(opt) => Ok(opt.clone()), - Err(s) => Err(Status::new(s.code(), s.message())), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::test_support::FakeResolver; - use super::*; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; - use std::collections::BTreeMap; - - fn bearer_headers(token: &str) -> http::HeaderMap { - let mut h = http::HeaderMap::new(); - h.insert( - "authorization", - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ); - h - } - - fn kube_api_error(code: u16, message: &str) -> KubeError { - KubeError::Api(kube::core::ErrorResponse { - status: if code == 404 { - "404 Not Found".to_string() - } else { - "Failure".to_string() - }, - message: message.to_string(), - reason: "Failed to parse error data".to_string(), - code, - }) - } - - #[test] - fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { - let structured = kube_api_error(404, "could not find the requested resource"); - assert!(should_try_next_sandbox_api_version(&structured)); - - let raw = kube_api_error(404, "404 page not found\n"); - assert!(should_try_next_sandbox_api_version(&raw)); - } - - #[test] - fn sandbox_api_version_probe_keeps_non_404_errors() { - let err = kube_api_error(403, "sandboxes.agents.x-k8s.io is forbidden"); - assert!(!should_try_next_sandbox_api_version(&err)); - } - - fn token_review_status( - authenticated: bool, - audiences: Vec<&str>, - username: &str, - extra: Vec<(&str, &str)>, - ) -> TokenReviewStatus { - TokenReviewStatus { - authenticated: Some(authenticated), - audiences: Some(audiences.into_iter().map(str::to_string).collect()), - error: None, - user: Some(UserInfo { - username: Some(username.to_string()), - uid: Some("sa-uid".to_string()), - groups: Some(vec![ - "system:serviceaccounts".to_string(), - "system:serviceaccounts:openshell".to_string(), - "system:authenticated".to_string(), - ]), - extra: Some( - extra - .into_iter() - .map(|(k, v)| (k.to_string(), vec![v.to_string()])) - .collect::>(), - ), - }), - } - } - - fn sandbox_owner(name: &str, uid: &str) -> OwnerReference { - sandbox_owner_with_api_version(SANDBOX_API_VERSION_FULL_V1BETA1, name, uid) - } - - fn sandbox_owner_with_api_version(api_version: &str, name: &str, uid: &str) -> OwnerReference { - OwnerReference { - api_version: api_version.to_string(), - block_owner_deletion: None, - controller: Some(true), - kind: SANDBOX_KIND.to_string(), - name: name.to_string(), - uid: uid.to_string(), - } - } - - fn pod_with_owner_refs(owner_references: Vec) -> Pod { - Pod { - metadata: ObjectMeta { - owner_references: Some(owner_references), - ..Default::default() - }, - ..Default::default() - } - } - - fn pod_with_sandbox_id(sandbox_id: Option<&str>) -> Pod { - Pod { - metadata: ObjectMeta { - annotations: sandbox_id.map(|id| { - BTreeMap::from([(SANDBOX_ID_ANNOTATION.to_string(), id.to_string())]) - }), - ..Default::default() - }, - ..Default::default() - } - } - - fn sandbox_cr(name: &str, uid: &str, sandbox_id: &str) -> DynamicObject { - let sandbox_gvk = - GroupVersionKind::gvk(SANDBOX_API_GROUP, SANDBOX_API_VERSION_V1BETA1, SANDBOX_KIND); - let sandbox_resource = ApiResource::from_gvk(&sandbox_gvk); - let mut cr = DynamicObject::new(name, &sandbox_resource); - cr.metadata.uid = Some(uid.to_string()); - cr.metadata.labels = Some(BTreeMap::from([( - SANDBOX_ID_LABEL.to_string(), - sandbox_id.to_string(), - )])); - cr - } - - #[test] - fn token_review_identity_extracts_pod_binding() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:openshell:default", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - - let identity = token_review_identity(&status, "openshell-gateway", "openshell", "default") - .unwrap() - .expect("authenticated token should resolve"); - - assert_eq!(identity.pod_name, "openshell-sandbox-a"); - assert_eq!(identity.pod_uid, "uid-a"); - } - - #[test] - fn token_review_identity_returns_none_when_not_authenticated() { - let status = TokenReviewStatus { - authenticated: Some(false), - error: Some("invalid audience".to_string()), - ..Default::default() - }; - - assert!( - token_review_identity(&status, "openshell-gateway", "openshell", "default") - .unwrap() - .is_none() - ); - } - - #[test] - fn token_review_identity_requires_expected_audience() { - let status = token_review_status( - true, - vec!["kubernetes.default.svc"], - "system:serviceaccount:openshell:default", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - - let err = token_review_identity(&status, "openshell-gateway", "openshell", "default") - .expect_err("wrong audience must fail closed"); - assert_eq!(err.code(), tonic::Code::Unauthenticated); - } - - #[test] - fn token_review_identity_requires_sandbox_namespace() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:other:default", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - - let err = token_review_identity(&status, "openshell-gateway", "openshell", "default") - .expect_err("other namespace must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn token_review_identity_requires_configured_service_account() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:openshell:other", - vec![ - (POD_NAME_EXTRA, "openshell-sandbox-a"), - (POD_UID_EXTRA, "uid-a"), - ], - ); - - let err = token_review_identity(&status, "openshell-gateway", "openshell", "default") - .expect_err("other service account must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn token_review_identity_requires_pod_bound_extras() { - let status = token_review_status( - true, - vec!["openshell-gateway"], - "system:serviceaccount:openshell:default", - vec![], - ); - - let err = token_review_identity(&status, "openshell-gateway", "openshell", "default") - .expect_err("non pod-bound tokens must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn pod_sandbox_id_requires_annotation() { - assert_eq!( - pod_sandbox_id(&pod_with_sandbox_id(Some("sandbox-id-a"))).unwrap(), - "sandbox-id-a" - ); - - let err = pod_sandbox_id(&pod_with_sandbox_id(None)) - .expect_err("missing sandbox-id annotation must fail"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_extracts_controlling_sandbox_owner() { - let pod = pod_with_owner_refs(vec![sandbox_owner("sandbox-a", "cr-uid-a")]); - - let owner = sandbox_owner_reference(&pod).expect("expected Sandbox owner"); - - assert_eq!( - owner, - SandboxOwnerReference { - api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(), - name: "sandbox-a".to_string(), - uid: "cr-uid-a".to_string(), - } - ); - } - - #[test] - fn sandbox_owner_reference_accepts_v1alpha1_owner() { - let pod = pod_with_owner_refs(vec![sandbox_owner_with_api_version( - SANDBOX_API_VERSION_FULL_V1ALPHA1, - "sandbox-a", - "cr-uid-a", - )]); - - let owner = sandbox_owner_reference(&pod).expect("expected v1alpha1 Sandbox owner"); - - assert_eq!( - owner, - SandboxOwnerReference { - api_version: SANDBOX_API_VERSION_FULL_V1ALPHA1.to_string(), - name: "sandbox-a".to_string(), - uid: "cr-uid-a".to_string(), - } - ); - } - - #[test] - fn sandbox_owner_reference_rejects_missing_owner() { - let pod = pod_with_owner_refs(vec![]); - - let err = sandbox_owner_reference(&pod).expect_err("missing owner must fail"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_rejects_unsupported_sandbox_api_version() { - let pod = pod_with_owner_refs(vec![sandbox_owner_with_api_version( - "agents.x-k8s.io/v1", - "sandbox-a", - "cr-uid-a", - )]); - - let err = - sandbox_owner_reference(&pod).expect_err("unsupported apiVersion must fail closed"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_requires_controlling_owner() { - let mut owner = sandbox_owner("sandbox-a", "cr-uid-a"); - owner.controller = Some(false); - let pod = pod_with_owner_refs(vec![owner]); - - let err = sandbox_owner_reference(&pod).expect_err("non-controller owner must fail"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn sandbox_owner_reference_rejects_ambiguous_sandbox_owners() { - let pod = pod_with_owner_refs(vec![ - sandbox_owner("sandbox-a", "cr-uid-a"), - sandbox_owner("sandbox-b", "cr-uid-b"), - ]); - - let err = sandbox_owner_reference(&pod).expect_err("multiple owners must fail"); - - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[test] - fn validate_sandbox_owner_reference_requires_matching_cr_uid_and_label() { - let owner = SandboxOwnerReference { - api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(), - name: "sandbox-a".to_string(), - uid: "cr-uid-a".to_string(), - }; - let cr = sandbox_cr("sandbox-a", "cr-uid-a", "sandbox-id-a"); - validate_sandbox_owner_reference(&owner, "sandbox-id-a", &cr) - .expect("matching CR should be accepted"); - - let wrong_uid = sandbox_cr("sandbox-a", "cr-uid-b", "sandbox-id-a"); - let err = validate_sandbox_owner_reference(&owner, "sandbox-id-a", &wrong_uid) - .expect_err("wrong CR UID must fail"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - - let wrong_label = sandbox_cr("sandbox-a", "cr-uid-a", "sandbox-id-b"); - let err = validate_sandbox_owner_reference(&owner, "sandbox-id-a", &wrong_label) - .expect_err("wrong sandbox-id label must fail"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[tokio::test] - async fn authenticates_on_issue_path_only() { - let resolved = ResolvedK8sIdentity { - sandbox_id: "sandbox-a".to_string(), - pod_name: "openshell-sandbox-a".to_string(), - pod_uid: "uid-a".to_string(), - }; - let fake = Arc::new(FakeResolver::returning(Ok(Some(resolved)))); - let auth = K8sServiceAccountAuthenticator::new(fake.clone()); - - let on_issue = auth - .authenticate(&bearer_headers("sa-jwt"), ISSUE_SANDBOX_TOKEN_PATH) - .await - .unwrap() - .expect("expected principal"); - match on_issue { - Principal::Sandbox(p) => { - assert_eq!(p.sandbox_id, "sandbox-a"); - assert!(matches!( - p.source, - SandboxIdentitySource::K8sServiceAccount { .. } - )); - } - _ => panic!("expected sandbox principal"), - } - - let off_issue = auth - .authenticate( - &bearer_headers("sa-jwt"), - "/openshell.v1.OpenShell/GetSandboxConfig", - ) - .await - .unwrap(); - assert!( - off_issue.is_none(), - "K8s SA authenticator must be scoped to IssueSandboxToken" - ); - assert_eq!( - fake.seen_tokens.lock().unwrap().len(), - 1, - "off-path call must not consult the apiserver" - ); - } - - #[tokio::test] - async fn missing_bearer_yields_none() { - let fake = Arc::new(FakeResolver::returning(Ok(None))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let result = auth - .authenticate(&http::HeaderMap::new(), ISSUE_SANDBOX_TOKEN_PATH) - .await - .unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn resolver_returning_none_falls_through() { - let fake = Arc::new(FakeResolver::returning(Ok(None))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let result = auth - .authenticate( - &bearer_headers("not-a-real-sa-token"), - ISSUE_SANDBOX_TOKEN_PATH, - ) - .await - .unwrap(); - assert!(result.is_none(), "non-authenticating tokens fall through"); - } - - #[tokio::test] - async fn pod_without_annotation_is_rejected() { - let resolved = ResolvedK8sIdentity { - sandbox_id: String::new(), - pod_name: "stray-pod".to_string(), - pod_uid: "uid".to_string(), - }; - let fake = Arc::new(FakeResolver::returning(Ok(Some(resolved)))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let err = auth - .authenticate(&bearer_headers("sa-jwt"), ISSUE_SANDBOX_TOKEN_PATH) - .await - .expect_err("unbound pod must be rejected"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - } - - #[tokio::test] - async fn resolver_error_propagates() { - let fake = Arc::new(FakeResolver::returning(Err(Status::unavailable( - "apiserver down", - )))); - let auth = K8sServiceAccountAuthenticator::new(fake); - let err = auth - .authenticate(&bearer_headers("sa-jwt"), ISSUE_SANDBOX_TOKEN_PATH) - .await - .expect_err("resolver error must propagate"); - assert_eq!(err.code(), tonic::Code::Unavailable); - } -} diff --git a/crates/openshell-server/src/auth/method_authz.rs b/crates/openshell-server/src/auth/method_authz.rs index c06ca09f69..71eb7acac6 100644 --- a/crates/openshell-server/src/auth/method_authz.rs +++ b/crates/openshell-server/src/auth/method_authz.rs @@ -148,4 +148,18 @@ mod tests { // Unknown method falls through to AuthzPolicy::check. assert!(is_user_callable("/openshell.v1.OpenShell/FutureMethod")); } + + #[test] + fn sandbox_lifecycle_mutations_require_user_write_authority() { + for path in [ + "/openshell.v1.OpenShell/StopSandbox", + "/openshell.v1.OpenShell/StartSandbox", + ] { + let entry = lookup(path).expect("lifecycle RPC must have auth metadata"); + assert_eq!(entry.auth_mode, AuthMode::Bearer); + assert_eq!(entry.scope.as_deref(), Some("sandbox:write")); + assert_eq!(entry.workspace_role.as_deref(), Some("user")); + assert!(!is_sandbox_callable(path)); + } + } } diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index c26fac08ad..b39ff7bfa9 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -10,11 +10,12 @@ pub mod authenticator; pub mod authz; +pub mod compute_driver; pub mod descriptor_authz; +pub mod extension_mint_limit; pub mod guard; mod http; pub mod identity; -pub mod k8s_sa; pub mod method_authz; pub mod oidc; pub mod principal; diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index cbe83ff060..d50ad32f3f 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -18,12 +18,13 @@ use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; use openshell_core::OidcConfig; use reqwest::Client; use serde::Deserialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use tonic::Status; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; /// Path prefixes that bypass OIDC validation (gRPC reflection, health probes). /// @@ -40,16 +41,34 @@ pub fn is_unauthenticated_method(path: &str) -> bool { .any(|prefix| path.starts_with(prefix)) } +/// How long previously-cached keys stay trusted after a refresh starts +/// returning zero usable keys, expressed as a multiple of the configured +/// TTL. Bounds exposure to keys the issuer may have deliberately revoked, +/// while still tolerating a transient empty or bad JWKS response. +const STALE_KEY_GRACE_MULTIPLIER: u32 = 3; + +/// Minimum interval between kid-miss-triggered refreshes. Bounds how much +/// a burst or steady stream of unknown-`kid` lookups can amplify requests +/// against the issuer's JWKS endpoint. +const KID_MISS_REFRESH_COOLDOWN: Duration = Duration::from_secs(1); + /// Cached JWKS key set fetched from the OIDC issuer. /// /// A `refresh_mutex` ensures that only one refresh runs at a time, /// preventing a "thundering herd" when the TTL expires or a new `kid` /// is encountered under concurrent load. pub struct JwksCache { - keys: Arc>>, + keys: Arc>>, jwks_uri: String, ttl: Duration, last_refresh: Arc>, + /// Timestamp of the last refresh that yielded at least one usable key. + /// Bounds how long a fully-empty or fully-unusable JWKS response can + /// keep serving stale cached keys before they're evicted. + last_nonempty_refresh: Arc>, + /// Timestamp of the last refresh triggered by an unknown `kid`, as + /// opposed to the regular TTL cadence. See `KID_MISS_REFRESH_COOLDOWN`. + last_kid_miss_refresh: Arc>, /// Serializes JWKS refresh operations so concurrent requests coalesce /// into a single HTTP fetch rather than stampeding the OIDC provider. refresh_mutex: tokio::sync::Mutex<()>, @@ -88,6 +107,218 @@ struct JwkKey { n: String, #[serde(default)] e: String, + #[serde(default)] + x: String, + #[serde(default)] + y: String, + #[serde(default)] + crv: String, + #[serde(default)] + alg: Option, + #[serde(rename = "use", default)] + use_: Option, + #[serde(default)] + key_ops: Vec, +} + +enum SkipReason { + MissingKid, + MissingComponents, + UnsupportedKeyType { + kty: String, + crv: Option, + }, + ParseError(String), + AlgMismatch { + declared: String, + derived: Algorithm, + }, + UseEnc, + UseNotSig, + KeyOpsNotForVerify, +} + +/// The RSA signature algorithms `jsonwebtoken` supports. Used to decide +/// whether a JWK's declared `alg` is a legitimate RSA algorithm selection +/// (see the RSA arm of `parse_jwk`) as opposed to an absent, unparseable, or +/// non-RSA value. +fn is_rsa_algorithm(algorithm: Algorithm) -> bool { + matches!( + algorithm, + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 + ) +} + +/// Log why a JWK was excluded from the cache, with the fields relevant to +/// diagnosing that specific `SkipReason`. +fn warn_skip(key: &JwkKey, reason: SkipReason) { + match reason { + SkipReason::MissingKid => { + warn!(kty = %key.kty, crv = %key.crv, "Skipping JWK without kid"); + } + SkipReason::MissingComponents => { + warn!( + kid = ?key.kid, + kty = %key.kty, + crv = %key.crv, + "Skipping JWK with missing key components" + ); + } + SkipReason::UnsupportedKeyType { kty, crv } => { + warn!(kid = ?key.kid, kty = %kty, crv = ?crv, "Skipping unsupported JWK key type"); + } + SkipReason::ParseError(error) => { + warn!(kid = ?key.kid, kty = %key.kty, error = %error, "Failed to parse JWK"); + } + SkipReason::AlgMismatch { declared, derived } => { + warn!( + kid = ?key.kid, + kty = %key.kty, + declared_alg = %declared, + derived_alg = ?derived, + "Skipping JWK with alg mismatch" + ); + } + SkipReason::UseEnc => { + warn!(kid = ?key.kid, kty = %key.kty, "Skipping encryption JWK (use: enc)"); + } + SkipReason::UseNotSig => { + warn!( + kid = ?key.kid, + kty = %key.kty, + use_ = ?key.use_, + "Skipping JWK with use not suitable for verify" + ); + } + SkipReason::KeyOpsNotForVerify => { + warn!(kid = ?key.kid, kty = %key.kty, "Skipping JWK with key_ops not suitable for verify"); + } + } +} + +/// Validate and decode a single JWK, returning its `kid`, decoding key, and +/// pinned algorithm, or the reason it must be excluded from the cache. +fn parse_jwk(key: &JwkKey) -> Result<(String, DecodingKey, Algorithm), SkipReason> { + // Must run before any early return below, not just before the + // `DecodingKey::from_*` calls further down: a JWKS response whose keys + // are all skipped by these checks would otherwise leave the process-wide + // crypto provider never installed on this path, and a caller reaching + // its own first `jsonwebtoken` operation without having installed one + // itself would panic. + crate::install_jsonwebtoken_crypto_provider(); + + // Subsumed by the `use_ != "sig"` check below (`"enc" != "sig"`); kept + // separate only to produce a more specific log line for the common + // `use: enc` case. + if key.use_.as_deref() == Some("enc") { + return Err(SkipReason::UseEnc); + } + if let Some(use_) = key.use_.as_deref().filter(|u| !u.is_empty()) + && use_ != "sig" + { + return Err(SkipReason::UseNotSig); + } + + // The gateway only ever verifies signatures, never creates them, so a + // key advertising `key_ops` must include "verify" — mirroring the + // strictness of the `use: "sig"` check above. `["sign"]` alone is + // semantically incoherent for a public verification key (RFC 7517 §4.3 + // treats "sign" and "verify" as distinct operations). + if !key.key_ops.is_empty() && !key.key_ops.iter().any(|op| op == "verify") { + return Err(SkipReason::KeyOpsNotForVerify); + } + + let kid = key.kid.as_deref().filter(|k| !k.is_empty()); + let Some(kid) = kid else { + return Err(SkipReason::MissingKid); + }; + + let (decoding_key, algorithm) = match key.kty.as_str() { + "RSA" => { + if key.n.is_empty() || key.e.is_empty() { + return Err(SkipReason::MissingComponents); + } + let dk = DecodingKey::from_rsa_components(&key.n, &key.e) + .map_err(|e| SkipReason::ParseError(e.to_string()))?; + // Select the algorithm from the JWK's declared `alg` when it + // names one of the RSA family; default to RS256 otherwise (most + // issuers, e.g. Microsoft Entra ID, omit `alg` entirely). The + // mismatch check below still rejects genuinely contradictory + // declarations (e.g. an RSA key declaring "ES256"). + let algorithm = key + .alg + .as_deref() + .and_then(|declared| Algorithm::from_str(declared).ok()) + .filter(|declared| is_rsa_algorithm(*declared)) + .unwrap_or(Algorithm::RS256); + (dk, algorithm) + } + "EC" => { + if key.x.is_empty() || key.y.is_empty() || key.crv.is_empty() { + return Err(SkipReason::MissingComponents); + } + let algorithm = match key.crv.as_str() { + "P-256" => Algorithm::ES256, + "P-384" => Algorithm::ES384, + _ => { + return Err(SkipReason::UnsupportedKeyType { + kty: key.kty.clone(), + crv: Some(key.crv.clone()), + }); + } + }; + let dk = DecodingKey::from_ec_components(&key.x, &key.y) + .map_err(|e| SkipReason::ParseError(e.to_string()))?; + (dk, algorithm) + } + "OKP" => { + if key.x.is_empty() || key.crv.is_empty() { + return Err(SkipReason::MissingComponents); + } + if key.crv != "Ed25519" { + return Err(SkipReason::UnsupportedKeyType { + kty: key.kty.clone(), + crv: Some(key.crv.clone()), + }); + } + let dk = DecodingKey::from_ed_components(&key.x) + .map_err(|e| SkipReason::ParseError(e.to_string()))?; + (dk, Algorithm::EdDSA) + } + other => { + return Err(SkipReason::UnsupportedKeyType { + kty: other.to_string(), + crv: if key.crv.is_empty() { + None + } else { + Some(key.crv.clone()) + }, + }); + } + }; + + if let Some(ref declared) = key.alg { + match Algorithm::from_str(declared) { + Ok(parsed) if parsed == algorithm => {} + // Either the declared algorithm genuinely contradicts the + // derived one, or it's an unrecognized string — in both cases + // we can't be sure the key is safe to use, so skip it rather + // than silently accepting our own default. + _ => { + return Err(SkipReason::AlgMismatch { + declared: declared.clone(), + derived: algorithm, + }); + } + } + } + + Ok((kid.to_string(), decoding_key, algorithm)) } /// Claims extracted from a validated JWT. @@ -168,6 +399,14 @@ impl JwksCache { /// Create a new JWKS cache, discovering the JWKS URI and fetching the /// initial key set. pub async fn new(config: &OidcConfig) -> Result { + if config.jwks_ttl_secs == 0 { + return Err( + "jwks_ttl_secs must be greater than zero (0 would refresh on every request and \ + collapse the stale-key grace period to zero)" + .to_string(), + ); + } + let http = Client::builder() .timeout(Duration::from_secs(10)) .build() @@ -209,6 +448,18 @@ impl JwksCache { .checked_sub(Duration::from_secs(config.jwks_ttl_secs + 1)) .unwrap_or_else(Instant::now), )), + // Placeholder value: the grace-period check is only reachable + // once `self.keys` has been non-empty, at which point this + // field always holds a freshly-stamped real value instead (see + // `refresh_keys`), so the value here is never actually read. + last_nonempty_refresh: Arc::new(RwLock::new(Instant::now())), + // Backdated beyond the cooldown so the very first kid-miss + // refresh is never throttled. + last_kid_miss_refresh: Arc::new(RwLock::new( + Instant::now() + .checked_sub(KID_MISS_REFRESH_COOLDOWN + Duration::from_secs(1)) + .unwrap_or_else(Instant::now), + )), refresh_mutex: tokio::sync::Mutex::new(()), http, config: config.clone(), @@ -233,25 +484,76 @@ impl JwksCache { .map_err(|e| format!("JWKS parse failed: {e}"))?; let mut new_keys = HashMap::new(); + let mut poisoned_kids = HashSet::new(); + let total = jwk_set.keys.len(); + for key in &jwk_set.keys { - if key.kty != "RSA" { + if let Some(kid) = key.kid.as_ref().filter(|k| !k.is_empty()) + && poisoned_kids.contains(kid) + { continue; } - let Some(ref kid) = key.kid else { - continue; - }; - match DecodingKey::from_rsa_components(&key.n, &key.e) { - Ok(dk) => { - new_keys.insert(kid.clone(), dk); - } - Err(e) => { - warn!(kid = %kid, error = %e, "Failed to parse JWK"); + + match parse_jwk(key) { + Ok((kid, dk, alg)) => { + if let Some((_, existing_alg)) = new_keys.get(&kid) { + if *existing_alg == alg { + warn!( + kid = %kid, + algorithm = ?alg, + "Duplicate JWK kid, keeping first entry" + ); + continue; + } + let existing_alg = *existing_alg; + new_keys.remove(&kid); + poisoned_kids.insert(kid.clone()); + warn!( + kid = %kid, + existing_alg = ?existing_alg, + conflicting_alg = ?alg, + "Duplicate JWK kid with conflicting algorithms, poison-pilling kid" + ); + continue; + } + new_keys.insert(kid, (dk, alg)); } + Err(reason) => warn_skip(key, reason), } } - info!(count = new_keys.len(), "JWKS keys loaded"); - *self.keys.write().await = new_keys; + if new_keys.is_empty() { + // A refresh with zero usable keys must not immediately wipe a + // working cache, since that would turn a degraded issuer into a + // total outage. Keys stay trusted until a later refresh + // succeeds or the grace period expires. With no existing keys + // to fall back to (the first load, or after eviction), the + // empty set is committed immediately. + let grace = self.ttl.saturating_mul(STALE_KEY_GRACE_MULTIPLIER); + if self.keys.read().await.is_empty() { + error!( + total, + "JWKS has zero usable signing keys and no previous keys to fall back to" + ); + *self.keys.write().await = new_keys; + } else if self.last_nonempty_refresh.read().await.elapsed() > grace { + error!( + total, + grace_secs = grace.as_secs(), + "JWKS has had zero usable signing keys past the grace period; evicting cached keys" + ); + *self.keys.write().await = new_keys; + } else { + error!( + total, + "JWKS refresh loaded zero usable signing keys; keeping previous key set within grace period" + ); + } + } else { + info!(count = new_keys.len(), "JWKS keys loaded"); + *self.keys.write().await = new_keys; + *self.last_nonempty_refresh.write().await = Instant::now(); + } *self.last_refresh.write().await = Instant::now(); Ok(()) } @@ -275,9 +577,20 @@ impl JwksCache { self.refresh_keys().await } - /// Refresh keys unconditionally, coalescing concurrent callers. + /// Refresh keys on a kid-miss lookup, coalescing concurrent callers and + /// throttling how often such refreshes may hit the issuer. + /// + /// Without a cooldown, a caller presenting an unknown or fast-rotating + /// `kid` could force a fresh HTTP fetch on every single request. Only + /// the first kid-miss refresh within `KID_MISS_REFRESH_COOLDOWN` does + /// real work; the rest reuse its result. async fn refresh_keys_coalesced(&self) -> Result<(), String> { let _guard = self.refresh_mutex.lock().await; + let last = *self.last_kid_miss_refresh.read().await; + if last.elapsed() < KID_MISS_REFRESH_COOLDOWN { + return Ok(()); + } + *self.last_kid_miss_refresh.write().await = Instant::now(); self.refresh_keys().await } @@ -286,6 +599,8 @@ impl JwksCache { /// This is the authentication step — it verifies the caller's identity /// but does not check authorization (that's `authz::AuthzPolicy::check`). pub async fn validate_token(&self, token: &str) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + self.refresh_if_stale().await.map_err(|e| { warn!(error = %e, "JWKS refresh failed"); Status::internal("OIDC key refresh failed") @@ -302,28 +617,39 @@ impl JwksCache { Status::unauthenticated("invalid token: missing kid") })?; - // Look up the key in cache. - let keys = self.keys.read().await; - let decoding_key = if let Some(k) = keys.get(&kid) { - k.clone() - } else { - // Key not found -- try refreshing once (key rotation). - drop(keys); + // Refresh once on kid miss. + let mut keys_guard = self.keys.read().await; + if !keys_guard.contains_key(&kid) { + drop(keys_guard); self.refresh_keys_coalesced().await.map_err(|e| { warn!(error = %e, "JWKS refresh on kid miss failed"); Status::internal("OIDC key refresh failed") })?; - let keys = self.keys.read().await; - keys.get(&kid).cloned().ok_or_else(|| { + keys_guard = self.keys.read().await; + } + + let (decoding_key, cached_algorithm) = keys_guard + .get(&kid) + .map(|(dk, alg)| (dk.clone(), *alg)) + .ok_or_else(|| { debug!(kid = %kid, "JWT kid not found in JWKS"); Status::unauthenticated("invalid token: unknown signing key") - })? - }; + })?; + drop(keys_guard); - // Validate the JWT. - let mut validation = Validation::new(Algorithm::RS256); + if header.alg != cached_algorithm { + debug!( + header_alg = ?header.alg, + expected = ?cached_algorithm, + "JWT algorithm mismatch for cached signing key" + ); + return Err(Status::unauthenticated("invalid token")); + } + + let mut validation = Validation::new(cached_algorithm); validation.set_issuer(&[&self.config.issuer]); validation.set_audience(&[&self.config.audience]); + validation.set_required_spec_claims(&["iss", "aud", "exp", "sub"]); let token_data = decode::(token, &decoding_key, &validation).map_err(|e| { debug!(error = %e, "JWT validation failed"); @@ -510,4 +836,945 @@ mod tests { let scopes = claims.extract_scopes("scope"); assert!(scopes.is_empty()); } + + // ----------------------------------------------------------------------- + // RS256 verification through the real JWKS path + // + // The tests above only cover claim extraction from an already-trusted + // payload. These sign real RS256 tokens and push them through + // `JwksCache::new` + `validate_token`, so the JWKS `n`/`e` decoding and the + // RSA signature check are exercised against whichever crypto backend + // `jsonwebtoken` is built with — a backend swap is otherwise invisible to + // the test suite. + // ----------------------------------------------------------------------- + + const TEST_KID: &str = "test-signing-key"; + const TEST_AUDIENCE: &str = "openshell-cli"; + + /// One RSA key per test binary. Key generation dominates the runtime of + /// these tests and the key carries no meaning beyond being valid. + static TEST_RSA_KEY: std::sync::LazyLock = + std::sync::LazyLock::new(TestRsaKey::generate); + + struct TestRsaKey { + private_pem: String, + modulus_b64: String, + exponent_b64: String, + } + + impl TestRsaKey { + fn generate() -> Self { + use base64::Engine as _; + use rsa::pkcs1::EncodeRsaPrivateKey as _; + use rsa::traits::PublicKeyParts as _; + + let private = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048) + .expect("generate RSA test key"); + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + Self { + private_pem: private + .to_pkcs1_pem(rsa::pkcs1::LineEnding::LF) + .expect("encode RSA private key as PEM") + .to_string(), + modulus_b64: b64.encode(private.n().to_bytes_be()), + exponent_b64: b64.encode(private.e().to_bytes_be()), + } + } + } + + fn now_secs() -> i64 { + i64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the unix epoch") + .as_secs(), + ) + .expect("current time fits in i64") + } + + /// Sign `claims` with the test key, tagging the header with `kid`. + fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String { + crate::install_jsonwebtoken_crypto_provider(); + + let mut header = jsonwebtoken::Header::new(Algorithm::RS256); + header.kid = Some(kid.to_owned()); + let key = jsonwebtoken::EncodingKey::from_rsa_pem(TEST_RSA_KEY.private_pem.as_bytes()) + .expect("load RSA signing key"); + jsonwebtoken::encode(&header, claims, &key).expect("sign RS256 token") + } + + fn claims_for(issuer: &str, audience: &str, exp: i64) -> serde_json::Value { + serde_json::json!({ + "sub": "user-42", + "preferred_username": "ada", + "iss": issuer, + "aud": audience, + "exp": exp, + "scope": "openid profile sandbox:write", + "realm_access": { "roles": ["openshell-user"] }, + }) + } + + /// Serve an OIDC discovery document and a JWKS carrying the test key, then + /// build a cache against them the same way production does. + async fn cache_with_mock_issuer(server: &wiremock::MockServer) -> JwksCache { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + let issuer = server.uri(); + Mock::given(method("GET")) + .and(path("/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": issuer, + "jwks_uri": format!("{issuer}/jwks"), + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [{ + "kid": TEST_KID, + "kty": "RSA", + "n": TEST_RSA_KEY.modulus_b64, + "e": TEST_RSA_KEY.exponent_b64, + }], + }))) + .mount(server) + .await; + + JwksCache::new(&OidcConfig { + issuer, + audience: TEST_AUDIENCE.to_owned(), + jwks_ttl_secs: 3600, + roles_claim: "realm_access.roles".to_owned(), + admin_role: "openshell-admin".to_owned(), + user_role: "openshell-user".to_owned(), + scopes_claim: "scope".to_owned(), + }) + .await + .expect("cache should build from the mock issuer") + } + + #[tokio::test] + async fn rs256_token_signed_by_jwks_key_is_accepted() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + let token = mint_rs256( + &claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600), + TEST_KID, + ); + let identity = cache + .validate_token(&token) + .await + .expect("a correctly signed token must be accepted"); + + assert_eq!(identity.subject, "user-42"); + assert_eq!(identity.display_name.as_deref(), Some("ada")); + assert_eq!(identity.roles, vec!["openshell-user".to_owned()]); + assert_eq!(identity.scopes, vec!["sandbox:write".to_owned()]); + assert_eq!(identity.provider, IdentityProvider::Oidc); + } + + #[tokio::test] + async fn rs256_token_with_tampered_payload_is_rejected() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + let exp = now_secs() + 3600; + let token = mint_rs256(&claims_for(&server.uri(), TEST_AUDIENCE, exp), TEST_KID); + + // Keep the header and signature but swap in a payload that escalates + // the subject: only the RSA check stands between this and an identity. + let segments: Vec<&str> = token.split('.').collect(); + assert_eq!(segments.len(), 3, "a JWT has three segments"); + let mut forged_claims = claims_for(&server.uri(), TEST_AUDIENCE, exp); + forged_claims["sub"] = serde_json::json!("root"); + let forged_payload = { + use base64::Engine as _; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&forged_claims).expect("serialize forged claims")) + }; + let forged = format!("{}.{forged_payload}.{}", segments[0], segments[2]); + + cache + .validate_token(&forged) + .await + .expect_err("a swapped payload must fail the signature check"); + } + + #[tokio::test] + async fn rs256_token_signed_by_unrelated_key_is_rejected() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + let other = TestRsaKey::generate(); + crate::install_jsonwebtoken_crypto_provider(); + let mut header = jsonwebtoken::Header::new(Algorithm::RS256); + header.kid = Some(TEST_KID.to_owned()); + let token = jsonwebtoken::encode( + &header, + &claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600), + &jsonwebtoken::EncodingKey::from_rsa_pem(other.private_pem.as_bytes()) + .expect("load unrelated signing key"), + ) + .expect("sign with unrelated key"); + + cache + .validate_token(&token) + .await + .expect_err("a token signed by a key outside the JWKS must be rejected"); + } + + #[tokio::test] + async fn rs256_expired_token_is_rejected() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + // Beyond the 60s default leeway. + let token = mint_rs256( + &claims_for(&server.uri(), TEST_AUDIENCE, now_secs() - 3600), + TEST_KID, + ); + + cache + .validate_token(&token) + .await + .expect_err("an expired token must be rejected"); + } + + #[tokio::test] + async fn rs256_token_from_other_issuer_is_rejected() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + let token = mint_rs256( + &claims_for("https://evil.example.com", TEST_AUDIENCE, now_secs() + 3600), + TEST_KID, + ); + + cache + .validate_token(&token) + .await + .expect_err("a token from another issuer must be rejected"); + } + + #[tokio::test] + async fn rs256_token_for_other_audience_is_rejected() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + let token = mint_rs256( + &claims_for(&server.uri(), "some-other-client", now_secs() + 3600), + TEST_KID, + ); + + cache + .validate_token(&token) + .await + .expect_err("a token minted for another audience must be rejected"); + } + + #[tokio::test] + async fn rs256_token_with_unknown_kid_is_rejected() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + let token = mint_rs256( + &claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600), + "rotated-away-key", + ); + + cache + .validate_token(&token) + .await + .expect_err("a token naming a kid absent from the JWKS must be rejected"); + } + + mod jwks_validation { + use super::*; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use jsonwebtoken::{EncodingKey, Header, encode}; + use openshell_core::OidcConfig; + use rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256, PKCS_ECDSA_P384_SHA384, PKCS_ED25519}; + use rsa::traits::PublicKeyParts; + use std::time::{SystemTime, UNIX_EPOCH}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + struct TestSigningKey { + kid: String, + jwk: serde_json::Value, + encoding_key: EncodingKey, + algorithm: Algorithm, + } + + fn now_secs() -> i64 { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + .unwrap() + } + + fn ec_coords_from_spki(spki: &[u8], coord_len: usize) -> (String, String) { + // The uncompressed EC point (a 0x04 tag byte followed by X and Y) + // is always the tail of an SPKI DER encoding, so slice it + // directly at the known offset instead of scanning for the tag + // byte (which could false-match a coordinate byte at the wrong + // offset). + let point_len = 1 + 2 * coord_len; + let point = &spki[spki.len() - point_len..]; + assert_eq!(point[0], 0x04, "expected uncompressed EC point marker"); + let x = URL_SAFE_NO_PAD.encode(&point[1..=coord_len]); + let y = URL_SAFE_NO_PAD.encode(&point[1 + coord_len..point_len]); + (x, y) + } + + // Precomputed 2048-bit RSA keys (PKCS8 PEM). Generating a fresh RSA + // key per test call site is the dominant cost of this test module + // (~7s across ~15 call sites); two static fixtures are deterministic + // and effectively free to parse. Two distinct keys are kept because + // a couple of tests (key rotation, duplicate-kid poisoning) need + // genuinely different key material, not just different `kid`s. + const RSA_TEST_KEY_PEM_PRIMARY: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDFDsXUQjvihWzC\nA8UfdQ+QiQrAt8RBEMXXxiYHTn+EwDl4umH+OFURRGIlGgZ8cFrewKDotaJylEal\nnRq+urYCS3JjoerIS1lc9XZFp5ri7XB+5fB0bZQPNX4KIJ7MlbP2XG0zqvawww1A\nZQ4w9Ni8X+pptB7QlGWldALlNiOPvlVNaRFRzvY4wc2JqwlWj0ZD/5fqVaO/7oa6\nyyKuZzXQ3ps0PMbt7sz9FcyMEgkQCm0eNIOancjeQKhFNFh5VN71PhyfnhN9Xkwn\n0vaV24SRrOurLZAH4vhum0aDQLFC0yDVCKEfwW1Wl7Cs0ns6kS/IbG+RPBGH4Snr\nRoI9OaiLAgMBAAECggEAWOS9IW9vjFQcJ7mDpxkrmEv56c38Xk2ushPU+97Rb5U3\nV9rccc3/sfZjP9Fps6ELnQjQjanCSmXRKMyiT//yMz7Nr1xPiWNUQLcKT4m4OT5b\nTSN1QVBdRi8fWHo2qJuvvycarAAnoL2csLvllvgc/X1XRa/XZshKwkR/Od8eU60C\nMHuCmks+NCpUL/lMIZecAbWFyQDYJqwB/lnJdEO5oDaIcGclAOQJzj3+xCfFnLLh\nYt/7tCxOboIeyP/fLh5OyqC6NppRUSwtfWNQNwgQWw7WWVd5TW9d2wjycgQKrm6P\n1tnbs8yRJ2h8jemJR65OukW1QOfdN7QFWBAoD8BEwQKBgQDiIXhmre2qw/d4aOZV\nqDzjlHFCAG9XCAWF8pio2Uy0SUXAI4CcK6d+Njm/U7CaKNUfbNyip0iv3YoADwAv\nDlI6kqo1DOGXYgo2z0zfmxWTixQsCvZHtLrSk4Z1sxiSlFXfbTsR3vBd7qLXgKQ/\nWR9b2chPYlGew8DBRTNa0aQ/KQKBgQDfFjVKMibXSi69x704LQ9QoO0SR+pgdyw6\nJ5auf0N3ACqIOTqdD001Dn1zNO35RdTbV3vYDCq24yED5xro+fpSSbZ2+ZpMjhEk\nZjLGSlTSwsjzXx4l41f8wX7Br5OgTjA9B23NNDTf/RaD+ui5mfVA3BJyoXMkHCC2\nIV5ZZt7EkwKBgHu8Xtqor5UymDaOCAO1BGRvdK3t+P7Bh+wsvDYgeaVpNr6VbqmG\nBae9WkoELG2ejEge1Hg4W0DIU9wGWU5mYr5kRLi0rLieUAJ/2ou8m8jZYJddBDhm\nf5f8W6YJ8xc6DectKRZ1TEfJ7ddIMBft14f2GnK91PWwHchj6l72ug5JAoGAd1Hc\njOPILIyL9YvY5CwNrfV097spXBFBwZUdHhYJkqOvHA9oD0t44zDt3mnoAtTb5bmk\nDslrK0jOhtTcatIRlmPAyV/1rI6sEojrDW4CcnwmmS095cv0asdfsd7kGfDYEjxf\n+Uq8ITWwDkVspqD3MYrD/zXlbOHyiRfN7Al+iysCgYEA4Q8+8d5UwF1jzUQFmSEt\nMjlGyycrLpzadgWTG9EjQN0FuSJFK8nyEyqcfzFm+rSsReZ37JwPTCQ/Ck4g4IXY\n3VvHY+6wj0NYwRwjKCZeqN8IAANsj5Xsh5pz4ETSwnwd0bUlGP6Bcqoc5edLnB85\nEMcbZoGCXLCoi/qf8T334k4=\n-----END PRIVATE KEY-----\n"; + const RSA_TEST_KEY_PEM_SECONDARY: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCnRp9lR8xbtsXp\nCTTBkyAkhYji4Hp5FnAXxZF5DGfyw3I9ol0J8Hoe9RSBkQVIKXQe5Gfi+L7o9G6v\nVcucrzILxSJ4dCPKgNaj53G4zwkySpTsVO0XjxKanozQqdRbkAGaz9AsuzfGWB9m\n1AiC0JxKmOo8Ifxi4oj0hIBV4Mp3TCfHX8vFCIn5AZ/31Y29vT1mPzoCxwGw4Yxd\nh4kYQgIIWoAekebWeJ8iowgOvbV8rmT1geMWu7JRdpcStN9PWVZKUiZplILz9ci2\n+owm8UyxdQvBt6csEJFNdbAGUR1/Ywv/unoTQfrPvwQ4of5viywRra+iCMM0SrSA\nUO6Fu5a/AgMBAAECggEATDSTzD+73XJ0Ujhz9NYSeCDvnjBXC1AKDAJhRiy9NG8O\n3f5YdX09HVpYl7haGChuct5qZ5Ab5SPqQu2Kn5x+57bM/+QlJA2y+yOm/uMvFN6+\nXrZH9woilxcxHqSoDniaCo2vEJnQDIe78owZPoNMGH32hCOVh/UdIIw2rSkGA/eM\nYHHQXxx3sbi0AxCnlDzkgST15igP1baAZKUM2vIHVdg2Ccx2CnUiLM5BgvlWd98f\nS6GmTMk4WUWvmYYFZx+5GEhMmgz64xnyjIe+tRT1iP4p5NaCZc2CWLQdycEaEeKp\nmWFjxe7t/eXLUE3h1BBf/qZvcekpYbDGjw8JSaa00QKBgQDcctFl9P4npQUkoq0M\nDWeKSITsSwOVNKBLrQv5C4inz7QGPglGgIF8G6O7mvQ9ZulzDsvWfzxS0JE6ADkU\nHAkMY4/6Rfm7XKk8Yg1QoxCqsoI85nVQOhsmvVmIwXBDdINMY+g9jez9psiiiQiC\nh0nt3n9LPKu/YCzve50YTvR/bwKBgQDCQJSmZTFVsTC29e0CCgS1Zp9z4IAcZgth\nEJsaenE/ksQriH8gl3I4bwfI1p/xquBeWO40A016T67laCUBPTCwuZTGyqUiOtAw\nV5CoUiCvu38lNW6dk/RgouR0WoIQVvKq+5NKcnQtwbDHCt4U+hnB8c6EbT8dG2XU\noAamLcq1sQKBgADYI7srPAn01Nc2FEmWh439Bx1MkD/zCqYfjIswox5ZakwX0rtF\nZLmP9YmTZ1oQ2dYJ+Xfh1t5OVDAPrihIjzRP8U45FGLGUROdIIXtifPNaThIfayH\n/HCiiwQ+EWsAuDwDqfEKaRzzlZMhyTmOwRa7ImusWNAL00A7jfd43fDbAoGAa5u8\n/USXhOIIm4I2zmdgXmFAOcAHGDRLX3UEhzGHJPGX7InL6vEanDqdtFt49TZ03q8j\nHfsqY3Ra7ci4nywXmf7kdQ9zVTgBdpY7k5MTemZCtAkagv6gZRw3tGEjJgwUmDWP\nTbGDvIlM9aaGilZWCIN8pQ2j5er0iUoxBMPfRLECgYBu8/kcgM/sEeYh5vN5QqAh\n3/ezl+xboYDGGY1SLRwAzEsyIm9lyvvrFc+zGLpHqIP7jtrnu7CTqjxX0FQ2ECnd\nLIqNx0WMFZPq+GAD0hyUpYcqGFpHLGZGIOm8YhQUYvUL6ZWU4UQaND6gEq4ZYk9g\nk4dfOCu/AeloLlC3LUC9fw==\n-----END PRIVATE KEY-----\n"; + + enum RsaFixture { + Primary, + Secondary, + } + + /// Build an RSA `TestSigningKey` from a precomputed fixture, with an + /// optional declared `alg` and an explicit signing algorithm — used + /// by tests that exercise algorithm selection from the JWK's `alg` + /// field (RS256 default vs. a declared RS384/RS512/etc). + fn rsa_test_key_from( + kid: &str, + fixture: RsaFixture, + declared_alg: Option<&str>, + sign_algorithm: Algorithm, + ) -> TestSigningKey { + use rsa::pkcs8::DecodePrivateKey; + + let pem = match fixture { + RsaFixture::Primary => RSA_TEST_KEY_PEM_PRIMARY, + RsaFixture::Secondary => RSA_TEST_KEY_PEM_SECONDARY, + }; + let private_key = + rsa::RsaPrivateKey::from_pkcs8_pem(pem).expect("valid static test RSA key"); + let encoding_key = EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap(); + let public_key = private_key.to_public_key(); + let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be()); + let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be()); + let mut jwk = serde_json::json!({ + "kty": "RSA", + "kid": kid, + "n": n, + "e": e, + "use": "sig", + }); + if let Some(alg) = declared_alg { + jwk["alg"] = serde_json::json!(alg); + } + TestSigningKey { + kid: kid.to_string(), + jwk, + encoding_key, + algorithm: sign_algorithm, + } + } + + fn rsa_test_key(kid: &str) -> TestSigningKey { + rsa_test_key_from(kid, RsaFixture::Primary, None, Algorithm::RS256) + } + + /// Distinct key material from `rsa_test_key`, for tests that need + /// two genuinely different RSA keys (e.g. key rotation, duplicate + /// `kid` poisoning) rather than just different `kid` labels. + fn rsa_test_key_secondary(kid: &str) -> TestSigningKey { + rsa_test_key_from(kid, RsaFixture::Secondary, None, Algorithm::RS256) + } + + fn ec_test_key(kid: &str, curve: &str) -> TestSigningKey { + let (key_pair, algorithm, coord_len) = match curve { + "P-256" => ( + KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(), + Algorithm::ES256, + 32, + ), + "P-384" => ( + KeyPair::generate_for(&PKCS_ECDSA_P384_SHA384).unwrap(), + Algorithm::ES384, + 48, + ), + other => panic!("unsupported curve {other}"), + }; + let encoding_key = + EncodingKey::from_ec_pem(key_pair.serialize_pem().as_bytes()).unwrap(); + let (x, y) = ec_coords_from_spki(&key_pair.public_key_der(), coord_len); + let jwk = serde_json::json!({ + "kty": "EC", + "kid": kid, + "crv": curve, + "x": x, + "y": y, + "use": "sig", + }); + TestSigningKey { + kid: kid.to_string(), + jwk, + encoding_key, + algorithm, + } + } + + fn ed25519_test_key(kid: &str) -> TestSigningKey { + let key_pair = KeyPair::generate_for(&PKCS_ED25519).unwrap(); + let encoding_key = + EncodingKey::from_ed_pem(key_pair.serialize_pem().as_bytes()).unwrap(); + let spki = key_pair.public_key_der(); + let x = URL_SAFE_NO_PAD.encode(&spki[spki.len() - 32..]); + let jwk = serde_json::json!({ + "kty": "OKP", + "kid": kid, + "crv": "Ed25519", + "x": x, + "use": "sig", + }); + TestSigningKey { + kid: kid.to_string(), + jwk, + encoding_key, + algorithm: Algorithm::EdDSA, + } + } + + fn token_with_header_alg(token: &str, algorithm: Algorithm) -> String { + let mut parts: Vec = token.split('.').map(str::to_string).collect(); + assert_eq!(parts.len(), 3); + let header_bytes = URL_SAFE_NO_PAD.decode(&parts[0]).unwrap(); + let mut header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); + header["alg"] = serde_json::json!(match algorithm { + Algorithm::HS256 => "HS256", + other => panic!("unsupported test algorithm {other:?}"), + }); + parts[0] = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + parts.join(".") + } + + fn sign_token(key: &TestSigningKey, issuer: &str, audience: &str, sub: &str) -> String { + let mut header = Header::new(key.algorithm); + header.kid = Some(key.kid.clone()); + let claims = serde_json::json!({ + "sub": sub, + "iss": issuer, + "aud": audience, + "exp": now_secs() + 3600, + }); + encode(&header, &claims, &key.encoding_key).unwrap() + } + + fn test_oidc_config(issuer: &str) -> OidcConfig { + OidcConfig { + issuer: issuer.to_string(), + audience: "test-audience".to_string(), + jwks_ttl_secs: 3600, + roles_claim: "roles".to_string(), + admin_role: "admin".to_string(), + user_role: "user".to_string(), + scopes_claim: String::new(), + } + } + + async fn mount_oidc(server: &MockServer, jwks: serde_json::Value) -> (String, OidcConfig) { + let issuer = format!("{}/issuer", server.uri()); + let jwks_uri = format!("{issuer}/jwks"); + Mock::given(method("GET")) + .and(path("/issuer/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": issuer, + "jwks_uri": jwks_uri, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(jwks)) + .mount(server) + .await; + (issuer.clone(), test_oidc_config(&issuer)) + } + + async fn cache_from_jwks(jwks: serde_json::Value) -> (JwksCache, String, OidcConfig) { + let server = MockServer::start().await; + let (issuer, config) = mount_oidc(&server, jwks).await; + let cache = JwksCache::new(&config).await.unwrap(); + (cache, issuer, config) + } + + #[tokio::test] + async fn validate_rsa_es256_es384_eddsa_tokens() { + let rsa = rsa_test_key("rsa-key"); + let es256 = ec_test_key("es256-key", "P-256"); + let es384 = ec_test_key("es384-key", "P-384"); + let eddsa = ed25519_test_key("eddsa-key"); + let jwks = serde_json::json!({ + "keys": [rsa.jwk, es256.jwk, es384.jwk, eddsa.jwk] + }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + + for (name, key) in [ + ("rsa", &rsa), + ("es256", &es256), + ("es384", &es384), + ("eddsa", &eddsa), + ] { + let token = sign_token(key, &issuer, &config.audience, "user-1"); + let result = cache.validate_token(&token).await; + assert!(result.is_ok(), "validation failed for {name}: {result:?}"); + assert_eq!(result.unwrap().subject, "user-1"); + } + } + + #[tokio::test] + async fn unsupported_curve_is_skipped() { + let signing = rsa_test_key("good"); + let jwks = serde_json::json!({ + "keys": [ + { "kty": "EC", "kid": "p521", "crv": "P-521", "x": "abc", "y": "def" }, + signing.jwk, + ] + }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let token = sign_token(&signing, &issuer, &config.audience, "user-1"); + assert!(cache.validate_token(&token).await.is_ok()); + } + + #[tokio::test] + async fn kid_miss_triggers_refresh() { + let initial = rsa_test_key("initial"); + let rotated = rsa_test_key_secondary("rotated"); + let server = MockServer::start().await; + let issuer = format!("{}/issuer", server.uri()); + let jwks_uri = format!("{issuer}/jwks"); + Mock::given(method("GET")) + .and(path("/issuer/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": issuer, + "jwks_uri": jwks_uri, + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [initial.jwk] + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [rotated.jwk] + }))) + .mount(&server) + .await; + + let config = test_oidc_config(&issuer); + let cache = JwksCache::new(&config).await.unwrap(); + let initial_token = sign_token(&initial, &issuer, &config.audience, "user-initial"); + assert!(cache.validate_token(&initial_token).await.is_ok()); + + let rotated_token = sign_token(&rotated, &issuer, &config.audience, "user-rotated"); + let identity = cache.validate_token(&rotated_token).await.unwrap(); + assert_eq!(identity.subject, "user-rotated"); + } + + /// Shared setup for the "a refresh yields no usable keys" family of + /// regression tests: an initially-working cache, then a refresh + /// (triggered by an unknown `kid`) whose response is `bad_jwks`. + /// Asserts the previously cached key remains usable afterward. + async fn assert_refresh_preserves_cache(bad_jwks: serde_json::Value) { + let good = rsa_test_key("good"); + let server = MockServer::start().await; + let issuer = format!("{}/issuer", server.uri()); + let jwks_uri = format!("{issuer}/jwks"); + Mock::given(method("GET")) + .and(path("/issuer/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": issuer, + "jwks_uri": jwks_uri, + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [good.jwk] + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(bad_jwks)) + .mount(&server) + .await; + + let config = test_oidc_config(&issuer); + let cache = JwksCache::new(&config).await.unwrap(); + let token = sign_token(&good, &issuer, &config.audience, "user-1"); + assert!(cache.validate_token(&token).await.is_ok()); + + // An unknown kid triggers an unconditional refresh against the + // bad response, so this specific lookup still fails, but the + // previously cached key must remain usable afterward: the bad + // refresh must not have wiped it. + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("unknown".to_string()); + let claims = serde_json::json!({ + "sub": "user-2", + "iss": issuer, + "aud": config.audience, + "exp": now_secs() + 3600, + }); + let unknown_kid_token = encode(&header, &claims, &good.encoding_key).unwrap(); + let err = cache.validate_token(&unknown_kid_token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + + assert!(cache.validate_token(&token).await.is_ok()); + } + + #[tokio::test] + async fn refresh_keeps_previous_keys_when_new_jwks_fully_unusable() { + // An issuer-side metadata change (e.g. every key switches to + // `use: "enc"`) must not wipe a previously-working key cache — + // otherwise a single bad JWKS response turns "working" into + // "every OIDC request rejected". + assert_refresh_preserves_cache(serde_json::json!({ + "keys": [{ "kty": "RSA", "kid": "good", "n": "abc", "e": "AQAB", "use": "enc" }] + })) + .await; + } + + #[tokio::test] + async fn refresh_keeps_previous_keys_when_new_jwks_response_empty() { + // A literally empty JWKS response (`total == 0`, as opposed to + // keys present but all filtered out) must be just as harmless + // to an existing cache as the fully-unusable case above. + assert_refresh_preserves_cache(serde_json::json!({ "keys": [] })).await; + } + + #[tokio::test] + async fn stale_keys_evicted_after_grace_period() { + // A JWKS that goes empty and stays empty must eventually stop + // being trusted; otherwise a key the issuer revoked could + // remain valid indefinitely behind a permanently broken JWKS + // endpoint. + let good = rsa_test_key("good"); + let server = MockServer::start().await; + let issuer = format!("{}/issuer", server.uri()); + let jwks_uri = format!("{issuer}/jwks"); + Mock::given(method("GET")) + .and(path("/issuer/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": issuer, + "jwks_uri": jwks_uri, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [good.jwk] + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "keys": [] })), + ) + .mount(&server) + .await; + + let mut config = test_oidc_config(&issuer); + config.jwks_ttl_secs = 1; // 1s TTL -> 3s grace period. + let cache = JwksCache::new(&config).await.unwrap(); + let token = sign_token(&good, &issuer, &config.audience, "user-1"); + assert!(cache.validate_token(&token).await.is_ok()); + + // Past the TTL but within the grace period: a scheduled refresh + // sees the now-empty JWKS, but the cached key stays trusted. + tokio::time::sleep(Duration::from_millis(1200)).await; + assert!(cache.validate_token(&token).await.is_ok()); + + // Past the grace period with the JWKS still empty: the stale + // key must finally be evicted. + tokio::time::sleep(Duration::from_millis(2500)).await; + let err = cache.validate_token(&token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn zero_ttl_rejected_at_construction() { + // A `jwks_ttl_secs` of 0 would refresh on every request and + // collapse the stale-key grace period to zero, so it's rejected + // outright rather than silently tolerated. This is consistent + // with how `AuthzPolicy::validate` rejects OIDC RBAC + // misconfiguration before the server starts, instead of + // working around it later. The check runs before any network + // I/O, so no mock server is needed here. + let mut config = test_oidc_config("http://unused.invalid"); + config.jwks_ttl_secs = 0; + let err = JwksCache::new(&config).await.unwrap_err(); + assert!( + err.contains("jwks_ttl_secs"), + "error should name the offending field: {err}" + ); + } + + #[tokio::test] + async fn kid_miss_refresh_throttled_within_cooldown() { + // Repeated lookups for an unknown kid within the cooldown + // window must not each trigger a fresh fetch. Otherwise, a + // burst (or steady stream) of unknown-kid requests could + // amplify load on the issuer's JWKS endpoint without bound. + let good = rsa_test_key("good"); + let server = MockServer::start().await; + let issuer = format!("{}/issuer", server.uri()); + let jwks_uri = format!("{issuer}/jwks"); + Mock::given(method("GET")) + .and(path("/issuer/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": issuer, + "jwks_uri": jwks_uri, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/issuer/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [good.jwk] + }))) + .mount(&server) + .await; + + let config = test_oidc_config(&issuer); + let cache = JwksCache::new(&config).await.unwrap(); + + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("unknown".to_string()); + let claims = serde_json::json!({ + "sub": "user-2", + "iss": issuer, + "aud": config.audience, + "exp": now_secs() + 3600, + }); + let unknown_kid_token = encode(&header, &claims, &good.encoding_key).unwrap(); + + for _ in 0..5 { + let err = cache.validate_token(&unknown_kid_token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + let requests = server.received_requests().await.unwrap(); + let jwks_hits = requests + .iter() + .filter(|r| r.url.path() == "/issuer/jwks") + .count(); + // One fetch for the initial load, one more for the first + // kid-miss lookup; the remaining four must be throttled. + assert_eq!(jwks_hits, 2); + } + + #[tokio::test] + async fn header_alg_mismatch_rejected() { + let ec = ec_test_key("ec", "P-256"); + let jwks = serde_json::json!({ "keys": [ec.jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let token = sign_token(&ec, &issuer, &config.audience, "user-1"); + let forged = token_with_header_alg(&token, Algorithm::HS256); + let err = cache.validate_token(&forged).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn missing_sub_claim_rejected() { + let key = rsa_test_key("rsa"); + let jwks = serde_json::json!({ "keys": [key.jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let mut header = Header::new(key.algorithm); + header.kid = Some(key.kid.clone()); + let claims = serde_json::json!({ + "iss": issuer, + "aud": config.audience, + "exp": now_secs() + 3600, + }); + let token = encode(&header, &claims, &key.encoding_key).unwrap(); + let err = cache.validate_token(&token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn jwk_alg_mismatch_skipped() { + let signing = rsa_test_key("rsa"); + let mut bad = signing.jwk.clone(); + bad["alg"] = serde_json::json!("ES256"); + let jwks = serde_json::json!({ "keys": [bad] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let token = sign_token(&signing, &issuer, &config.audience, "user-1"); + let err = cache.validate_token(&token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn rsa_declared_alg_rs512_selected_and_pinned() { + // An RSA JWK declaring "alg": "RS512" is validated as RS512, not + // vetoed against a hardcoded RS256 default that would silently + // discard the declared algorithm. + let key = rsa_test_key_from( + "rsa-rs512", + RsaFixture::Primary, + Some("RS512"), + Algorithm::RS512, + ); + let jwks = serde_json::json!({ "keys": [key.jwk.clone()] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + + let token = sign_token(&key, &issuer, &config.audience, "user-1"); + let result = cache.validate_token(&token).await; + assert!(result.is_ok(), "RS512 token should validate: {result:?}"); + + // The cached algorithm is pinned to RS512, not re-derived from + // the header — a token for the same kid claiming RS256 must + // still be rejected. + let mut rs256_variant = key; + rs256_variant.algorithm = Algorithm::RS256; + let mismatched = sign_token(&rs256_variant, &issuer, &config.audience, "user-1"); + let err = cache.validate_token(&mismatched).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn rsa_missing_alg_defaults_to_rs256() { + // No "alg" field at all (e.g. Microsoft Entra ID's JWKS) must + // still default to RS256, not be treated as unusable. + let key = rsa_test_key("rsa-no-alg"); + assert!(key.jwk.get("alg").is_none()); + let jwks = serde_json::json!({ "keys": [key.jwk.clone()] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let token = sign_token(&key, &issuer, &config.audience, "user-1"); + assert!(cache.validate_token(&token).await.is_ok()); + } + + #[tokio::test] + async fn key_ops_verify_accepted_sign_only_rejected() { + // The gateway only ever verifies signatures, so `key_ops` must + // include "verify" — `["sign"]` alone (without "verify") on a + // public key is semantically incoherent per RFC 7517 §4.3 and + // must be rejected, not treated as interchangeable with "verify". + let mut verify_key = rsa_test_key("verify-key"); + verify_key.jwk["key_ops"] = serde_json::json!(["verify"]); + let mut sign_key = rsa_test_key_secondary("sign-key"); + sign_key.jwk["key_ops"] = serde_json::json!(["sign"]); + let jwks = serde_json::json!({ "keys": [verify_key.jwk, sign_key.jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + + let verify_token = sign_token(&verify_key, &issuer, &config.audience, "verify-user"); + assert!(cache.validate_token(&verify_token).await.is_ok()); + + let sign_token_str = sign_token(&sign_key, &issuer, &config.audience, "sign-user"); + let err = cache.validate_token(&sign_token_str).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn key_ops_sign_and_encrypt_rejected() { + // RFC 7517 §4.3 permits "sign" with "verify" but not "sign" with + // "encrypt" ("other combinations SHOULD NOT be used"). Since + // "verify" is absent here, the key must be skipped. + let mut key = rsa_test_key("mixed-ops"); + key.jwk["key_ops"] = serde_json::json!(["sign", "encrypt"]); + let jwks = serde_json::json!({ "keys": [key.jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let token = sign_token(&key, &issuer, &config.audience, "user-1"); + let err = cache.validate_token(&token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn key_ops_encrypt_only_skipped() { + let signing = rsa_test_key("good"); + let mut enc_ops = signing.jwk.clone(); + enc_ops["kid"] = serde_json::json!("enc-ops"); + enc_ops["key_ops"] = serde_json::json!(["encrypt"]); + let jwks = serde_json::json!({ "keys": [enc_ops, signing.jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let token = sign_token(&signing, &issuer, &config.audience, "user-1"); + assert!(cache.validate_token(&token).await.is_ok()); + } + + #[tokio::test] + async fn encryption_jwk_skipped_but_sig_key_works() { + let signing = rsa_test_key("sig"); + let mut enc = signing.jwk.clone(); + enc["use"] = serde_json::json!("enc"); + enc["kid"] = serde_json::json!("enc-key"); + let jwks = serde_json::json!({ "keys": [enc, signing.jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + let token = sign_token(&signing, &issuer, &config.audience, "user-1"); + assert!(cache.validate_token(&token).await.is_ok()); + } + + #[tokio::test] + async fn duplicate_kid_same_algorithm_keeps_first() { + let first = rsa_test_key("dup"); + let second = rsa_test_key_secondary("other"); + let mut second_jwk = second.jwk.clone(); + second_jwk["kid"] = serde_json::json!("dup"); + let jwks = serde_json::json!({ "keys": [first.jwk, second_jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + + let first_token = sign_token(&first, &issuer, &config.audience, "first-user"); + assert!(cache.validate_token(&first_token).await.is_ok()); + + let mut header = Header::new(second.algorithm); + header.kid = Some("dup".to_string()); + let claims = serde_json::json!({ + "sub": "second-user", + "iss": issuer, + "aud": config.audience, + "exp": now_secs() + 3600, + }); + let second_token = encode(&header, &claims, &second.encoding_key).unwrap(); + let err = cache.validate_token(&second_token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn duplicate_kid_conflicting_algorithms_poison_pill() { + let rsa = rsa_test_key("conflict"); + let eddsa = ed25519_test_key("conflict"); + let jwks = serde_json::json!({ "keys": [rsa.jwk, eddsa.jwk] }); + let (cache, issuer, config) = cache_from_jwks(jwks).await; + + let rsa_token = sign_token(&rsa, &issuer, &config.audience, "rsa-user"); + let err = cache.validate_token(&rsa_token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + + let eddsa_token = sign_token(&eddsa, &issuer, &config.audience, "eddsa-user"); + let err = cache.validate_token(&eddsa_token).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + } } diff --git a/crates/openshell-server/src/auth/principal.rs b/crates/openshell-server/src/auth/principal.rs index 1d4cb7276c..9567cc62d2 100644 --- a/crates/openshell-server/src/auth/principal.rs +++ b/crates/openshell-server/src/auth/principal.rs @@ -70,7 +70,8 @@ pub enum SandboxIdentitySource { /// Per-sandbox client certificate. Reserved for channel-bound sandbox /// identity. BootstrapCert { fingerprint: String }, - /// K8s `ServiceAccount` token used to bootstrap a gateway-minted JWT - /// via `IssueSandboxToken`. Populated only on that one RPC path. - K8sServiceAccount { pod_name: String, pod_uid: String }, + /// Driver-native credential used to bootstrap a gateway-minted JWT via + /// `IssueSandboxToken`. The named compute driver authenticated only the + /// sandbox identity; the gateway still authorizes the exchange. + ComputeDriver { driver_name: String }, } diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index 39f5982ca0..9dc10b8401 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -18,13 +18,22 @@ use super::authenticator::Authenticator; use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode, }; +pub use openshell_extension_core::{ + EXTENSION_JWT_TYP, ExtensionAudience, ExtensionCallerKind, ExtensionJwtClaims, + MAX_EXTENSION_TOKEN_TTL, +}; use serde::{Deserialize, Serialize}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{ + io::Cursor, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use tonic::Status; use tracing::{debug, warn}; +use x509_parser::{oid_registry::OID_SIG_ED25519, prelude::FromDer, x509::SubjectPublicKeyInfo}; /// SPIFFE-shaped subject prefix. Embedded in the `sub` claim of every /// minted token so a future migration to per-sandbox certs or SPIRE can @@ -33,6 +42,24 @@ use tracing::{debug, warn}; const SPIFFE_SUBJECT_PREFIX: &str = "spiffe://openshell/sandbox/"; const SANDBOX_JWT_EXP_LEEWAY_SECS: i64 = 60; +/// Public JSON Web Key Set served by the gateway. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GatewayJwks { + pub keys: Vec, +} + +/// Ed25519 public key entry in the gateway JWKS. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GatewayJwk { + pub kty: &'static str, + pub crv: &'static str, + pub alg: &'static str, + #[serde(rename = "use")] + pub key_use: &'static str, + pub kid: String, + pub x: String, +} + /// JWT claim set serialized in every gateway-minted sandbox token. #[derive(Debug, Serialize, Deserialize)] pub struct SandboxJwtClaims { @@ -85,6 +112,8 @@ impl SandboxJwtIssuer { gateway_id: &str, ttl: Duration, ) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + let encoding_key = EncodingKey::from_ed_pem(signing_key_pem) .map_err(|e| format!("failed to parse Ed25519 signing key PEM: {e}"))?; let identity = format!("openshell-gateway:{gateway_id}"); @@ -100,6 +129,8 @@ impl SandboxJwtIssuer { /// Mint a fresh token for `sandbox_id`. #[allow(clippy::result_large_err)] // `tonic::Status` is the natural error here pub fn mint(&self, sandbox_id: &str) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + let now = now_secs(); let exp = if self.ttl.is_zero() { 0 @@ -126,6 +157,75 @@ impl SandboxJwtIssuer { }) } + /// Mint a short-lived bearer token for one exact extension audience. + /// + /// `sandbox_id` is required for supervisor calls and forbidden for + /// gateway calls. The subject follows the existing SPIFFE-shaped sandbox + /// identity for supervisor calls; gateway calls use the issuer identity. + #[allow(clippy::result_large_err)] + pub fn mint_extension_token( + &self, + audience: &ExtensionAudience, + caller_kind: ExtensionCallerKind, + sandbox_id: Option<&str>, + ttl: Duration, + ) -> Result { + if audience.as_str() == self.audience { + return Err(Status::invalid_argument( + "extension audience must not equal the gateway sandbox audience", + )); + } + if ttl.is_zero() || ttl > MAX_EXTENSION_TOKEN_TTL { + return Err(Status::invalid_argument(format!( + "extension token TTL must be between 1 and {} seconds", + MAX_EXTENSION_TOKEN_TTL.as_secs() + ))); + } + + let (sub, sandbox_id) = match (caller_kind, sandbox_id) { + (ExtensionCallerKind::Gateway, None) => (self.issuer.clone(), None), + (ExtensionCallerKind::Supervisor, Some(id)) if !id.trim().is_empty() => { + (format!("{SPIFFE_SUBJECT_PREFIX}{id}"), Some(id.to_string())) + } + (ExtensionCallerKind::Gateway, Some(_)) => { + return Err(Status::invalid_argument( + "gateway extension tokens must not include a sandbox ID", + )); + } + (ExtensionCallerKind::Supervisor, _) => { + return Err(Status::invalid_argument( + "supervisor extension tokens require a sandbox ID", + )); + } + }; + + let now = now_secs(); + let exp = now.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(3_600)); + let claims = ExtensionJwtClaims { + iss: self.issuer.clone(), + aud: audience.as_str().to_string(), + sub, + iat: now, + exp, + jti: uuid::Uuid::new_v4().to_string(), + caller_kind, + sandbox_id, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + // Explicit typing so an extension that requires this `typ` cannot be + // handed a sandbox-to-gateway bootstrap token signed by the same key. + header.typ = Some(EXTENSION_JWT_TYP.to_string()); + let token = encode(&header, &claims, &self.encoding_key).map_err(|e| { + warn!(error = %e, "failed to mint extension JWT"); + Status::internal("failed to mint extension token") + })?; + Ok(MintedToken { + token, + expires_at_ms: exp.saturating_mul(1000), + }) + } + pub fn ttl(&self) -> Duration { self.ttl } @@ -137,6 +237,7 @@ pub struct SandboxJwtAuthenticator { kid: String, issuer: String, audience: String, + jwks: GatewayJwks, } impl std::fmt::Debug for SandboxJwtAuthenticator { @@ -151,24 +252,53 @@ impl std::fmt::Debug for SandboxJwtAuthenticator { impl SandboxJwtAuthenticator { pub fn from_pem(public_key_pem: &[u8], kid: String, gateway_id: &str) -> Result { + crate::install_jsonwebtoken_crypto_provider(); + let decoding_key = DecodingKey::from_ed_pem(public_key_pem) .map_err(|e| format!("failed to parse Ed25519 public key PEM: {e}"))?; + let jwks = GatewayJwks::from_public_key_pem(public_key_pem, kid.clone())?; let identity = format!("openshell-gateway:{gateway_id}"); Ok(Self { decoding_key, kid, issuer: identity.clone(), audience: identity, + jwks, }) } + /// Return the public signing keys integrations use to verify extension + /// tokens. No private key material is retained by this type. + #[must_use] + pub const fn jwks(&self) -> &GatewayJwks { + &self.jwks + } + + /// The exact `iss` claim carried by every token this gateway mints. + #[must_use] + pub fn issuer(&self) -> &str { + &self.issuer + } + #[allow(clippy::result_large_err)] fn validate_bearer(&self, token: &str) -> Result, Status> { + crate::install_jsonwebtoken_crypto_provider(); + let header = decode_header(token).map_err(|e| { debug!(error = %e, "sandbox JWT header decode failed"); Status::unauthenticated("invalid token") })?; + // Extension credentials share this signing key during alpha, but are + // never valid sandbox admission credentials. Check the explicit type + // before `kid` fallthrough so another authenticator cannot accept one. + // Legacy and untyped sandbox JWTs remain valid for rolling upgrades. + if header.typ.as_deref() == Some(EXTENSION_JWT_TYP) { + return Err(Status::unauthenticated( + "extension tokens cannot authenticate to the gateway", + )); + } + // Fall through to other authenticators when the kid does not match — // OIDC issuers may share the Bearer slot. if header.kid.as_deref() != Some(self.kid.as_str()) { @@ -201,6 +331,36 @@ impl SandboxJwtAuthenticator { } } +impl GatewayJwks { + fn from_public_key_pem(public_key_pem: &[u8], kid: String) -> Result { + let item = rustls_pemfile::read_one(&mut Cursor::new(public_key_pem)) + .map_err(|e| format!("failed to parse Ed25519 public key PEM for JWKS: {e}"))?; + let Some(rustls_pemfile::Item::SubjectPublicKeyInfo(der)) = item else { + return Err("Ed25519 public key PEM does not contain a PUBLIC KEY block".into()); + }; + let (remainder, spki) = SubjectPublicKeyInfo::from_der(der.as_ref()) + .map_err(|e| format!("failed to parse SubjectPublicKeyInfo for JWKS: {e}"))?; + if !remainder.is_empty() || spki.algorithm.algorithm != OID_SIG_ED25519 { + return Err("public key is not an RFC 8410 Ed25519 SubjectPublicKeyInfo key".into()); + } + let raw_key = spki.subject_public_key.data.as_ref(); + if raw_key.len() != 32 { + return Err("Ed25519 public key must be 32 bytes".to_string()); + } + + Ok(Self { + keys: vec![GatewayJwk { + kty: "OKP", + crv: "Ed25519", + alg: "EdDSA", + key_use: "sig", + kid, + x: URL_SAFE_NO_PAD.encode(raw_key), + }], + }) + } +} + #[async_trait] impl Authenticator for SandboxJwtAuthenticator { async fn authenticate( @@ -278,6 +438,10 @@ mod tests { (issuer, auth) } + fn extension_audience(value: &str) -> ExtensionAudience { + ExtensionAudience::new(value).expect("valid extension audience") + } + #[tokio::test] async fn mint_and_validate_round_trip() { let (issuer, auth) = pair(); @@ -301,6 +465,54 @@ mod tests { } } + #[tokio::test] + async fn extension_token_cannot_authenticate_as_a_sandbox() { + let mat = generate_jwt_key().expect("jwt key"); + let issuer = SandboxJwtIssuer::from_pem( + mat.signing_key_pem.as_bytes(), + mat.kid.clone(), + "test-gateway", + Duration::from_secs(3600), + ) + .expect("issuer"); + let auth = SandboxJwtAuthenticator::from_pem( + mat.public_key_pem.as_bytes(), + mat.kid.clone(), + "test-gateway", + ) + .expect("authenticator"); + + // Build the token directly to model a credential minted before the + // reserved-audience guard was added. Its claims otherwise satisfy the + // sandbox authenticator's issuer, audience, subject, and expiry checks. + let sandbox_id = "sandbox-a"; + let now = now_secs(); + let claims = ExtensionJwtClaims { + iss: "openshell-gateway:test-gateway".to_string(), + aud: "openshell-gateway:test-gateway".to_string(), + sub: format!("{SPIFFE_SUBJECT_PREFIX}{sandbox_id}"), + iat: now, + exp: now + 300, + jti: uuid::Uuid::new_v4().to_string(), + caller_kind: ExtensionCallerKind::Supervisor, + sandbox_id: Some(sandbox_id.to_string()), + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(mat.kid); + header.typ = Some(EXTENSION_JWT_TYP.to_string()); + let token = encode(&header, &claims, &issuer.encoding_key).expect("extension token"); + + let error = auth + .authenticate(&header_map_with_bearer(&token), "/anything") + .await + .expect_err("extension token must not authenticate as a sandbox"); + assert_eq!(error.code(), tonic::Code::Unauthenticated); + assert_eq!( + error.message(), + "extension tokens cannot authenticate to the gateway" + ); + } + #[tokio::test] async fn ttl_zero_mints_non_expiring_token() { let (issuer, auth) = pair_with_ttl(Duration::ZERO); @@ -393,4 +605,195 @@ mod tests { .expect_err("expired token must reject"); assert_eq!(err.code(), tonic::Code::Unauthenticated); } + + #[test] + fn extension_tokens_have_exact_audience_and_caller_identity() { + let mat = generate_jwt_key().expect("jwt key"); + let issuer = SandboxJwtIssuer::from_pem( + mat.signing_key_pem.as_bytes(), + mat.kid.clone(), + "gateway-a", + Duration::ZERO, + ) + .expect("issuer"); + let decoding_key = DecodingKey::from_ed_pem(mat.public_key_pem.as_bytes()).unwrap(); + + let gateway = issuer + .mint_extension_token( + &extension_audience("urn:openshell:extension:middleware:scanner"), + ExtensionCallerKind::Gateway, + None, + Duration::from_secs(300), + ) + .expect("gateway token"); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&["openshell-gateway:gateway-a"]); + validation.set_audience(&["urn:openshell:extension:middleware:scanner"]); + validation.set_required_spec_claims(&["iss", "aud", "sub", "iat", "exp"]); + let claims = decode::(&gateway.token, &decoding_key, &validation) + .expect("valid extension token") + .claims; + assert_eq!(claims.sub, "openshell-gateway:gateway-a"); + assert_eq!(claims.caller_kind, ExtensionCallerKind::Gateway); + assert_eq!(claims.sandbox_id, None); + assert!(!claims.jti.is_empty()); + assert_eq!(gateway.expires_at_ms, claims.exp * 1000); + + let supervisor = issuer + .mint_extension_token( + &extension_audience("urn:openshell:extension:middleware:scanner"), + ExtensionCallerKind::Supervisor, + Some("sandbox-a"), + Duration::from_secs(300), + ) + .expect("supervisor token"); + let claims = decode::(&supervisor.token, &decoding_key, &validation) + .expect("valid supervisor extension token") + .claims; + assert_eq!(claims.sub, "spiffe://openshell/sandbox/sandbox-a"); + assert_eq!(claims.caller_kind, ExtensionCallerKind::Supervisor); + assert_eq!(claims.sandbox_id.as_deref(), Some("sandbox-a")); + } + + #[test] + fn extension_tokens_are_explicitly_typed_and_sandbox_tokens_are_not() { + let mat = generate_jwt_key().expect("jwt key"); + let issuer = SandboxJwtIssuer::from_pem( + mat.signing_key_pem.as_bytes(), + mat.kid.clone(), + "gateway-a", + Duration::from_secs(3600), + ) + .expect("issuer"); + + let extension = issuer + .mint_extension_token( + &extension_audience("urn:openshell:extension:middleware:scanner"), + ExtensionCallerKind::Gateway, + None, + Duration::from_secs(300), + ) + .expect("extension token"); + assert_eq!( + decode_header(&extension.token).unwrap().typ.as_deref(), + Some(EXTENSION_JWT_TYP) + ); + + // The discriminator is only useful if the sandbox bootstrap token does + // not carry it. A verifier requiring `openshell-ext+jwt` must reject a + // gateway admission credential even though both share a signing key. + let sandbox = issuer.mint("sandbox-a").expect("sandbox token"); + assert_ne!( + decode_header(&sandbox.token).unwrap().typ.as_deref(), + Some(EXTENSION_JWT_TYP) + ); + } + + #[test] + fn extension_token_rejects_wrong_audience() { + let mat = generate_jwt_key().expect("jwt key"); + let issuer = SandboxJwtIssuer::from_pem( + mat.signing_key_pem.as_bytes(), + mat.kid, + "gateway-a", + Duration::ZERO, + ) + .expect("issuer"); + let minted = issuer + .mint_extension_token( + &extension_audience("service-a"), + ExtensionCallerKind::Gateway, + None, + Duration::from_secs(60), + ) + .expect("token"); + let decoding_key = DecodingKey::from_ed_pem(mat.public_key_pem.as_bytes()).unwrap(); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&["openshell-gateway:gateway-a"]); + validation.set_audience(&["service-b"]); + assert!(decode::(&minted.token, &decoding_key, &validation).is_err()); + } + + #[test] + fn extension_token_rejects_gateway_sandbox_audience() { + let (issuer, _) = pair(); + let error = issuer + .mint_extension_token( + &extension_audience("openshell-gateway:test-gateway"), + ExtensionCallerKind::Supervisor, + Some("sandbox-a"), + Duration::from_secs(60), + ) + .expect_err("gateway sandbox audience must be reserved"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert_eq!( + error.message(), + "extension audience must not equal the gateway sandbox audience" + ); + } + + #[test] + fn extension_token_enforces_positive_bounded_ttl_and_caller_shape() { + let (issuer, _) = pair_with_ttl(Duration::ZERO); + for ttl in [ + Duration::ZERO, + MAX_EXTENSION_TOKEN_TTL + Duration::from_secs(1), + ] { + let error = issuer + .mint_extension_token( + &extension_audience("service"), + ExtensionCallerKind::Gateway, + None, + ttl, + ) + .expect_err("invalid TTL"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } + assert!( + issuer + .mint_extension_token( + &extension_audience("service"), + ExtensionCallerKind::Supervisor, + None, + Duration::from_secs(60), + ) + .is_err() + ); + assert!( + issuer + .mint_extension_token( + &extension_audience("service"), + ExtensionCallerKind::Gateway, + Some("sandbox-a"), + Duration::from_secs(60), + ) + .is_err() + ); + assert!(ExtensionAudience::new(" ").is_err()); + } + + #[test] + fn jwks_contains_public_ed25519_key_without_pem_material() { + let mat = generate_jwt_key().expect("jwt key"); + let auth = SandboxJwtAuthenticator::from_pem( + mat.public_key_pem.as_bytes(), + mat.kid.clone(), + "gateway-a", + ) + .expect("authenticator"); + let jwks = auth.jwks(); + assert_eq!(jwks.keys.len(), 1); + let key = &jwks.keys[0]; + assert_eq!(key.kid, mat.kid); + assert_eq!(key.kty, "OKP"); + assert_eq!(key.crv, "Ed25519"); + assert_eq!(key.alg, "EdDSA"); + assert_eq!(key.key_use, "sig"); + assert_eq!(URL_SAFE_NO_PAD.decode(&key.x).unwrap().len(), 32); + + let json = serde_json::to_string(jwks).expect("JSON"); + assert!(!json.contains("BEGIN PUBLIC KEY")); + assert!(!json.contains("PRIVATE")); + assert!(json.contains(r#""use":"sig""#)); + } } diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index a74b1280ce..89f34d1253 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -32,6 +32,9 @@ mod tests { assert!(is_sandbox_callable( "/openshell.inference.v1.Inference/GetInferenceBundle" )); + assert!(is_sandbox_callable( + "/openshell.v1.OpenShell/ExchangeProviderSubjectToken" + )); } #[test] @@ -42,6 +45,8 @@ mod tests { assert!(!is_sandbox_callable( "/openshell.v1.OpenShell/DeleteSandbox" )); + assert!(!is_sandbox_callable("/openshell.v1.OpenShell/StopSandbox")); + assert!(!is_sandbox_callable("/openshell.v1.OpenShell/StartSandbox")); assert!(!is_sandbox_callable( "/openshell.v1.OpenShell/CreateProvider" )); diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 8b18034947..33008774b9 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -6,8 +6,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; -use openshell_core::ComputeDriverKind; -use openshell_core::config::DEFAULT_SERVER_PORT; +use openshell_core::config::{DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::{error, info, warn}; @@ -17,7 +16,7 @@ use crate::certgen; use crate::compute::driver_config::GuestTlsPaths; use crate::config_file::{self, ConfigFile, GatewayFileSection}; use crate::defaults::{self, LocalTlsPaths}; -use crate::{ServerStartupConfig, run_server, tracing_bus::TracingLogBus}; +use crate::{ComputeDriverRegistry, ServerStartupConfig, run_server, tracing_bus::TracingLogBus}; /// `OpenShell` gateway process - gRPC and HTTP server with protocol multiplexing. /// @@ -52,6 +51,14 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")] config: Option, + /// Operator-assigned name for this gateway installation. + #[arg( + long = "name", + default_value = DEFAULT_GATEWAY_NAME, + env = "OPENSHELL_GATEWAY_NAME" + )] + name: String, + /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] bind_address: IpAddr, @@ -96,12 +103,10 @@ struct RunArgs { /// Compute drivers configured for this gateway. /// - /// Accepts a comma-delimited list such as `kubernetes` or - /// `kubernetes,podman`. The configuration format is future-proofed for - /// multiple drivers, but the gateway currently requires exactly one. - /// When unset, the gateway auto-detects the driver based on the runtime - /// environment (Kubernetes → Podman → Docker). VM is never - /// auto-detected and requires explicit configuration. + /// Accepts a comma-delimited list of registered driver names. The + /// configuration format is future-proofed for multiple drivers, but the + /// gateway currently requires exactly one. When unset, the gateway runs + /// detection probes supplied by the drivers compiled into the binary. #[arg( long, alias = "driver", @@ -115,9 +120,10 @@ struct RunArgs { /// implementing `compute_driver.proto`. /// /// When set, the socket is associated with the single driver name supplied - /// by `--drivers` or `OPENSHELL_DRIVERS`. Reserved built-in driver names - /// such as Docker, Podman, Kubernetes, and VM do not accept socket - /// endpoints. + /// by `--drivers` or `OPENSHELL_DRIVERS` and replaces normal construction + /// for that selected name, including a compiled registration with the same + /// name. The gateway connects to this operator-provided endpoint; it does + /// not provision the remote driver. #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] compute_driver_socket: Option, @@ -135,9 +141,9 @@ struct RunArgs { /// Enable mTLS client certificate authentication for local single-user gateways. /// - /// When unset, this defaults on for Docker, Podman, and VM gateways that - /// have client certificate verification configured and no OIDC issuer. - /// Kubernetes deployments must use OIDC or fronting-proxy auth instead. + /// When unset, this defaults on for drivers registered as local + /// single-player backends when client certificate verification is + /// configured and no OIDC issuer is present. #[arg( long = "enable-mtls-auth", env = "OPENSHELL_ENABLE_MTLS_AUTH", @@ -219,6 +225,11 @@ pub fn command() -> Command { } pub async fn run_cli() -> Result<()> { + run_cli_with_compute_drivers(ComputeDriverRegistry::new()).await +} + +/// Run the gateway CLI with the compute drivers linked by the binary. +pub async fn run_cli_with_compute_drivers(compute_drivers: ComputeDriverRegistry) -> Result<()> { rustls::crypto::ring::default_provider() .install_default() .map_err(|e| miette::miette!("failed to install rustls crypto provider: {e:?}"))?; @@ -228,11 +239,20 @@ pub async fn run_cli() -> Result<()> { match cli.command { Some(Commands::GenerateCerts(args)) => certgen::run(args).await, - None => Box::pin(run_from_args(cli.run, matches)).await, + None => Box::pin(run_from_args(cli.run, matches, compute_drivers)).await, } } +#[cfg(test)] fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result { + prepare_server_config_with_drivers(args, matches, &ComputeDriverRegistry::new()) +} + +fn prepare_server_config_with_drivers( + args: &mut RunArgs, + matches: &ArgMatches, + compute_drivers: &ComputeDriverRegistry, +) -> Result { // Load TOML when explicitly requested, or from the default XDG location // when that file exists. Missing default config is not an error: runtime // defaults and OPENSHELL_* env vars are enough for package-managed starts. @@ -246,6 +266,10 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result Result Result Result Result Result Result Result<()> { - let prepared = prepare_server_config(&mut args, &matches)?; +async fn run_from_args( + mut args: RunArgs, + matches: ArgMatches, + compute_drivers: ComputeDriverRegistry, +) -> Result<()> { + let prepared = prepare_server_config_with_drivers(&mut args, &matches, &compute_drivers)?; let tracing_log_bus = TracingLogBus::new(); let otlp_config = prepared .config_file .as_ref() .and_then(|f| f.openshell.gateway.otlp.as_ref()); + let gateway_resource = crate::otel_tracing::GatewayResourceAttributes::new( + Some(prepared.config.name.as_str()), + Some(prepared.compute_driver.name()), + ); + let compute_driver_tracing = compute_drivers.in_process_tracing( + &prepared.compute_driver, + &prepared.config.compute_driver_endpoints, + ); let (tracing_handle, setup_error) = crate::tracing_setup::install( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)), &tracing_log_bus, otlp_config, + compute_driver_tracing, + gateway_resource, ); let has_client_ca = prepared @@ -512,7 +580,7 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> { info!(bind = %prepared.config.bind_address, "Starting OpenShell server"); - let result = Box::pin(run_server(prepared, tracing_log_bus)).await; + let result = Box::pin(run_server(prepared, tracing_log_bus, compute_drivers)).await; tracing_handle.shutdown(); @@ -606,6 +674,11 @@ fn resolve_aux_listener( /// The function intentionally does not touch `database_url` — that secret is /// env-only and the loader already rejected it when it appears in the file. fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: &ArgMatches) { + if let Some(name) = &file.name + && arg_defaulted(matches, "name") + { + args.name.clone_from(name); + } if let Some(addr) = file.bind_address { if arg_defaulted(matches, "bind_address") { args.bind_address = addr.ip(); @@ -730,7 +803,7 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches } if arg_defaulted(matches, "drivers") { return Err(miette::miette!( - "--compute-driver-socket requires --drivers or OPENSHELL_DRIVERS= to select a non-reserved compute driver name" + "--compute-driver-socket requires --drivers or OPENSHELL_DRIVERS= to select a compute driver name" )); } @@ -738,19 +811,6 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches [driver] => { let driver = openshell_core::config::normalize_compute_driver_name(driver) .map_err(|err| miette::miette!("{err}"))?; - if matches!( - driver.parse::().ok(), - Some( - ComputeDriverKind::Docker - | ComputeDriverKind::Podman - | ComputeDriverKind::Kubernetes - | ComputeDriverKind::Vm - ) - ) { - return Err(miette::miette!( - "--compute-driver-socket cannot be combined with reserved built-in compute driver '{driver}'" - )); - } args.drivers[0] = driver; Ok(()) } @@ -761,25 +821,15 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches } } -fn effective_single_driver(args: &RunArgs) -> Option { - match args.drivers.as_slice() { - [] => openshell_core::config::detect_driver(), - [driver] => driver.parse().ok(), - _ => None, - } -} - -fn is_singleplayer_driver(args: &RunArgs) -> bool { - matches!( - effective_single_driver(args), - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm) - ) +fn is_singleplayer_driver(registration: Option<&crate::ComputeDriverRegistration>) -> bool { + registration.is_some_and(crate::ComputeDriverRegistration::is_local_singleplayer) } fn resolve_mtls_auth_enabled( args: &RunArgs, matches: &ArgMatches, file: Option<&ConfigFile>, + selected_registration: Option<&crate::ComputeDriverRegistration>, ) -> bool { let file_configured = file .and_then(|f| f.openshell.gateway.mtls_auth.as_ref()) @@ -792,7 +842,7 @@ fn resolve_mtls_auth_enabled( return false; } - is_singleplayer_driver(args) + is_singleplayer_driver(selected_registration) } #[cfg(test)] @@ -801,6 +851,55 @@ mod tests { use crate::TEST_ENV_LOCK as ENV_LOCK; use clap::Parser; use std::net::{IpAddr, Ipv4Addr}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static REGISTRY_DETECTION_CALLS: AtomicUsize = AtomicUsize::new(0); + + fn detect_registered_local() -> bool { + REGISTRY_DETECTION_CALLS.fetch_add(1, Ordering::SeqCst); + true + } + + #[derive(Clone, Copy)] + struct TestFactory; + + #[async_trait::async_trait] + impl crate::ComputeDriverFactory for TestFactory { + async fn build( + &self, + _context: crate::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + unreachable!("CLI metadata tests do not build drivers") + } + } + + fn test_registry(name: &str, singleplayer: bool, mtls: bool) -> crate::ComputeDriverRegistry { + let mut registration = + crate::ComputeDriverRegistration::new(name, 100, None, TestFactory).unwrap(); + if singleplayer { + registration = registration.with_local_singleplayer(); + } + if !mtls { + registration = registration.without_mtls_user_auth(); + } + let mut registry = crate::ComputeDriverRegistry::new(); + registry.install(registration).unwrap(); + registry + } + + fn detected_local_registry() -> crate::ComputeDriverRegistry { + let registration = crate::ComputeDriverRegistration::new( + "local", + 100, + Some(detect_registered_local), + TestFactory, + ) + .unwrap() + .with_local_singleplayer(); + let mut registry = crate::ComputeDriverRegistry::new(); + registry.install(registration).unwrap(); + registry + } struct EnvVarGuard { key: &'static str, @@ -1198,11 +1297,15 @@ mod tests { let expected = format!( "sqlite:{}", - tmp.path().join("openshell/gateway/openshell.db").display() + 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/gateway").is_dir()); + assert!(tmp.path().join("openshell").join("gateway").is_dir()); } #[test] @@ -1255,7 +1358,7 @@ mod tests { "--db-url", "sqlite::memory:", "--drivers", - "docker", + "local", "--tls-cert", "/tmp/server.crt", "--tls-key", @@ -1264,11 +1367,51 @@ mod tests { "/tmp/ca.crt", ]); - assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); + assert!(super::resolve_mtls_auth_enabled( + &args, + &matches, + None, + test_registry("local", true, true).get("local") + )); } #[test] - fn mtls_auth_does_not_auto_default_for_kubernetes_driver() { + 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_DRIVERS"); + 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_drivers.is_empty()); + assert!(prepared.config.mtls_auth.enabled); + assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 1); + } + + #[test] + fn mtls_auth_does_not_auto_default_for_shared_driver() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1279,7 +1422,7 @@ mod tests { "--db-url", "sqlite::memory:", "--drivers", - "kubernetes", + "shared", "--tls-cert", "/tmp/server.crt", "--tls-key", @@ -1288,7 +1431,12 @@ mod tests { "/tmp/ca.crt", ]); - assert!(!super::resolve_mtls_auth_enabled(&args, &matches, None)); + assert!(!super::resolve_mtls_auth_enabled( + &args, + &matches, + None, + test_registry("shared", false, false).get("shared") + )); } #[test] @@ -1303,7 +1451,7 @@ mod tests { "--db-url", "sqlite::memory:", "--drivers", - "docker", + "local", "--tls-cert", "/tmp/server.crt", "--tls-key", @@ -1323,7 +1471,8 @@ enabled = false assert!(!super::resolve_mtls_auth_enabled( &args, &matches, - Some(&file) + Some(&file), + test_registry("local", true, true).get("local") )); } @@ -1335,12 +1484,14 @@ enabled = false 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#" [openshell.gateway] +name = "production-us-west" bind_address = "0.0.0.0:9090" log_level = "debug" "#, @@ -1350,6 +1501,7 @@ log_level = "debug" 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] @@ -1359,6 +1511,7 @@ log_level = "debug" .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", @@ -1366,16 +1519,20 @@ log_level = "debug" "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); assert_eq!(args.log_level, "warn", "CLI flag must win over file"); + assert_eq!(args.name, "cli-gateway"); } #[test] @@ -1384,18 +1541,21 @@ log_level = "debug" .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#" [openshell.gateway] +name = "file-gateway" log_level = "debug" "#, ); 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"); } #[test] @@ -1550,38 +1710,12 @@ ssh_session_ttl_secs = 1234 } #[test] - fn singleplayer_driver_matches_only_one_local_driver() { - for driver in ["docker", "podman", "vm"] { - let (args, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--drivers", - driver, - ]); - assert!( - super::is_singleplayer_driver(&args), - "{driver} should be singleplayer" - ); - } + fn singleplayer_behavior_comes_from_registration() { + let local = test_registry("local", true, true); + assert!(super::is_singleplayer_driver(local.get("local"))); - let (k8s, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--drivers", - "kubernetes", - ]); - assert!(!super::is_singleplayer_driver(&k8s)); - - let (multi, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--drivers", - "docker,podman", - ]); - assert!(!super::is_singleplayer_driver(&multi)); + let shared = test_registry("shared", false, true); + assert!(!super::is_singleplayer_driver(shared.get("shared"))); } #[test] @@ -1607,7 +1741,6 @@ ssh_session_ttl_secs = 1234 Some(std::path::Path::new("/run/openshell/kyma.sock")) ); assert_eq!(args.drivers, ["kyma"]); - assert!(super::effective_single_driver(&args).is_none()); } #[test] @@ -1634,7 +1767,7 @@ ssh_session_ttl_secs = 1234 } #[test] - fn compute_driver_socket_rejects_reserved_builtin_drivers() { + fn compute_driver_socket_accepts_canonical_builtin_driver_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1650,16 +1783,12 @@ ssh_session_ttl_secs = 1234 "--compute-driver-socket", "/run/openshell/extension.sock", ]); - let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err(); - assert!( - err.to_string() - .contains("cannot be combined with reserved built-in compute driver 'docker'"), - "unexpected error: {err}" - ); + super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap(); + assert_eq!(args.drivers, ["docker"]); } #[test] - fn compute_driver_socket_rejects_vm_endpoint() { + fn compute_driver_socket_accepts_vm_endpoint() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1675,12 +1804,8 @@ ssh_session_ttl_secs = 1234 "--compute-driver-socket", "/run/openshell/vm.sock", ]); - let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err(); - assert!( - err.to_string() - .contains("cannot be combined with reserved built-in compute driver 'vm'"), - "unexpected error: {err}" - ); + super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap(); + assert_eq!(args.drivers, ["vm"]); } #[test] @@ -1807,51 +1932,4 @@ mem_mib = "not-a-number" assert!(file.openshell.drivers.contains_key("docker")); assert!(file.openshell.drivers.contains_key("vm")); } - - #[test] - fn driver_inherits_shared_image_from_gateway_section() { - // [openshell.gateway].default_image inherits into the K8s driver - // table when the driver-specific table does not set it. - let file = config_file_from_toml( - r#" -[openshell.gateway] -default_image = "ghcr.io/nvidia/openshell/sandbox:1.0" - -[openshell.drivers.kubernetes] -namespace = "agents" -"#, - ); - let merged = crate::config_file::driver_table( - super::ComputeDriverKind::Kubernetes.as_str(), - &file.openshell.gateway, - file.openshell.drivers.get("kubernetes"), - ); - let parsed = merged - .try_into::() - .expect("merged table deserializes"); - assert_eq!(parsed.default_image, "ghcr.io/nvidia/openshell/sandbox:1.0"); - assert_eq!(parsed.namespace, "agents"); - } - - #[test] - fn driver_specific_value_overrides_gateway_inheritance() { - let file = config_file_from_toml( - r#" -[openshell.gateway] -default_image = "gateway-default:1.0" - -[openshell.drivers.kubernetes] -default_image = "k8s-specific:1.0" -"#, - ); - let merged = crate::config_file::driver_table( - super::ComputeDriverKind::Kubernetes.as_str(), - &file.openshell.gateway, - file.openshell.drivers.get("kubernetes"), - ); - let parsed = merged - .try_into::() - .expect("deserializes"); - assert_eq!(parsed.default_image, "k8s-specific:1.0"); - } } diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index f56d233f2f..d06e6fbc8f 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -3,22 +3,17 @@ //! Selected compute-driver config construction. //! -//! This module owns loading the selected driver config from TOML, applying -//! driver-specific environment overrides, and applying gateway startup defaults. -//! It does not acquire, connect to, or start compute drivers. +//! This module owns loading the selected driver config from TOML and applying +//! gateway startup defaults and endpoint overrides. It does not acquire, +//! connect to, or start compute drivers. use crate::config_file; use crate::defaults::LocalTlsPaths; -use openshell_core::{ComputeDriverKind, Error, Result}; -use openshell_driver_docker::DockerComputeConfig; -use openshell_driver_kubernetes::KubernetesComputeConfig; -use openshell_driver_podman::PodmanComputeConfig; +use openshell_core::{Error, Result}; use serde::Deserialize; use std::collections::BTreeMap; use std::path::PathBuf; -use super::VmComputeConfig; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct GuestTlsPaths { ca: PathBuf, @@ -26,6 +21,12 @@ pub struct GuestTlsPaths { key: PathBuf, } +impl GuestTlsPaths { + pub(crate) fn as_paths(&self) -> (&std::path::Path, &std::path::Path, &std::path::Path) { + (&self.ca, &self.cert, &self.key) + } +} + impl From<&LocalTlsPaths> for GuestTlsPaths { fn from(paths: &LocalTlsPaths) -> Self { Self { @@ -45,83 +46,47 @@ pub struct DriverStartupContext<'a> { pub endpoint_overrides: &'a BTreeMap, } -/// Build the selected Kubernetes config from TOML plus runtime defaults. -pub fn kubernetes_config_from_context( - context: DriverStartupContext<'_>, -) -> Result { - let mut cfg = driver_config_from_context(context, ComputeDriverKind::Kubernetes.as_str())?; - apply_kubernetes_runtime_defaults(&mut cfg); - Ok(cfg) -} - -pub fn kubernetes_config_for_k8s_sa_bootstrap( - file: Option<&config_file::ConfigFile>, -) -> Result { - let Some(file) = file else { - return Err(Error::config( - "K8s ServiceAccount bootstrap requires [openshell.drivers.kubernetes] when sandbox JWT issuing is enabled in-cluster", - )); - }; - if !file.openshell.drivers.contains_key("kubernetes") { - return Err(Error::config( - "K8s ServiceAccount bootstrap requires [openshell.drivers.kubernetes] when sandbox JWT issuing is enabled in-cluster", - )); - } - driver_config_from_file(Some(file), ComputeDriverKind::Kubernetes.as_str()) -} - -/// Build the selected Podman config from TOML plus runtime defaults. -pub fn podman_config_from_context( - context: DriverStartupContext<'_>, -) -> Result { - let mut podman = driver_config_from_context(context, ComputeDriverKind::Podman.as_str())?; - apply_podman_runtime_defaults(&mut podman, context); - Ok(podman) -} - -/// Build the selected Docker config from TOML plus runtime defaults. -pub fn docker_config_from_context( - context: DriverStartupContext<'_>, -) -> Result { - let mut cfg = driver_config_from_context(context, ComputeDriverKind::Docker.as_str())?; - apply_docker_runtime_defaults(&mut cfg, context); - Ok(cfg) -} - -/// Build the selected VM config from TOML plus runtime defaults. -pub fn vm_config_from_context(context: DriverStartupContext<'_>) -> Result { - let mut cfg = driver_config_from_context(context, ComputeDriverKind::Vm.as_str())?; - apply_vm_runtime_defaults(&mut cfg, context); - Ok(cfg) -} - pub fn remote_driver_config_from_context( context: DriverStartupContext<'_>, name: &str, ) -> Result { - let mut cfg = driver_config_from_context(context, name)?; + let mut cfg = RemoteDriverConfig::default(); + if let Some(file) = context.file { + let merged = config_file::driver_table( + name, + &file.openshell.gateway, + file.openshell.drivers.get(name), + ); + if let Some(socket_path) = merged.get("socket_path").and_then(toml::Value::as_str) { + cfg.socket_path = PathBuf::from(socket_path); + } + } apply_remote_driver_overrides(&mut cfg, context, name); validate_remote_driver_config(&cfg, name)?; Ok(cfg) } #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct RemoteDriverConfig { #[serde(default)] pub socket_path: PathBuf, } -fn driver_config_from_context(context: DriverStartupContext<'_>, driver_name: &str) -> Result +pub fn driver_config_from_context( + context: DriverStartupContext<'_>, + driver_name: &str, + inherited_config_keys: &[&str], +) -> Result where T: Default + serde::de::DeserializeOwned, { - driver_config_from_file(context.file, driver_name) + driver_config_from_file(context.file, driver_name, inherited_config_keys) } fn driver_config_from_file( file: Option<&config_file::ConfigFile>, driver_name: &str, + inherited_config_keys: &[&str], ) -> Result where T: Default + serde::de::DeserializeOwned, @@ -129,10 +94,11 @@ where let Some(file) = file else { return Ok(T::default()); }; - let merged = config_file::driver_table( + let merged = config_file::driver_table_with_inherited_keys( driver_name, &file.openshell.gateway, file.openshell.drivers.get(driver_name), + inherited_config_keys, ); merged.try_into().map_err(|e| { Error::config(format!( @@ -141,70 +107,6 @@ where }) } -fn apply_kubernetes_runtime_defaults(k8s: &mut KubernetesComputeConfig) { - if let Ok(size) = std::env::var("OPENSHELL_K8S_WORKSPACE_DEFAULT_STORAGE_SIZE") { - k8s.workspace_default_storage_size = size; - } - if let Ok(storage_class) = std::env::var("OPENSHELL_K8S_WORKSPACE_STORAGE_CLASS") { - k8s.workspace_storage_class = storage_class; - } -} - -fn apply_podman_runtime_defaults( - podman: &mut PodmanComputeConfig, - context: DriverStartupContext<'_>, -) { - podman.gateway_port = context.gateway_port; - apply_podman_env_overrides(podman); - apply_guest_tls_defaults_to_split_fields( - &mut podman.guest_tls_ca, - &mut podman.guest_tls_cert, - &mut podman.guest_tls_key, - context.guest_tls, - ); -} - -fn apply_docker_runtime_defaults(cfg: &mut DockerComputeConfig, context: DriverStartupContext<'_>) { - apply_guest_tls_defaults_to_split_fields( - &mut cfg.guest_tls_ca, - &mut cfg.guest_tls_cert, - &mut cfg.guest_tls_key, - context.guest_tls, - ); -} - -fn apply_vm_runtime_defaults(cfg: &mut VmComputeConfig, context: DriverStartupContext<'_>) { - if cfg.state_dir.as_os_str().is_empty() { - cfg.state_dir = VmComputeConfig::default_state_dir(); - } - if cfg.grpc_endpoint.trim().is_empty() - && (!context.gateway_tls_enabled || context.guest_tls.is_some()) - { - let scheme = if context.gateway_tls_enabled { - "https" - } else { - "http" - }; - cfg.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port); - } - - apply_guest_tls_defaults_to_split_fields( - &mut cfg.guest_tls_ca, - &mut cfg.guest_tls_cert, - &mut cfg.guest_tls_key, - context.guest_tls, - ); -} - -fn apply_podman_env_overrides(podman: &mut PodmanComputeConfig) { - if let Ok(p) = std::env::var("OPENSHELL_PODMAN_SOCKET") { - podman.socket_path = Some(PathBuf::from(p)); - } - if let Ok(ip) = std::env::var("OPENSHELL_PODMAN_HOST_GATEWAY_IP") { - podman.host_gateway_ip = ip; - } -} - fn apply_remote_driver_overrides( cfg: &mut RemoteDriverConfig, context: DriverStartupContext<'_>, @@ -224,23 +126,6 @@ fn validate_remote_driver_config(cfg: &RemoteDriverConfig, name: &str) -> Result ))) } -fn apply_guest_tls_defaults_to_split_fields( - ca: &mut Option, - cert: &mut Option, - key: &mut Option, - defaults: Option<&GuestTlsPaths>, -) { - if ca.is_none() - && cert.is_none() - && key.is_none() - && let Some(paths) = defaults - { - *ca = Some(paths.ca.clone()); - *cert = Some(paths.cert.clone()); - *key = Some(paths.key.clone()); - } -} - #[cfg(test)] mod tests { use super::*; @@ -266,93 +151,42 @@ mod tests { } #[test] - fn k8s_sa_bootstrap_rejects_missing_kubernetes_driver_config() { - let err = kubernetes_config_for_k8s_sa_bootstrap(None).unwrap_err(); - assert!(err.to_string().contains("[openshell.drivers.kubernetes]")); - - let file: config_file::ConfigFile = - toml::from_str("[openshell.gateway]\n").expect("valid config"); - let err = kubernetes_config_for_k8s_sa_bootstrap(Some(&file)).unwrap_err(); - assert!(err.to_string().contains("[openshell.drivers.kubernetes]")); - } - - #[test] - fn k8s_sa_bootstrap_uses_configured_namespace_and_service_account() { + fn remote_driver_config_reads_socket_path_from_named_table() { let file: config_file::ConfigFile = toml::from_str( r#" -[openshell.gateway] - -[openshell.drivers.kubernetes] -namespace = "sandboxes" -service_account_name = "sandbox-sa" +[openshell.drivers.kyma] +socket_path = "/run/openshell/kyma.sock" "#, ) .expect("valid config"); - let cfg = kubernetes_config_for_k8s_sa_bootstrap(Some(&file)).unwrap(); - assert_eq!(cfg.namespace, "sandboxes"); - assert_eq!(cfg.service_account_name, "sandbox-sa"); - } - - #[test] - fn podman_config_reads_bind_mount_opt_in_from_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r" -[openshell.drivers.podman] -enable_bind_mounts = true -", - ) - .expect("valid config"); - - let cfg = podman_config_from_context(test_context(Some(&file))).expect("podman config"); - - assert!(cfg.enable_bind_mounts); - } - - #[test] - fn docker_config_reads_bind_mount_opt_in_from_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r" -[openshell.drivers.docker] -enable_bind_mounts = true -", - ) - .expect("valid config"); - - let cfg = docker_config_from_context(test_context(Some(&file))).expect("docker config"); + let cfg = remote_driver_config_from_context(test_context(Some(&file)), "kyma") + .expect("remote config"); - assert!(cfg.enable_bind_mounts); + assert_eq!(cfg.socket_path, PathBuf::from("/run/openshell/kyma.sock")); } #[test] - fn docker_config_reads_socket_path_from_driver_table() { + fn remote_driver_config_ignores_in_process_driver_fields() { let file: config_file::ConfigFile = toml::from_str( r#" -[openshell.drivers.docker] -socket_path = "/tmp/docker.sock" -"#, - ) - .expect("valid config"); - - let cfg = docker_config_from_context(test_context(Some(&file))).expect("docker config"); - - assert_eq!(cfg.socket_path, Some(PathBuf::from("/tmp/docker.sock"))); - } +[openshell.gateway] +sandbox_namespace = "sandboxes" - #[test] - fn remote_driver_config_reads_socket_path_from_named_table() { - let file: config_file::ConfigFile = toml::from_str( - r#" -[openshell.drivers.kyma] -socket_path = "/run/openshell/kyma.sock" +[openshell.drivers.kubernetes] +socket_path = "/run/openshell/kubernetes.sock" +workspace_mode = "shared" +service_account_name = "sandbox-sa" "#, ) .expect("valid config"); - let cfg = remote_driver_config_from_context(test_context(Some(&file)), "kyma") + let cfg = remote_driver_config_from_context(test_context(Some(&file)), "kubernetes") .expect("remote config"); - - assert_eq!(cfg.socket_path, PathBuf::from("/run/openshell/kyma.sock")); + assert_eq!( + cfg.socket_path, + PathBuf::from("/run/openshell/kubernetes.sock") + ); } #[test] @@ -399,40 +233,4 @@ socket_path = "/run/openshell/kyma.sock" .contains("remote compute driver 'kyma' requires socket_path") ); } - - #[test] - fn docker_config_reports_selected_invalid_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r" -[openshell.drivers.docker] -unknown_docker_key = true -", - ) - .expect("valid config"); - - let err = docker_config_from_context(test_context(Some(&file))).unwrap_err(); - - assert!( - err.to_string() - .contains("invalid [openshell.drivers.docker] table") - ); - } - - #[test] - fn vm_config_reports_selected_invalid_driver_table() { - let file: config_file::ConfigFile = toml::from_str( - r#" -[openshell.drivers.vm] -mem_mib = "not-a-number" -"#, - ) - .expect("valid config"); - - let err = vm_config_from_context(test_context(Some(&file))).unwrap_err(); - - assert!( - err.to_string() - .contains("invalid [openshell.drivers.vm] table") - ); - } } diff --git a/crates/openshell-server/src/compute/lease.rs b/crates/openshell-server/src/compute/lease.rs index bf58fae48b..3310946dab 100644 --- a/crates/openshell-server/src/compute/lease.rs +++ b/crates/openshell-server/src/compute/lease.rs @@ -242,10 +242,10 @@ impl ReconcilerLease { /// Derive a stable replica identity for lease ownership. /// -/// Kubernetes sets `HOSTNAME` to the pod name, Docker sets it to the -/// container ID, and systemd units inherit the machine hostname. -/// `OPENSHELL_REPLICA_ID` allows explicit override. The UUID fallback -/// handles edge cases where neither env var is set. +/// Managed workloads commonly receive a stable runtime identity through +/// `HOSTNAME`, while systemd units inherit the machine hostname. +/// `OPENSHELL_REPLICA_ID` allows an explicit override. The UUID fallback +/// handles environments where neither variable is set. pub fn replica_id() -> String { std::env::var("OPENSHELL_REPLICA_ID") .or_else(|_| std::env::var("HOSTNAME")) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index ba7eff3e92..5f7524f838 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -5,48 +5,40 @@ pub mod driver_config; pub mod lease; -pub mod vm; - -pub use openshell_driver_docker::DockerComputeConfig; -pub use openshell_driver_kubernetes::KubernetesComputeConfig; -pub use openshell_driver_podman::PodmanComputeConfig; -pub use vm::VmComputeConfig; use crate::grpc::policy::SANDBOX_SETTINGS_OBJECT_TYPE; use crate::otel_tracing::TraceContextInterceptor; use crate::persistence::{ - DRAFT_CHUNK_OBJECT_TYPE, ObjectId, ObjectName, ObjectRecord, ObjectType, POLICY_OBJECT_TYPE, - Store, WriteCondition, + DRAFT_CHUNK_OBJECT_TYPE, ObjectCursor, ObjectId, ObjectName, ObjectRecord, ObjectType, + POLICY_OBJECT_TYPE, Store, WriteCondition, }; use crate::sandbox_index::SandboxIndex; use crate::sandbox_watch::SandboxWatchBus; use crate::supervisor_session::SupervisorSessionRegistry; use crate::tracing_bus::TracingLogBus; use futures::{Stream, StreamExt}; +#[cfg(unix)] use hyper_util::rt::TokioIo; -use openshell_core::ComputeDriverKind; use openshell_core::proto::compute::v1::{ - CreateSandboxRequest, DeleteSandboxRequest, DriverCondition, DriverPlatformEvent, - DriverResourceRequirements, DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, - DriverSandboxTemplate, GatewayListenerRequirement as ProtoGatewayListenerRequirement, - GetCapabilitiesRequest, GetGatewayListenerRequirementsRequest, - GetGatewayListenerRequirementsResponse, GetSandboxRequest, - GpuResourceRequirements as DriverGpuResourceRequirements, ListSandboxesRequest, - ResourceRequirements as DriverSandboxResourceRequirements, ValidateSandboxCreateRequest, - WatchSandboxesEvent, WatchSandboxesRequest, compute_driver_client::ComputeDriverClient, + AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, + DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, + DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, DriverSandboxTemplate, + EnsureWorkspaceRequest, EnsureWorkspaceResponse, + GatewayListenerRequirement as ProtoGatewayListenerRequirement, GetCapabilitiesRequest, + GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, + GetSandboxRequest, GpuResourceRequirements as DriverGpuResourceRequirements, + ListSandboxesRequest, ResourceRequirements as DriverSandboxResourceRequirements, + StartSandboxRequest, StopSandboxRequest, ValidateSandboxCreateRequest, WatchSandboxesEvent, + WatchSandboxesRequest, compute_driver_client::ComputeDriverClient, compute_driver_server::ComputeDriver, gateway_listener_requirement::Selector, watch_sandboxes_event, }; use openshell_core::proto::{ PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, ServiceEndpoint, SshSession, + SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, }; +use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; -use openshell_driver_docker::DockerComputeDriver; -use openshell_driver_kubernetes::{ - ComputeDriverService as KubernetesDriverService, KubernetesComputeDriver, -}; -use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; use prost::Message; use std::collections::HashMap; use std::fmt; @@ -54,21 +46,28 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::{Arc, Mutex as StdMutex, Weak}; -use std::time::Duration; +use std::time::{Duration, Instant}; #[cfg(unix)] use tokio::net::UnixStream; use tokio::sync::{Mutex, watch}; -use tonic::transport::{Channel, Endpoint}; +use tonic::transport::Channel; +#[cfg(unix)] +use tonic::transport::Endpoint; use tonic::{Code, Request, Status}; +#[cfg(unix)] use tower::service_fn; use tracing::{Instrument as _, debug, info, warn}; -type DriverWatchStream = Pin> + Send>>; -type SharedComputeDriver = +pub type DriverWatchStream = + Pin> + Send>>; +pub type SharedComputeDriver = Arc + Send + Sync>; use traced_driver::TracedDriver; +const LIFECYCLE_SWEEP_PAGE_SIZE: u32 = 1000; +const SHUTDOWN_STOP_CONCURRENCY: usize = 16; + /// Instrumenting wrapper around the compute driver. mod traced_driver { use std::future::Future; @@ -76,7 +75,9 @@ mod traced_driver { use tonic::Status; use tracing::Instrument as _; - use super::SharedComputeDriver; + use super::{DriverWatchStream, SharedComputeDriver}; + + type TracedWatchStream = openshell_otel::TracedGrpcStream; #[derive(Clone)] pub(super) struct TracedDriver { @@ -89,45 +90,83 @@ mod traced_driver { Self { inner, name } } - /// Run one call across the driver boundary inside its span. - /// - /// Takes a closure rather than a future so the call cannot be built - /// without going through here. - pub(super) async fn call( + fn span( &self, - operation: &'static str, + rpc: openshell_otel::ComputeDriverRpc, sandbox_id: Option<&str>, - call: impl FnOnce(SharedComputeDriver) -> Fut, - ) -> Result - where - Fut: Future>, - { + ) -> tracing::Span { let span = tracing::info_span!( "driver", - otel.name = operation, + otel.name = rpc.operation, otel.kind = "client", otel.status_code = tracing::field::Empty, driver.name = %self.name, sandbox.id = tracing::field::Empty, - grpc.code = tracing::field::Empty, + rpc.system.name = "grpc", + rpc.method = rpc.operation, + rpc.response.status_code = tracing::field::Empty, + error.type = tracing::field::Empty, ); if let Some(sandbox_id) = sandbox_id { span.record("sandbox.id", sandbox_id); } + span + } + + /// Run one call across the driver boundary inside its span. + /// + /// Takes a closure rather than a future so the call cannot be built + /// without going through here. + pub(super) async fn call( + &self, + rpc: openshell_otel::ComputeDriverRpc, + sandbox_id: Option<&str>, + call: impl FnOnce(SharedComputeDriver) -> Fut, + ) -> Result + where + Fut: Future>, + { + let span = self.span(rpc, sandbox_id); let future = call(self.inner.clone()); async { let result = future.await; - if let Err(status) = &result { - let current = tracing::Span::current(); - crate::otel_tracing::mark_error(¤t); - current.record("grpc.code", status.code() as i32); + let current = tracing::Span::current(); + match &result { + Ok(_) => { + openshell_otel::record_grpc_status(¤t, tonic::Code::Ok); + } + Err(status) => { + openshell_otel::record_grpc_status(¤t, status.code()); + } } result } .instrument(span) .await } + + /// Open a driver watch while keeping the client span alive with the stream. + pub(super) async fn watch(&self) -> Result, Status> { + let span = self.span(openshell_otel::rpc::WATCH_SANDBOXES, None); + let result = self + .inner + .clone() + .watch_sandboxes(tonic::Request::new(super::WatchSandboxesRequest {})) + .instrument(span.clone()) + .await; + match result { + Ok(response) => { + let (metadata, inner, extensions) = response.into_parts(); + let stream: DriverWatchStream = Box::pin(TracedWatchStream::new(inner, span)); + Ok(tonic::Response::from_parts(metadata, stream, extensions)) + } + Err(status) => { + openshell_otel::record_grpc_status(&span, status.code()); + Err(status) + } + } + } } } @@ -168,20 +207,20 @@ impl GatewayListenerRequirement { } } -/// Serializes request-side deletes for the same stable sandbox ID. +/// Serializes request-side lifecycle mutations for the same stable sandbox ID. /// /// Watch events deliberately do not use these gates, so a slow driver delete /// cannot block the sequential watch loop. Weak values let entries disappear /// after the last request using a sandbox's gate completes. #[derive(Debug, Default)] -struct DeleteGateRegistry { +struct LifecycleGateRegistry { gates: StdMutex>>>, } -impl DeleteGateRegistry { - async fn lock_for(&self, sandbox_id: &str) -> SandboxDeleteGuard { +impl LifecycleGateRegistry { + async fn lock_for(&self, sandbox_id: &str) -> SandboxLifecycleGuard { let gate = self.gate_for(sandbox_id); - SandboxDeleteGuard { + SandboxLifecycleGuard { _guard: gate.lock_owned().await, } } @@ -190,7 +229,7 @@ impl DeleteGateRegistry { let mut gates = self .gates .lock() - .expect("sandbox delete gate registry lock poisoned"); + .expect("sandbox lifecycle gate registry lock poisoned"); gates.retain(|_, gate| gate.strong_count() > 0); if let Some(gate) = gates.get(sandbox_id).and_then(Weak::upgrade) { @@ -206,18 +245,18 @@ impl DeleteGateRegistry { fn entry_count(&self) -> usize { self.gates .lock() - .expect("sandbox delete gate registry lock poisoned") + .expect("sandbox lifecycle gate registry lock poisoned") .len() } } -/// Proof that the current delete operation holds its sandbox-ID gate. +/// Proof that the current operation holds its sandbox-ID lifecycle gate. /// -/// Delete code must acquire this guard before taking `ComputeRuntime::sync_lock`. -/// Passing it to `lock_global_for_delete` makes that ordering visible at every -/// global-lock acquisition in the delete path. +/// Lifecycle code must acquire this guard before taking `ComputeRuntime::sync_lock`. +/// Passing it to `lock_global_for_lifecycle` makes that ordering visible at +/// every global-lock acquisition in a lifecycle path. #[derive(Debug)] -struct SandboxDeleteGuard { +struct SandboxLifecycleGuard { _guard: tokio::sync::OwnedMutexGuard<()>, } @@ -260,46 +299,14 @@ pub struct ComputeDriverInfoSnapshot { pub driver_name: String, /// Driver-reported implementation version from the startup capability snapshot. pub driver_version: String, + /// Whether the driver asks the gateway to reconcile compute across restarts. + pub gateway_manages_lifecycle: bool, + /// Whether the driver authenticates driver-native sandbox credentials. + pub supports_sandbox_authentication: bool, + /// Whether the driver reports runtime readiness without a supervisor session. + pub driver_reports_runtime_readiness: bool, } -#[tonic::async_trait] -trait ShutdownCleanup: Send + Sync { - async fn cleanup_on_shutdown(&self) -> Result<(), String>; -} - -#[tonic::async_trait] -impl ShutdownCleanup for DockerComputeDriver { - async fn cleanup_on_shutdown(&self) -> Result<(), String> { - let stopped = self - .stop_managed_containers_on_shutdown() - .await - .map_err(|err| err.to_string())?; - info!( - stopped_containers = stopped, - "Stopped Docker sandbox containers during gateway shutdown" - ); - Ok(()) - } -} - -/// Resume a single sandbox whose store record indicates it should be -/// running. Implemented by drivers (currently only Docker) where compute -/// resources do not auto-restart with the gateway. Returns `Ok(true)` if -/// the backend resource was found and resumed (or was already running), -/// `Ok(false)` if no backend resource exists. -#[tonic::async_trait] -trait StartupResume: Send + Sync { - async fn resume_sandbox(&self, sandbox_id: &str, sandbox_name: &str) -> Result; -} - -#[tonic::async_trait] -impl StartupResume for DockerComputeDriver { - async fn resume_sandbox(&self, sandbox_id: &str, sandbox_name: &str) -> Result { - Self::resume_sandbox(self, sandbox_id, sandbox_name) - .await - .map_err(|err| err.to_string()) - } -} /// Interval between store-vs-backend reconciliation sweeps. const RECONCILE_INTERVAL: Duration = Duration::from_secs(60); @@ -317,7 +324,8 @@ pub struct ManagedDriverProcess { } impl ManagedDriverProcess { - pub(crate) fn new(child: tokio::process::Child, socket_path: PathBuf) -> Self { + #[cfg(unix)] + pub fn new(child: tokio::process::Child, socket_path: PathBuf) -> Self { Self { child: std::sync::Mutex::new(Some(child)), socket_path, @@ -414,13 +422,13 @@ pub struct AcquiredRemoteDriverEndpoint { } impl AcquiredRemoteDriverEndpoint { - pub(crate) fn managed_builtin( - driver_kind: ComputeDriverKind, + pub fn managed( + name: impl Into, channel: Channel, driver_process: Arc, ) -> Self { Self { - name: driver_kind.as_str().to_string(), + name: name.into(), channel, driver_process: Some(driver_process), } @@ -469,6 +477,17 @@ impl ComputeDriver for RemoteComputeDriver { client.get_capabilities(request).await } + async fn authenticate_sandbox( + &self, + request: Request, + ) -> Result< + tonic::Response, + Status, + > { + let mut client = self.client(); + client.authenticate_sandbox(request).await + } + async fn get_gateway_listener_requirements( &self, request: Request, @@ -517,13 +536,22 @@ impl ComputeDriver for RemoteComputeDriver { async fn stop_sandbox( &self, - request: Request, + request: Request, ) -> Result, Status> { let mut client = self.client(); client.stop_sandbox(request).await } + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> + { + let mut client = self.client(); + client.start_sandbox(request).await + } + async fn delete_sandbox( &self, request: Request, @@ -542,14 +570,29 @@ impl ComputeDriver for RemoteComputeDriver { let stream = response.into_inner(); Ok(tonic::Response::new(Box::pin(stream))) } + + async fn ensure_workspace( + &self, + request: Request, + ) -> Result, Status> { + let mut client = self.client(); + client.ensure_workspace(request).await + } + + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + let mut client = self.client(); + client.delete_workspace(request).await + } } #[derive(Clone)] pub struct ComputeRuntime { driver: TracedDriver, driver_info: ComputeDriverInfoSnapshot, - shutdown_cleanup: Option>, - startup_resume: Option>, + telemetry_compute_driver: TelemetryComputeDriver, driver_process: Option>, default_image: String, store: Arc, @@ -558,7 +601,7 @@ pub struct ComputeRuntime { tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, sync_lock: Arc>, - delete_gates: Arc, + lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, } @@ -580,11 +623,9 @@ impl ComputeRuntime { driver.name = %driver_name, ) )] - async fn from_driver( + pub(crate) async fn from_driver( driver_name: String, driver: SharedComputeDriver, - shutdown_cleanup: Option>, - startup_resume: Option>, driver_process: Option>, store: Arc, sandbox_index: SandboxIndex, @@ -600,17 +641,18 @@ impl ComputeRuntime { compute_error_from_status(status) })? .into_inner(); - let driver_kind = driver_name.parse::().ok(); info!( configured_driver = %driver_name, advertised_driver = %capabilities.driver_name, - in_tree = driver_kind.is_some(), "Compute driver connected" ); let driver_info = ComputeDriverInfoSnapshot { name: driver_name.clone(), driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, + gateway_manages_lifecycle: capabilities.gateway_manages_lifecycle, + supports_sandbox_authentication: capabilities.supports_sandbox_authentication, + driver_reports_runtime_readiness: capabilities.driver_reports_runtime_readiness, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -669,8 +711,7 @@ impl ComputeRuntime { Ok(Self { driver: TracedDriver::new(driver, driver_name), driver_info, - shutdown_cleanup, - startup_resume, + telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process, default_image, store, @@ -679,7 +720,7 @@ impl ComputeRuntime { tracing_log_bus, supervisor_sessions, sync_lock: Arc::new(Mutex::new(())), - delete_gates: Arc::new(DeleteGateRegistry::default()), + lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), }) @@ -696,77 +737,18 @@ impl ComputeRuntime { } /// Acquires the process-wide lock for code that already holds the - /// sandbox-ID delete gate. The guard parameter documents and enforces that - /// delete-path callers acquire locks in delete-gate -> global-lock order. - async fn lock_global_for_delete( + /// sandbox-ID lifecycle gate. The guard parameter documents and enforces + /// that callers acquire locks in lifecycle-gate -> global-lock order. + async fn lock_global_for_lifecycle( &self, - _delete_guard: &SandboxDeleteGuard, + _lifecycle_guard: &SandboxLifecycleGuard, ) -> tokio::sync::OwnedMutexGuard<()> { self.sync_lock.clone().lock_owned().await } #[cfg(test)] - pub(crate) fn delete_gate_entry_count(&self) -> usize { - self.delete_gates.entry_count() - } - - pub async fn new_docker( - config: openshell_core::Config, - docker_config: DockerComputeConfig, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, - ) -> Result { - let driver = Arc::new( - DockerComputeDriver::new(&config, &docker_config) - .await - .map_err(|err| ComputeError::Message(err.to_string()))?, - ); - let shutdown_cleanup: Arc = driver.clone(); - let startup_resume: Arc = driver.clone(); - let driver: SharedComputeDriver = driver; - Self::from_driver( - ComputeDriverKind::Docker.as_str().to_string(), - driver, - Some(shutdown_cleanup), - Some(startup_resume), - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - } - - pub async fn new_kubernetes( - config: KubernetesComputeConfig, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, - ) -> Result { - let driver = KubernetesComputeDriver::new(config) - .await - .map_err(|err| ComputeError::Message(err.to_string()))?; - let driver: SharedComputeDriver = Arc::new(KubernetesDriverService::new(driver)); - Self::from_driver( - ComputeDriverKind::Kubernetes.as_str().to_string(), - driver, - None, - None, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await + pub(crate) fn lifecycle_gate_entry_count(&self) -> usize { + self.lifecycle_gates.entry_count() } pub(crate) async fn new_remote_driver( @@ -781,8 +763,6 @@ impl ComputeRuntime { Self::from_driver( endpoint.name, driver, - None, - None, endpoint.driver_process, store, sandbox_index, @@ -793,33 +773,6 @@ impl ComputeRuntime { .await } - pub async fn new_podman( - config: PodmanComputeConfig, - store: Arc, - sandbox_index: SandboxIndex, - sandbox_watch_bus: SandboxWatchBus, - tracing_log_bus: TracingLogBus, - supervisor_sessions: Arc, - ) -> Result { - let driver = PodmanComputeDriver::new(config) - .await - .map_err(|err| ComputeError::Message(err.to_string()))?; - let driver: SharedComputeDriver = Arc::new(PodmanDriverService::new(driver)); - Self::from_driver( - ComputeDriverKind::Podman.as_str().to_string(), - driver, - None, - None, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - } - #[must_use] pub fn default_image(&self) -> &str { &self.default_image @@ -831,8 +784,46 @@ impl ComputeRuntime { } #[must_use] - pub fn driver_kind(&self) -> Option { - self.driver_info.name.parse().ok() + pub fn configured_driver_name(&self) -> &str { + &self.driver_info.name + } + + #[must_use] + pub fn supports_sandbox_authentication(&self) -> bool { + self.driver_info.supports_sandbox_authentication + } + + pub(crate) async fn authenticate_sandbox(&self, credential: &str) -> Result { + if !self.supports_sandbox_authentication() { + return Err(Status::unimplemented( + "selected compute driver does not authenticate sandbox credentials", + )); + } + let request = AuthenticateSandboxRequest { + credential: credential.to_string(), + }; + self.driver + .call( + openshell_otel::rpc::AUTHENTICATE_SANDBOX, + None, + |driver| async move { driver.authenticate_sandbox(Request::new(request)).await }, + ) + .await + .map(|response| response.into_inner().sandbox_id) + } + + #[must_use] + pub(crate) fn telemetry_compute_driver(&self) -> TelemetryComputeDriver { + self.telemetry_compute_driver + } + + #[must_use] + pub(crate) fn with_telemetry_compute_driver( + mut self, + telemetry_compute_driver: TelemetryComputeDriver, + ) -> Self { + self.telemetry_compute_driver = telemetry_compute_driver; + self } #[must_use] @@ -840,12 +831,54 @@ impl ComputeRuntime { &self.gateway_listener_requirements } + pub(crate) async fn ensure_workspace(&self, workspace: &str) -> Result<(), Status> { + let workspace = workspace.to_string(); + match self + .driver + .call( + openshell_otel::rpc::ENSURE_WORKSPACE, + None, + |driver| async move { + driver + .ensure_workspace(Request::new(EnsureWorkspaceRequest { workspace })) + .await + }, + ) + .await + { + Ok(_) => Ok(()), + Err(status) if status.code() == Code::Unimplemented => Ok(()), + Err(status) => Err(status), + } + } + + pub(crate) async fn delete_workspace(&self, workspace: &str) -> Result<(), Status> { + let workspace = workspace.to_string(); + match self + .driver + .call( + openshell_otel::rpc::DELETE_WORKSPACE, + None, + |driver| async move { + driver + .delete_workspace(Request::new(DeleteWorkspaceRequest { workspace })) + .await + }, + ) + .await + { + Ok(_) => Ok(()), + Err(status) if status.code() == Code::Unimplemented => Ok(()), + Err(status) => Err(status), + } + } + pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { let driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) .map_err(|status| *status)?; self.driver .call( - "driver.validate_sandbox_create", + openshell_otel::rpc::VALIDATE_SANDBOX_CREATE, Some(sandbox.object_id()), |driver| async move { driver @@ -863,6 +896,7 @@ impl ComputeRuntime { &self, sandbox: Sandbox, sandbox_token: Option, + await_main_process_attachment: bool, ) -> Result { let sandbox_id = sandbox.object_id().to_string(); let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) @@ -911,10 +945,13 @@ impl ComputeRuntime { { spec.sandbox_token = token; } + if let Some(spec) = driver_sandbox.spec.as_mut() { + spec.await_main_process_attachment = await_main_process_attachment; + } match self .driver .call( - "driver.create_sandbox", + openshell_otel::rpc::CREATE_SANDBOX, Some(sandbox.object_id()), |driver| async move { driver @@ -963,38 +1000,84 @@ impl ComputeRuntime { } } - pub(crate) async fn delete_sandbox( + pub(crate) async fn stop_sandbox( &self, workspace: &str, name: &str, - ) -> Result { - // Resolve and acquire both request-side locks before spawning the - // owned worker. Cancellation while any of these awaits is pending is - // harmless because no mutation or detached work has started. + ) -> Result { let candidate = self .store .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; - let target = SandboxDeleteTarget { - sandbox_id: candidate.object_id().to_string(), - sandbox_name: candidate.object_name().to_string(), + let sandbox_id = candidate.object_id().to_string(); + 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 + .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"))?; + if current.object_name() != sandbox_name { + return Err(Status::aborted( + "sandbox name changed while the stop request was waiting; retry explicitly", + )); + } + + 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); + } + if !matches!(phase, SandboxPhase::Ready | SandboxPhase::Stopping) { + return Err(Status::failed_precondition(format!( + "sandbox must be Ready 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 + .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) }; - let delete_guard = self.delete_gates.lock_for(&target.sandbox_id).await; - let global_guard = self.lock_global_for_delete(&delete_guard).await; + drop(global_guard); - // There is no await between acquiring the initial guards and spawning - // the worker. From this commitment point onward, request cancellation - // cannot stop the delete after it starts mutating durable state. + // Once the durable transition is committed, request cancellation must + // not cancel the driver operation and strand the sandbox in + // `Stopping`. Keep the lifecycle gate in an owned worker, matching + // the delete path's cancellation semantics. let runtime = self.clone(); - // `tokio::spawn` detaches from the current span, which would orphan - // the driver span from the request trace. Carry the span across. let request_span = tracing::Span::current(); tokio::spawn( async move { runtime - .delete_sandbox_inner(target, delete_guard, global_guard) + .complete_sandbox_stop( + sandbox_id, + sandbox_name, + previous, + stopping, + lifecycle_guard, + ) .await } .instrument(request_span), @@ -1002,69 +1085,30 @@ impl ComputeRuntime { .await .map_err(|err| { Status::internal(format!( - "sandbox delete worker terminated unexpectedly: {err}" + "sandbox stop worker terminated unexpectedly: {err}" )) })? } - async fn delete_sandbox_inner( + async fn complete_sandbox_stop( &self, - target: SandboxDeleteTarget, - delete_guard: SandboxDeleteGuard, - guard: tokio::sync::OwnedMutexGuard<()>, - ) -> Result { - let current = self - .store - .get_message::(&target.sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; - let Some(current) = current else { - // A delete that owned this ID's gate completed while this request - // waited. A different sandbox may now use the old name; this - // request acknowledges only the disappearance of its original ID. - self.cleanup_removed_sandbox_state(&target.sandbox_id); - return Ok(DeleteSandboxResult { - sandbox_id: target.sandbox_id, - deleted: true, - }); - }; - if current.object_name() != target.sandbox_name { - return Err(Status::aborted( - "sandbox name changed while the delete request was waiting; retry explicitly", - )); - } - - // `Started` carries both sides of the CAS transition: the durable - // `Deleting` row used to fence recovery, and the prior row used only - // for exact-version rollback after an ambiguous driver failure. - let transition = match self - .begin_sandbox_delete_with_initial_snapshot(&target.sandbox_id, Some(current)) - .await? - { - BeginDelete::AlreadyDeleting => { - return Ok(DeleteSandboxResult { - sandbox_id: target.sandbox_id, - deleted: true, - }); - } - BeginDelete::Started(transition) => *transition, - }; - - self.sandbox_index.update_from_sandbox(&transition.deleting); - self.sandbox_watch_bus.notify(&target.sandbox_id); - drop(guard); - + sandbox_id: String, + sandbox_name: String, + previous: Sandbox, + stopping: Sandbox, + lifecycle_guard: SandboxLifecycleGuard, + ) -> Result { let result = self .driver .call( - "driver.delete_sandbox", - Some(transition.deleting.object_id()), + openshell_otel::rpc::STOP_SANDBOX, + Some(&sandbox_id), |driver| { - let sandbox_id = transition.deleting.object_id().to_string(); - let sandbox_name = transition.deleting.object_name().to_string(); + let sandbox_id = sandbox_id.clone(); + let sandbox_name = sandbox_name.clone(); async move { driver - .delete_sandbox(Request::new(DeleteSandboxRequest { + .stop_sandbox(Request::new(StopSandboxRequest { sandbox_id, sandbox_name, })) @@ -1075,456 +1119,925 @@ impl ComputeRuntime { .await; match result { - Ok(response) => { - let deleted = response.into_inner().deleted; - if deleted { - self.cleanup_local_state_if_sandbox_absent(&delete_guard, &target.sandbox_id) - .await?; - } else if !self - .remove_deleting_sandbox_record(&delete_guard, &target.sandbox_id) + Ok(_) => { + let _global_guard = self.lock_global_for_lifecycle(&lifecycle_guard).await; + let latest = self + .store + .get_message::(&sandbox_id) .await - { - return Err(Status::internal( - "compute resource was absent, but gateway cleanup did not complete", + .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", )); - } - Ok(DeleteSandboxResult { - sandbox_id: target.sandbox_id, - deleted, - }) + }; + self.cleanup_stopped_sandbox_sessions(&stopped) + .await + .map_err(Status::internal)?; + self.sandbox_index.update_from_sandbox(&stopped); + self.sandbox_watch_bus.notify(&sandbox_id); + Ok(stopped) } Err(err) => { - self.recover_failed_delete(&delete_guard, &transition).await; - Err(Status::internal(format!( - "delete sandbox failed: {}", - err.message() - ))) + self.recover_failed_lifecycle(&lifecycle_guard, &stopping, &previous, true) + .await; + Err(Status::new( + err.code(), + format!("stop sandbox failed: {}", err.message()), + )) } } } - async fn begin_sandbox_delete_with_initial_snapshot( + pub(crate) async fn start_sandbox( &self, - sandbox_id: &str, - mut initial_snapshot: Option, - ) -> Result { - let operation = "set sandbox phase to Deleting"; + workspace: &str, + name: &str, + ) -> Result { + let candidate = self + .store + .get_message_by_name::(workspace, name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox_id = candidate.object_id().to_string(); + 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 + .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"))?; + if current.object_name() != sandbox_name { + return Err(Status::aborted( + "sandbox name changed while the start request was waiting; retry explicitly", + )); + } - for attempt in 1..=DELETE_PHASE_CAS_RETRY_LIMIT { - let sandbox = match initial_snapshot.take() { - Some(sandbox) => sandbox, - None => self + let phase = SandboxPhase::try_from(current.phase()).unwrap_or(SandboxPhase::Unknown); + if phase == SandboxPhase::Ready { + return Ok(current); + } + if !matches!( + phase, + SandboxPhase::Stopped | SandboxPhase::Completed | SandboxPhase::Starting + ) && !is_failed_main_process_result(¤t) + { + return Err(Status::failed_precondition(format!( + "sandbox must be Stopped, Completed, or a failed main-process Error to start (current phase: {phase:?})" + ))); + } + + if phase == SandboxPhase::Completed || is_failed_main_process_result(¤t) { + self.cleanup_stopped_sandbox_sessions(¤t) + .await + .map_err(Status::internal)?; + } + + let (previous, starting) = if phase == SandboxPhase::Starting { + // 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 starting = self + .write_lifecycle_phase( + ¤t, + SandboxPhase::Starting, + "Starting", + "Sandbox start requested", + ) + .await?; + self.sandbox_index.update_from_sandbox(&starting); + self.sandbox_watch_bus.notify(&sandbox_id); + (previous, starting) + }; + drop(global_guard); + + // The durable `Starting` transition commits the operation. Let an + // owned worker finish it even if the initiating RPC is canceled. + let runtime = self.clone(); + let request_span = tracing::Span::current(); + tokio::spawn( + async move { + runtime + .complete_sandbox_start( + sandbox_id, + sandbox_name, + previous, + starting, + lifecycle_guard, + ) + .await + } + .instrument(request_span), + ) + .await + .map_err(|err| { + Status::internal(format!( + "sandbox start worker terminated unexpectedly: {err}" + )) + })? + } + + async fn complete_sandbox_start( + &self, + sandbox_id: String, + sandbox_name: String, + previous: Sandbox, + starting: Sandbox, + lifecycle_guard: SandboxLifecycleGuard, + ) -> Result { + let result = self + .driver + .call( + openshell_otel::rpc::START_SANDBOX, + Some(&sandbox_id), + |driver| { + let sandbox_id = sandbox_id.clone(); + let sandbox_name = sandbox_name.clone(); + async move { + driver + .start_sandbox(Request::new(StartSandboxRequest { + sandbox_id, + sandbox_name, + })) + .await + } + }, + ) + .await; + + match result { + Ok(_) => { + let _global_guard = self.lock_global_for_lifecycle(&lifecycle_guard).await; + let latest = self .store - .get_message::(sandbox_id) + .get_message::(&sandbox_id) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?, - }; - - if SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown) - == SandboxPhase::Deleting - { - return Ok(BeginDelete::AlreadyDeleting); + .ok_or_else(|| Status::not_found("sandbox not found"))?; + Ok(latest) } + Err(err) => { + self.recover_failed_lifecycle(&lifecycle_guard, &starting, &previous, false) + .await; + Err(Status::new( + err.code(), + format!("start sandbox failed: {}", err.message()), + )) + } + } + } - let previous = sandbox.clone(); + /// Reconcile an ambiguous lifecycle error against the driver's observed + /// state before deciding whether the pre-operation snapshot is still true. + /// + /// A transport error can arrive after the runtime applied stop or start. + /// The driver lookup deliberately runs without the process-wide lock; the + /// exact transition resource version then fences the recovery write. + async fn recover_failed_lifecycle( + &self, + lifecycle_guard: &SandboxLifecycleGuard, + transition: &Sandbox, + previous: &Sandbox, + expected_stopped: bool, + ) { + let sandbox_id = transition.object_id(); + let sandbox_name = transition.object_name(); + let observed = self.get_driver_sandbox(sandbox_id, sandbox_name).await; + let _global_guard = self.lock_global_for_lifecycle(lifecycle_guard).await; - match self - .write_sandbox_phase_deleting_from_snapshot(sandbox) - .await - { - Ok(deleting) => { - if attempt > 1 { - debug!( + match observed { + Ok(Some(snapshot)) if snapshot.id == sandbox_id && snapshot.status.is_some() => { + let backend_phase = derive_phase(snapshot.status.as_ref()); + let observed_stopped = backend_phase == SandboxPhase::Stopped + || driver_snapshot_confirms_stopped(&snapshot); + let suspension_progressing = + expected_stopped && driver_snapshot_confirms_stopping(&snapshot); + let runtime_restart_during_stop = + expected_stopped && driver_snapshot_reports_runtime_restart(&snapshot); + if suspension_progressing || runtime_restart_during_stop { + // The backend has accepted the stop but has not finished + // terminating the sandbox. A runtime restart can likewise + // be the expected exit from an in-flight stop. Preserve the + // durable transition so completion or recovery, rather than + // a watch snapshot, determines its terminal state. + debug!(sandbox_id, "Sandbox stop is still progressing"); + } else if backend_phase == SandboxPhase::Error + || observed_stopped == expected_stopped + { + if let Some(reconciled) = self + .reconcile_lifecycle_snapshot(transition, &snapshot) + .await + && reconciled.phase() == SandboxPhase::Stopped as i32 + && let Err(err) = self.cleanup_stopped_sandbox_sessions(&reconciled).await + { + warn!( sandbox_id, - attempt, "Retried sandbox delete phase transition after CAS conflict" + error = %err, + "Failed to clean up sessions after reconciling stopped sandbox" ); } - return Ok(BeginDelete::Started(Box::new(DeleteTransition { - previous, - deleting, - }))); - } - Err(crate::persistence::PersistenceError::Conflict { - current_resource_version, - }) => { - let err = crate::persistence::PersistenceError::Conflict { - current_resource_version, - }; - if attempt == DELETE_PHASE_CAS_RETRY_LIMIT { - return Err(crate::grpc::persistence_error_to_status(err, operation)); - } - debug!( - sandbox_id, - attempt, - current_resource_version, - "Sandbox delete phase transition conflicted; retrying" - ); - tokio::task::yield_now().await; + } else { + self.restore_lifecycle_snapshot(transition, previous).await; } - Err(err) => return Err(crate::grpc::persistence_error_to_status(err, operation)), + } + Ok(Some(_) | None) | Err(_) => { + // Without authoritative backend state, retain the durable + // transition rather than claiming the old running/stopped + // state. Startup recovery can safely retry the idempotent + // driver operation. + warn!( + sandbox_id, + "Could not resolve ambiguous sandbox lifecycle outcome; retaining transition" + ); } } - - unreachable!("delete phase retry loop always returns") } - /// Removes a durable `Deleting` row after the driver confirms that its - /// compute resource is already absent (`deleted = false`). - /// - /// Benign resource-version changes are retried, but cleanup stops if the - /// row leaves `Deleting`; that state belongs to a concurrent writer. - async fn remove_deleting_sandbox_record( + async fn reconcile_lifecycle_snapshot( &self, - delete_guard: &SandboxDeleteGuard, - sandbox_id: &str, - ) -> bool { - let _guard = self.lock_global_for_delete(delete_guard).await; - for attempt in 1..=DELETE_PHASE_CAS_RETRY_LIMIT { - let record = match self.store.get(Sandbox::object_type(), sandbox_id).await { - Ok(Some(record)) => record, - Ok(None) => { - self.cleanup_removed_sandbox_state(sandbox_id); - return true; - } - Err(err) => { - warn!( - sandbox_id, - error = %err, - "Failed to fetch sandbox after the compute resource was absent" - ); - return false; - } - }; - let sandbox = match decode_sandbox_record(&record) { - Ok(sandbox) => sandbox, - Err(err) => { - warn!(sandbox_id, error = %err, "Failed to decode sandbox during cleanup"); - return false; - } - }; - if SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown) - != SandboxPhase::Deleting - { + transition: &Sandbox, + snapshot: &DriverSandbox, + ) -> Option { + let sandbox_id = transition.object_id().to_string(); + let expected_resource_version = sandbox_resource_version(transition); + let session_connected = self.supervisor_sessions.has_session(&sandbox_id); + match self + .store + .update_message_cas::(&sandbox_id, expected_resource_version, |sandbox| { + apply_driver_snapshot( + sandbox, + snapshot, + session_connected, + self.driver_info.driver_reports_runtime_readiness, + ); + }) + .await + { + Ok(reconciled) => { + self.sandbox_index.update_from_sandbox(&reconciled); + self.sandbox_watch_bus.notify(&sandbox_id); + Some(reconciled) + } + Err(err) => { debug!( sandbox_id, - "Skipped cleanup of a sandbox no longer deleting" + error = %err, + "Skipped lifecycle reconciliation after concurrent change" ); - return false; - } - - match self - .remove_sandbox_record_if_version_locked(sandbox_id, record.resource_version) - .await - { - Ok(true) => return true, - Ok(false) if attempt < DELETE_PHASE_CAS_RETRY_LIMIT => { - tokio::task::yield_now().await; - } - Ok(false) => return false, - Err(err) => { - warn!( - sandbox_id, - error = %err, - "Failed to clean up store after the compute resource was absent" - ); - return false; - } + None } } - - false } - /// Removes the sandbox by stable ID only when the expected resource - /// version still owns the row. The caller holds `sync_lock`; a successful - /// delete also removes sandbox-owned records, while successful or - /// already-completed removal clears this replica's index and watch/log - /// buses. - async fn remove_sandbox_record_if_version_locked( + async fn write_lifecycle_phase( &self, - sandbox_id: &str, - expected_resource_version: u64, - ) -> Result { - let Some(record) = self - .store - .get(Sandbox::object_type(), sandbox_id) - .await - .map_err(|err| err.to_string())? - else { - self.cleanup_removed_sandbox_state(sandbox_id); - return Ok(false); - }; - - if record.resource_version != expected_resource_version { - debug!( - sandbox_id, + sandbox: &Sandbox, + phase: SandboxPhase, + reason: &str, + message: &str, + ) -> Result { + let sandbox_id = sandbox.object_id().to_string(); + let expected_resource_version = sandbox_resource_version(sandbox); + let reason = reason.to_string(); + let message = message.to_string(); + self.store + .update_message_cas::( + &sandbox_id, expected_resource_version, - current_resource_version = record.resource_version, - "Skipped sandbox cleanup after a concurrent state change" - ); - return Ok(false); - } - - let sandbox = decode_sandbox_record(&record)?; - self.cleanup_sandbox_owned_records(&sandbox).await?; + move |sandbox| { + sandbox.set_phase(phase as i32); + let name = sandbox.object_name().to_string(); + if matches!(phase, SandboxPhase::Stopping | SandboxPhase::Starting) { + let status = sandbox.status.get_or_insert_with(Default::default); + // Retain the previous instance id as a tombstone until + // the restarted supervisor registers its new id. + status.exit_code = None; + } + upsert_ready_condition( + &mut sandbox.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.clone(), + message: message.clone(), + last_transition_time: String::new(), + }, + ); + }, + ) + .await + .map_err(|e| crate::grpc::persistence_error_to_status(e, "update sandbox lifecycle")) + } + async fn restore_lifecycle_snapshot(&self, owned: &Sandbox, previous: &Sandbox) { + let sandbox_id = owned.object_id().to_string(); + let previous = previous.clone(); match self .store - .delete_if( - Sandbox::object_type(), - sandbox_id, - expected_resource_version, + .update_message_cas::( + &sandbox_id, + sandbox_resource_version(owned), + move |sandbox| *sandbox = previous.clone(), ) .await { - Ok(true) => { - self.cleanup_removed_sandbox_state(sandbox_id); - Ok(true) - } - Ok(false) => { - self.cleanup_removed_sandbox_state(sandbox_id); - Ok(false) + Ok(restored) => { + self.sandbox_index.update_from_sandbox(&restored); + self.sandbox_watch_bus.notify(&sandbox_id); } - Err(crate::persistence::PersistenceError::Conflict { - current_resource_version, - }) => { - debug!( - sandbox_id, - expected_resource_version, - current_resource_version, - "Skipped sandbox cleanup after a concurrent state change" - ); - Ok(false) + Err(err) => { + debug!(sandbox_id, error = %err, "Skipped lifecycle rollback after concurrent change"); } - Err(err) => Err(err.to_string()), } } - /// Resolves an ambiguous driver delete error without overwriting newer - /// gateway state. - /// - /// The external lookup runs without `sync_lock`. Recovery then uses the - /// exact `Deleting` resource version to apply one of three outcomes: - /// reconcile an observed backend snapshot, remove a confirmed-absent - /// backend, or restore the pre-delete snapshot when lookup is inconclusive. - async fn recover_failed_delete( + pub(crate) async fn delete_sandbox( &self, - delete_guard: &SandboxDeleteGuard, - transition: &DeleteTransition, - ) { - let sandbox_id = transition.deleting.object_id(); - let sandbox_name = transition.deleting.object_name(); - let deleting_resource_version = sandbox_resource_version(&transition.deleting); + workspace: &str, + name: &str, + ) -> Result { + // Resolve and acquire both request-side locks before spawning the + // owned worker. Cancellation while any of these awaits is pending is + // harmless because no mutation or detached work has started. + let candidate = self + .store + .get_message_by_name::(workspace, name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let target = SandboxDeleteTarget { + sandbox_id: candidate.object_id().to_string(), + sandbox_name: candidate.object_name().to_string(), + }; + let delete_guard = self.lifecycle_gates.lock_for(&target.sandbox_id).await; + let global_guard = self.lock_global_for_lifecycle(&delete_guard).await; - // The driver lookup is deliberately outside the process-wide guard. - let observed = self.get_driver_sandbox(sandbox_id, sandbox_name).await; - let _guard = self.lock_global_for_delete(delete_guard).await; + // There is no await between acquiring the initial guards and spawning + // the worker. From this commitment point onward, request cancellation + // cannot stop the delete after it starts mutating durable state. + let runtime = self.clone(); + // `tokio::spawn` detaches from the current span, which would orphan + // the driver span from the request trace. Carry the span across. + let request_span = tracing::Span::current(); + tokio::spawn( + async move { + runtime + .delete_sandbox_inner(target, delete_guard, global_guard) + .await + } + .instrument(request_span), + ) + .await + .map_err(|err| { + Status::internal(format!( + "sandbox delete worker terminated unexpectedly: {err}" + )) + })? + } - match observed { - Ok(Some(snapshot)) if snapshot.id == sandbox_id && snapshot.status.is_some() => { - let session_connected = self.supervisor_sessions.has_session(sandbox_id); - self.write_delete_recovery_with_retry( - sandbox_id, - deleting_resource_version, - "reconcile observed backend snapshot", - |sandbox| apply_driver_snapshot(sandbox, &snapshot, session_connected), - ) - .await; + async fn delete_sandbox_inner( + &self, + target: SandboxDeleteTarget, + delete_guard: SandboxLifecycleGuard, + guard: tokio::sync::OwnedMutexGuard<()>, + ) -> Result { + let current = self + .store + .get_message::(&target.sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; + let Some(current) = current else { + // A delete that owned this ID's gate completed while this request + // waited. A different sandbox may now use the old name; this + // request acknowledges only the disappearance of its original ID. + self.cleanup_removed_sandbox_state(&target.sandbox_id); + return Ok(DeleteSandboxResult { + sandbox_id: target.sandbox_id, + deleted: true, + }); + }; + if current.object_name() != target.sandbox_name { + return Err(Status::aborted( + "sandbox name changed while the delete request was waiting; retry explicitly", + )); + } + + // `Started` carries both sides of the CAS transition: the durable + // `Deleting` row used to fence recovery, and the prior row used only + // for exact-version rollback after an ambiguous driver failure. + let transition = match self + .begin_sandbox_delete_with_initial_snapshot(&target.sandbox_id, Some(current)) + .await? + { + BeginDelete::AlreadyDeleting => { + return Ok(DeleteSandboxResult { + sandbox_id: target.sandbox_id, + deleted: true, + }); } - Ok(None) => { - match self - .remove_sandbox_record_if_version_locked(sandbox_id, deleting_resource_version) + BeginDelete::Started(transition) => *transition, + }; + + self.sandbox_index.update_from_sandbox(&transition.deleting); + self.sandbox_watch_bus.notify(&target.sandbox_id); + drop(guard); + + let result = self + .driver + .call( + openshell_otel::rpc::DELETE_SANDBOX, + Some(transition.deleting.object_id()), + |driver| { + let sandbox_id = transition.deleting.object_id().to_string(); + let sandbox_name = transition.deleting.object_name().to_string(); + async move { + driver + .delete_sandbox(Request::new(DeleteSandboxRequest { + sandbox_id, + sandbox_name, + })) + .await + } + }, + ) + .await; + + match result { + Ok(response) => { + let deleted = response.into_inner().deleted; + if deleted { + self.cleanup_local_state_if_sandbox_absent(&delete_guard, &target.sandbox_id) + .await?; + } else if !self + .remove_deleting_sandbox_record(&delete_guard, &target.sandbox_id) .await { - Ok(_) => {} - Err(err) => { - debug!( - sandbox_id, - error = %err, - "Skipped absent-backend recovery after a concurrent state change" - ); - } + return Err(Status::internal( + "compute resource was absent, but gateway cleanup did not complete", + )); } + Ok(DeleteSandboxResult { + sandbox_id: target.sandbox_id, + deleted, + }) } - Ok(Some(_)) | Err(_) => { - let previous = transition.previous.clone(); - self.write_delete_recovery_with_retry( - sandbox_id, - deleting_resource_version, - "restore pre-delete snapshot", - |sandbox| *sandbox = previous.clone(), - ) - .await; + Err(err) => { + self.recover_failed_delete(&delete_guard, &transition).await; + Err(Status::internal(format!( + "delete sandbox failed: {}", + err.message() + ))) } } } - /// Applies an exact-version delete recovery, retrying transient persistence - /// failures only while the durable row remains at the version this delete - /// owns. CAS conflicts belong to a newer writer and are never retried. - async fn write_delete_recovery_with_retry( + async fn begin_sandbox_delete_with_initial_snapshot( &self, sandbox_id: &str, - deleting_resource_version: u64, - recovery_action: &'static str, - apply: F, - ) where - F: Fn(&mut Sandbox) + Send + Sync, - { + mut initial_snapshot: Option, + ) -> Result { + let operation = "set sandbox phase to Deleting"; + for attempt in 1..=DELETE_PHASE_CAS_RETRY_LIMIT { + let sandbox = match initial_snapshot.take() { + Some(sandbox) => sandbox, + None => 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"))?, + }; + + if SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown) + == SandboxPhase::Deleting + { + return Ok(BeginDelete::AlreadyDeleting); + } + + let previous = sandbox.clone(); + match self - .store - .update_message_cas::( - sandbox_id, - deleting_resource_version, - |sandbox| apply(sandbox), - ) + .write_sandbox_phase_deleting_from_snapshot(sandbox) .await { - Ok(recovered) => { - self.sandbox_index.update_from_sandbox(&recovered); - self.sandbox_watch_bus.notify(sandbox_id); - return; - } - Err(error @ crate::persistence::PersistenceError::Conflict { .. }) => { - self.handle_delete_recovery_conflict(sandbox_id, error, recovery_action) - .await; - return; - } - Err(error) => match self.store.get(Sandbox::object_type(), sandbox_id).await { - Ok(None) => { - debug!( - sandbox_id, - recovery_action, - "Delete recovery found the row removed by another replica; cleaning local state" - ); - self.cleanup_removed_sandbox_state(sandbox_id); - return; - } - Ok(Some(record)) - if record.resource_version == deleting_resource_version - && attempt < DELETE_PHASE_CAS_RETRY_LIMIT => - { - debug!( - sandbox_id, - recovery_action, - attempt, - error = %error, - "Delete recovery write failed while its version was unchanged; retrying" - ); - tokio::task::yield_now().await; - } - Ok(Some(record)) if record.resource_version == deleting_resource_version => { - warn!( - sandbox_id, - recovery_action, - attempt, - error = %error, - "Delete recovery write failed after bounded retries; sandbox remains deleting" - ); - return; - } - Ok(Some(record)) => { + Ok(deleting) => { + if attempt > 1 { debug!( sandbox_id, - recovery_action, - error = %error, - current_resource_version = record.resource_version, - "Skipped delete recovery after a concurrent state change" + attempt, "Retried sandbox delete phase transition after CAS conflict" ); - return; } - Err(fetch_error) => { - warn!( - sandbox_id, - recovery_action, - error = %error, - fetch_error = %fetch_error, - "Failed to verify sandbox state after delete recovery write failure" - ); - return; + return Ok(BeginDelete::Started(Box::new(DeleteTransition { + previous, + deleting, + }))); + } + Err(crate::persistence::PersistenceError::Conflict { + current_resource_version, + }) => { + let err = crate::persistence::PersistenceError::Conflict { + current_resource_version, + }; + if attempt == DELETE_PHASE_CAS_RETRY_LIMIT { + return Err(crate::grpc::persistence_error_to_status(err, operation)); } - }, + debug!( + sandbox_id, + attempt, + current_resource_version, + "Sandbox delete phase transition conflicted; retrying" + ); + tokio::task::yield_now().await; + } + Err(err) => return Err(crate::grpc::persistence_error_to_status(err, operation)), } } + + unreachable!("delete phase retry loop always returns") } - /// Handles a recovery CAS conflict while the caller holds `sync_lock`. - /// Another replica may have removed the durable row during the external - /// driver lookup; in that case this replica still needs local cleanup. - async fn handle_delete_recovery_conflict( + /// Removes a durable `Deleting` row after the driver confirms that its + /// compute resource is already absent (`deleted = false`). + /// + /// Benign resource-version changes are retried, but cleanup stops if the + /// row leaves `Deleting`; that state belongs to a concurrent writer. + async fn remove_deleting_sandbox_record( &self, + delete_guard: &SandboxLifecycleGuard, sandbox_id: &str, - error: crate::persistence::PersistenceError, - recovery_action: &'static str, - ) { - match self.store.get(Sandbox::object_type(), sandbox_id).await { - Ok(None) => { - debug!( - sandbox_id, - recovery_action, - "Delete recovery found the row removed by another replica; cleaning local state" - ); - self.cleanup_removed_sandbox_state(sandbox_id); - } - Ok(Some(_)) => { + ) -> bool { + let _guard = self.lock_global_for_lifecycle(delete_guard).await; + for attempt in 1..=DELETE_PHASE_CAS_RETRY_LIMIT { + let record = match self.store.get(Sandbox::object_type(), sandbox_id).await { + Ok(Some(record)) => record, + Ok(None) => { + self.cleanup_removed_sandbox_state(sandbox_id); + return true; + } + Err(err) => { + warn!( + sandbox_id, + error = %err, + "Failed to fetch sandbox after the compute resource was absent" + ); + return false; + } + }; + let sandbox = match decode_sandbox_record(&record) { + Ok(sandbox) => sandbox, + Err(err) => { + warn!(sandbox_id, error = %err, "Failed to decode sandbox during cleanup"); + return false; + } + }; + if SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown) + != SandboxPhase::Deleting + { debug!( sandbox_id, - recovery_action, - error = %error, - "Skipped delete recovery after a concurrent state change" + "Skipped cleanup of a sandbox no longer deleting" ); + return false; } - Err(fetch_error) => { - warn!( - sandbox_id, - recovery_action, - error = %error, - fetch_error = %fetch_error, - "Failed to verify sandbox state after delete recovery conflict" - ); + + match self + .remove_sandbox_record_if_version_locked(sandbox_id, record.resource_version) + .await + { + Ok(true) => return true, + Ok(false) if attempt < DELETE_PHASE_CAS_RETRY_LIMIT => { + tokio::task::yield_now().await; + } + Ok(false) => return false, + Err(err) => { + warn!( + sandbox_id, + error = %err, + "Failed to clean up store after the compute resource was absent" + ); + return false; + } } } + + false } - async fn write_sandbox_phase_deleting_from_snapshot( + /// Removes the sandbox by stable ID only when the expected resource + /// version still owns the row. The caller holds `sync_lock`; a successful + /// delete also removes sandbox-owned records, while successful or + /// already-completed removal clears this replica's index and watch/log + /// buses. + async fn remove_sandbox_record_if_version_locked( &self, - mut sandbox: Sandbox, - ) -> crate::persistence::PersistenceResult { - let id = sandbox.object_id().to_string(); - let name = sandbox.object_name().to_string(); - let expected_resource_version = sandbox - .metadata - .as_ref() - .map_or(0, |metadata| metadata.resource_version); + sandbox_id: &str, + expected_resource_version: u64, + ) -> Result { + let Some(record) = self + .store + .get(Sandbox::object_type(), sandbox_id) + .await + .map_err(|err| err.to_string())? + else { + self.cleanup_removed_sandbox_state(sandbox_id); + return Ok(false); + }; - sandbox.set_phase(SandboxPhase::Deleting as i32); + if record.resource_version != expected_resource_version { + debug!( + sandbox_id, + expected_resource_version, + current_resource_version = record.resource_version, + "Skipped sandbox cleanup after a concurrent state change" + ); + return Ok(false); + } - let labels_json = sandbox - .metadata - .as_ref() - .map(|metadata| &metadata.labels) - .filter(|labels| !labels.is_empty()) - .map(serde_json::to_string) - .transpose() - .map_err(|e| { - crate::persistence::PersistenceError::Encode(format!( - "failed to serialize labels: {e}" - )) - })?; + let sandbox = decode_sandbox_record(&record)?; + self.cleanup_sandbox_owned_records(&sandbox).await?; - let result = self + match self .store - .put_if( + .delete_if( Sandbox::object_type(), - &id, - &name, - sandbox.object_workspace(), - &sandbox.encode_to_vec(), - labels_json.as_deref(), - WriteCondition::MatchResourceVersion(expected_resource_version), - ) + sandbox_id, + expected_resource_version, + ) + .await + { + Ok(true) => { + self.cleanup_removed_sandbox_state(sandbox_id); + Ok(true) + } + Ok(false) => { + self.cleanup_removed_sandbox_state(sandbox_id); + Ok(false) + } + Err(crate::persistence::PersistenceError::Conflict { + current_resource_version, + }) => { + debug!( + sandbox_id, + expected_resource_version, + current_resource_version, + "Skipped sandbox cleanup after a concurrent state change" + ); + Ok(false) + } + Err(err) => Err(err.to_string()), + } + } + + /// Resolves an ambiguous driver delete error without overwriting newer + /// gateway state. + /// + /// The external lookup runs without `sync_lock`. Recovery then uses the + /// exact `Deleting` resource version to apply one of three outcomes: + /// reconcile an observed backend snapshot, remove a confirmed-absent + /// backend, or restore the pre-delete snapshot when lookup is inconclusive. + async fn recover_failed_delete( + &self, + delete_guard: &SandboxLifecycleGuard, + transition: &DeleteTransition, + ) { + let sandbox_id = transition.deleting.object_id(); + let sandbox_name = transition.deleting.object_name(); + let deleting_resource_version = sandbox_resource_version(&transition.deleting); + + // The driver lookup is deliberately outside the process-wide guard. + let observed = self.get_driver_sandbox(sandbox_id, sandbox_name).await; + let _guard = self.lock_global_for_lifecycle(delete_guard).await; + + match observed { + Ok(Some(snapshot)) if snapshot.id == sandbox_id && snapshot.status.is_some() => { + let session_connected = self.supervisor_sessions.has_session(sandbox_id); + self.write_delete_recovery_with_retry( + sandbox_id, + deleting_resource_version, + "reconcile observed backend snapshot", + |sandbox| { + apply_driver_snapshot( + sandbox, + &snapshot, + session_connected, + self.driver_info.driver_reports_runtime_readiness, + ); + }, + ) + .await; + } + Ok(None) => { + match self + .remove_sandbox_record_if_version_locked(sandbox_id, deleting_resource_version) + .await + { + Ok(_) => {} + Err(err) => { + debug!( + sandbox_id, + error = %err, + "Skipped absent-backend recovery after a concurrent state change" + ); + } + } + } + Ok(Some(_)) | Err(_) => { + let previous = transition.previous.clone(); + self.write_delete_recovery_with_retry( + sandbox_id, + deleting_resource_version, + "restore pre-delete snapshot", + |sandbox| *sandbox = previous.clone(), + ) + .await; + } + } + } + + /// Applies an exact-version delete recovery, retrying transient persistence + /// failures only while the durable row remains at the version this delete + /// owns. CAS conflicts belong to a newer writer and are never retried. + async fn write_delete_recovery_with_retry( + &self, + sandbox_id: &str, + deleting_resource_version: u64, + recovery_action: &'static str, + apply: F, + ) where + F: Fn(&mut Sandbox) + Send + Sync, + { + for attempt in 1..=DELETE_PHASE_CAS_RETRY_LIMIT { + match self + .store + .update_message_cas::( + sandbox_id, + deleting_resource_version, + |sandbox| apply(sandbox), + ) + .await + { + Ok(recovered) => { + self.sandbox_index.update_from_sandbox(&recovered); + self.sandbox_watch_bus.notify(sandbox_id); + return; + } + Err(error @ crate::persistence::PersistenceError::Conflict { .. }) => { + self.handle_delete_recovery_conflict(sandbox_id, error, recovery_action) + .await; + return; + } + Err(error) => match self.store.get(Sandbox::object_type(), sandbox_id).await { + Ok(None) => { + debug!( + sandbox_id, + recovery_action, + "Delete recovery found the row removed by another replica; cleaning local state" + ); + self.cleanup_removed_sandbox_state(sandbox_id); + return; + } + Ok(Some(record)) + if record.resource_version == deleting_resource_version + && attempt < DELETE_PHASE_CAS_RETRY_LIMIT => + { + debug!( + sandbox_id, + recovery_action, + attempt, + error = %error, + "Delete recovery write failed while its version was unchanged; retrying" + ); + tokio::task::yield_now().await; + } + Ok(Some(record)) if record.resource_version == deleting_resource_version => { + warn!( + sandbox_id, + recovery_action, + attempt, + error = %error, + "Delete recovery write failed after bounded retries; sandbox remains deleting" + ); + return; + } + Ok(Some(record)) => { + debug!( + sandbox_id, + recovery_action, + error = %error, + current_resource_version = record.resource_version, + "Skipped delete recovery after a concurrent state change" + ); + return; + } + Err(fetch_error) => { + warn!( + sandbox_id, + recovery_action, + error = %error, + fetch_error = %fetch_error, + "Failed to verify sandbox state after delete recovery write failure" + ); + return; + } + }, + } + } + } + + /// Handles a recovery CAS conflict while the caller holds `sync_lock`. + /// Another replica may have removed the durable row during the external + /// driver lookup; in that case this replica still needs local cleanup. + async fn handle_delete_recovery_conflict( + &self, + sandbox_id: &str, + error: crate::persistence::PersistenceError, + recovery_action: &'static str, + ) { + match self.store.get(Sandbox::object_type(), sandbox_id).await { + Ok(None) => { + debug!( + sandbox_id, + recovery_action, + "Delete recovery found the row removed by another replica; cleaning local state" + ); + self.cleanup_removed_sandbox_state(sandbox_id); + } + Ok(Some(_)) => { + debug!( + sandbox_id, + recovery_action, + error = %error, + "Skipped delete recovery after a concurrent state change" + ); + } + Err(fetch_error) => { + warn!( + sandbox_id, + recovery_action, + error = %error, + fetch_error = %fetch_error, + "Failed to verify sandbox state after delete recovery conflict" + ); + } + } + } + + async fn write_sandbox_phase_deleting_from_snapshot( + &self, + mut sandbox: Sandbox, + ) -> crate::persistence::PersistenceResult { + let id = sandbox.object_id().to_string(); + let name = sandbox.object_name().to_string(); + let expected_resource_version = sandbox + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + + sandbox.set_phase(SandboxPhase::Deleting as i32); + + let labels_json = sandbox + .metadata + .as_ref() + .map(|metadata| &metadata.labels) + .filter(|labels| !labels.is_empty()) + .map(serde_json::to_string) + .transpose() + .map_err(|e| { + crate::persistence::PersistenceError::Encode(format!( + "failed to serialize labels: {e}" + )) + })?; + + let result = self + .store + .put_if( + Sandbox::object_type(), + &id, + &name, + sandbox.object_workspace(), + &sandbox.encode_to_vec(), + labels_json.as_deref(), + WriteCondition::MatchResourceVersion(expected_resource_version), + ) .await?; if let Some(metadata) = sandbox.metadata.as_mut() { @@ -1540,7 +2053,7 @@ impl ComputeRuntime { let watch_runtime = runtime.clone(); let watch_shutdown = shutdown_rx.clone(); tokio::spawn(async move { - watch_runtime.watch_loop(watch_shutdown).await; + Box::pin(watch_runtime.watch_loop(watch_shutdown)).await; }); tokio::spawn(async move { runtime.reconcile_loop(shutdown_rx).await; @@ -1553,76 +2066,214 @@ impl ComputeRuntime { } pub async fn cleanup_on_shutdown(&self) -> Result<(), String> { - let cleanup_result = match &self.shutdown_cleanup { - Some(cleanup) => cleanup.cleanup_on_shutdown().await, - None => Ok(()), - }; + let stop_result = self.stop_persisted_sandboxes_on_shutdown().await; + #[cfg(unix)] - let process_result = match &self.driver_process { - Some(process) => process.shutdown().await, - None => Ok(()), + let process_result = if let Some(process) = &self.driver_process { + process.shutdown().await + } else { + Ok(()) }; - cleanup_result?; - #[cfg(unix)] - process_result?; - Ok(()) + #[cfg(not(unix))] + let process_result: Result<(), String> = Ok(()); + + match (stop_result, process_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(stop_err), Ok(())) => Err(stop_err), + (Ok(()), Err(process_err)) => Err(process_err), + (Err(stop_err), Err(process_err)) => Err(format!( + "{stop_err}; managed driver process shutdown failed: {process_err}" + )), + } + } + + /// Stop local compute during graceful gateway shutdown without changing + /// persisted lifecycle intent. + /// + /// An explicit sandbox stop persists `Stopped`; gateway shutdown does not. + /// Drivers request this sweep through their startup capability snapshot. + async fn stop_persisted_sandboxes_on_shutdown(&self) -> Result<(), String> { + if !self.driver_info.gateway_manages_lifecycle { + return Ok(()); + } + + let sandbox_ids = self.list_persisted_sandbox_ids("gateway shutdown").await?; + + let outcomes = futures::stream::iter(sandbox_ids) + .map(|sandbox_id| async move { + let _lifecycle_guard = self.lifecycle_gates.lock_for(&sandbox_id).await; + let sandbox = match self.store.get_message::(&sandbox_id).await { + Ok(Some(sandbox)) => sandbox, + Ok(None) => return (0usize, 0usize), + Err(err) => { + warn!( + sandbox_id, + error = %err, + "Failed to re-read sandbox during gateway shutdown" + ); + return (0, 1); + } + }; + + let phase = + SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + if !sandbox_phase_should_be_running(phase) { + return (0, 0); + } + + let sandbox_name = sandbox.object_name().to_string(); + match self + .driver + .call( + openshell_otel::rpc::STOP_SANDBOX, + Some(&sandbox_id), + |driver| { + let sandbox_id = sandbox_id.clone(); + let sandbox_name = sandbox_name.clone(); + async move { + driver + .stop_sandbox(Request::new(StopSandboxRequest { + sandbox_id, + sandbox_name, + })) + .await + } + }, + ) + .await + { + Ok(_) => { + info!( + sandbox_id, + sandbox_name, + ?phase, + "Stopped sandbox during gateway shutdown" + ); + (1, 0) + } + Err(err) => { + warn!( + sandbox_id, + sandbox_name, + error = %err, + "Failed to stop sandbox during gateway shutdown" + ); + (0, 1) + } + } + }) + .buffer_unordered(SHUTDOWN_STOP_CONCURRENCY) + .collect::>() + .await; + let (stopped, failed) = outcomes + .into_iter() + .fold((0usize, 0usize), |(stopped, failed), outcome| { + (stopped + outcome.0, failed + outcome.1) + }); + + if stopped > 0 || failed > 0 { + info!(stopped, failed, "Sandbox shutdown stop sweep complete"); + } + if failed > 0 { + Err(format!( + "failed to stop {failed} sandbox(es) during gateway shutdown" + )) + } else { + Ok(()) + } } - /// Resume sandboxes whose store records say they should be running. - /// Drivers that do not auto-restart compute resources across gateway - /// restarts (currently only Docker) implement `StartupResume`. For - /// each sandbox in the store whose phase is not `Deleting` or - /// `Error`, we ask the driver to resume the underlying resource. If - /// the driver reports that the resource no longer exists or fails to - /// start, the sandbox is moved to the `Error` phase so the failure - /// surfaces in the UI. + /// Reconcile running intent for local compute after a gateway restart. + /// + /// `StartSandbox` is idempotent, so call it for every persisted phase that + /// requires running compute for drivers that request gateway-managed + /// lifecycle. Stable stopped and deleting states are deliberately left + /// alone. Error-phase sandboxes are included only when their Ready + /// condition indicates the runtime went away underneath a running container + /// — a signal-kill from a machine/daemon restart or an explicit runtime + /// stop. If the container still exists it is restarted and the sandbox is + /// moved back to `Provisioning`; otherwise it stays in `Error`. Ordinary + /// application exits and crashes stay terminal and are not relaunched. /// /// Should be called once at gateway startup, before watchers spawn, - /// so the watch loop sees the post-resume state on its first poll. - pub async fn resume_persisted_sandboxes(&self) -> Result<(), String> { - let Some(resume) = &self.startup_resume else { + /// so the watch loop sees the post-start state on its first poll. + pub async fn start_persisted_sandboxes(&self) -> Result<(), String> { + self.recover_persisted_lifecycle_transitions().await?; + if !self.driver_info.gateway_manages_lifecycle { return Ok(()); - }; + } - let records = self - .store - .list_by_type(Sandbox::object_type(), 1000, 0) - .await - .map_err(|e| e.to_string())?; + let sandbox_ids = self.list_persisted_sandbox_ids("gateway startup").await?; - let mut resumed = 0usize; + let mut started = 0usize; + let mut recovered = 0usize; let mut missing = 0usize; let mut failed = 0usize; - for record in records { - let sandbox = match Sandbox::decode(record.payload.as_slice()) { - Ok(sandbox) => sandbox, + for sandbox_id in sandbox_ids { + let _lifecycle_guard = self.lifecycle_gates.lock_for(&sandbox_id).await; + let sandbox = match self.store.get_message::(&sandbox_id).await { + Ok(Some(sandbox)) => sandbox, + Ok(None) => continue, Err(err) => { - warn!(error = %err, "Failed to decode sandbox record during startup resume"); + warn!( + sandbox_id, + error = %err, + "Failed to re-read sandbox during gateway startup" + ); + failed += 1; continue; } }; let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); - if !sandbox_phase_should_be_running(phase) { + let recoverable_error = + phase == SandboxPhase::Error && is_recoverable_error_reason(&sandbox); + if !sandbox_phase_should_be_running(phase) && !recoverable_error { continue; } - match resume - .resume_sandbox(sandbox.object_id(), sandbox.object_name()) + let sandbox_name = sandbox.object_name().to_string(); + match self + .driver + .call( + openshell_otel::rpc::START_SANDBOX, + Some(&sandbox_id), + |driver| { + let sandbox_id = sandbox_id.clone(); + let sandbox_name = sandbox_name.clone(); + async move { + driver + .start_sandbox(Request::new(StartSandboxRequest { + sandbox_id, + sandbox_name, + })) + .await + } + }, + ) .await { - Ok(true) => { + Ok(_) => { + let did_recover = if recoverable_error { + self.clear_recoverable_error(&sandbox).await + } else { + false + }; info!( sandbox_id = %sandbox.object_id(), sandbox_name = %sandbox.object_name(), ?phase, - "Resumed sandbox during gateway startup" + recovered = did_recover, + "Started sandbox during gateway startup" ); - resumed += 1; + started += 1; + if did_recover { + recovered += 1; + } } - Ok(false) => { + Err(err) if err.code() == Code::NotFound => { // Backend resource is gone but the store still // remembers the sandbox. Mark Error so the UI // surfaces the inconsistency; the reconcile loop @@ -1631,14 +2282,16 @@ impl ComputeRuntime { warn!( sandbox_id = %sandbox.object_id(), sandbox_name = %sandbox.object_name(), - "Cannot resume sandbox: backend resource is missing" + "Cannot start sandbox: backend resource is missing" ); - self.mark_sandbox_error( - &sandbox, - "BackendResourceMissing", - "Sandbox container disappeared while the gateway was offline", - ) - .await; + if !recoverable_error { + self.mark_sandbox_error( + &sandbox, + "BackendResourceMissing", + "Sandbox compute resource disappeared while the gateway was offline", + ) + .await; + } missing += 1; } Err(err) => { @@ -1646,30 +2299,164 @@ impl ComputeRuntime { sandbox_id = %sandbox.object_id(), sandbox_name = %sandbox.object_name(), error = %err, - "Failed to resume sandbox during gateway startup" + "Failed to start sandbox during gateway startup" ); - self.mark_sandbox_error( - &sandbox, - "ResumeFailed", - &format!("Failed to resume sandbox during gateway startup: {err}"), - ) - .await; + if !recoverable_error { + self.mark_sandbox_error( + &sandbox, + "StartFailed", + &format!( + "Failed to start sandbox during gateway startup: {}", + err.message() + ), + ) + .await; + } failed += 1; } } } - if resumed > 0 || missing > 0 || failed > 0 { + if started > 0 || missing > 0 || failed > 0 { info!( - resumed, + started, + recovered, missing_backend = missing, failed, - "Sandbox resume sweep complete" + "Sandbox start sweep complete" ); } Ok(()) } + async fn recover_persisted_lifecycle_transitions(&self) -> Result<(), String> { + let sandbox_ids = self + .list_persisted_sandbox_ids("lifecycle recovery") + .await?; + for sandbox_id in sandbox_ids { + let _lifecycle_guard = self.lifecycle_gates.lock_for(&sandbox_id).await; + let sandbox = match self.store.get_message::(&sandbox_id).await { + Ok(Some(sandbox)) => sandbox, + Ok(None) => continue, + Err(err) => { + warn!( + sandbox_id, + error = %err, + "Failed to re-read sandbox during lifecycle recovery" + ); + continue; + } + }; + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + match phase { + SandboxPhase::Stopped | SandboxPhase::Completed => { + if let Err(err) = self.cleanup_stopped_sandbox_sessions(&sandbox).await { + warn!(sandbox_id = %sandbox.object_id(), error = %err, "Failed to complete recovered sandbox session cleanup"); + } + } + SandboxPhase::Error if is_failed_main_process_result(&sandbox) => { + if let Err(err) = self.cleanup_stopped_sandbox_sessions(&sandbox).await { + warn!(sandbox_id = %sandbox.object_id(), error = %err, "Failed to complete recovered failed-main session cleanup"); + } + } + SandboxPhase::Stopping => { + let sandbox_id = sandbox.object_id().to_string(); + let sandbox_name = sandbox.object_name().to_string(); + let driver_sandbox_id = sandbox_id.clone(); + match self + .driver + .call( + openshell_otel::rpc::STOP_SANDBOX, + Some(&sandbox_id), + |driver| async move { + driver + .stop_sandbox(Request::new(StopSandboxRequest { + sandbox_id: driver_sandbox_id, + sandbox_name, + })) + .await + }, + ) + .await + { + Ok(_) => match self + .write_lifecycle_phase( + &sandbox, + SandboxPhase::Stopped, + "Stopped", + "Sandbox compute is stopped", + ) + .await + { + Ok(updated) => { + self.sandbox_index.update_from_sandbox(&updated); + self.sandbox_watch_bus.notify(updated.object_id()); + if let Err(err) = + self.cleanup_stopped_sandbox_sessions(&updated).await + { + warn!(sandbox_id = %updated.object_id(), error = %err, "Failed to complete recovered sandbox session cleanup"); + } + } + Err(err) => { + warn!(sandbox_id = %sandbox.object_id(), error = %err, "Failed to persist recovered stop"); + } + }, + Err(err) => { + warn!(sandbox_id = %sandbox.object_id(), error = %err, "Failed to recover sandbox stop"); + } + } + } + SandboxPhase::Starting => { + let sandbox_id = sandbox.object_id().to_string(); + let sandbox_name = sandbox.object_name().to_string(); + let driver_sandbox_id = sandbox_id.clone(); + if let Err(err) = self + .driver + .call( + openshell_otel::rpc::START_SANDBOX, + Some(&sandbox_id), + |driver| async move { + driver + .start_sandbox(Request::new(StartSandboxRequest { + sandbox_id: driver_sandbox_id, + sandbox_name, + })) + .await + }, + ) + .await + { + warn!(sandbox_id = %sandbox.object_id(), error = %err, "Failed to recover sandbox start"); + } + } + _ => {} + } + } + Ok(()) + } + + async fn list_persisted_sandbox_ids(&self, operation: &str) -> Result, String> { + let mut sandbox_ids = Vec::new(); + let mut offset = 0u32; + loop { + let records = self + .store + .list_by_type(Sandbox::object_type(), LIFECYCLE_SWEEP_PAGE_SIZE, offset) + .await + .map_err(|err| format!("failed to list sandboxes for {operation}: {err}"))?; + let page_len = u32::try_from(records.len()) + .map_err(|_| format!("sandbox page size overflow during {operation}"))?; + sandbox_ids.extend(records.into_iter().map(|record| record.id)); + if page_len < LIFECYCLE_SWEEP_PAGE_SIZE { + break; + } + offset = offset + .checked_add(page_len) + .ok_or_else(|| format!("sandbox pagination offset overflow during {operation}"))?; + } + Ok(sandbox_ids) + } + async fn mark_sandbox_error(&self, sandbox: &Sandbox, reason: &str, message: &str) { let _guard = self.sync_lock.lock().await; let sandbox_id = sandbox.object_id().to_string(); @@ -1702,8 +2489,50 @@ impl ComputeRuntime { warn!( sandbox_id = %sandbox_id, error = %err, - "Failed to persist sandbox error state during startup resume" + "Failed to persist sandbox error state during gateway startup" + ); + } + } + } + + /// Clear a recoverable `Error` state after the underlying container has + /// been restarted during the startup sweep. Moves the sandbox back to + /// `Provisioning` with a `Resumed` Ready condition. Returns `true` if the + /// store update succeeded. + async fn clear_recoverable_error(&self, sandbox: &Sandbox) -> bool { + let _guard = self.sync_lock.lock().await; + let sandbox_id = sandbox.object_id().to_string(); + match self + .store + .update_message_cas::(&sandbox_id, 0, |s| { + s.set_phase(SandboxPhase::Provisioning as i32); + let name = s.object_name().to_string(); + upsert_ready_condition( + &mut s.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "Resumed".to_string(), + message: "Sandbox recovered during gateway startup".to_string(), + last_transition_time: String::new(), + }, + ); + }) + .await + { + Ok(updated) => { + self.sandbox_index.update_from_sandbox(&updated); + self.sandbox_watch_bus.notify(&sandbox_id); + true + } + Err(err) => { + warn!( + sandbox_id = %sandbox_id, + error = %err, + "Failed to clear sandbox error state during startup resume" ); + false } } } @@ -1758,7 +2587,7 @@ impl ComputeRuntime { let runtime = self.clone(); let watch_cancel = cancel_rx.clone(); let watch_handle = tokio::spawn(async move { - runtime.watch_loop(watch_cancel).await; + Box::pin(runtime.watch_loop(watch_cancel)).await; }); let runtime = self.clone(); @@ -1806,17 +2635,7 @@ impl ComputeRuntime { async fn watch_loop(self: Arc, mut cancel: watch::Receiver) { loop { - // Spans the stream open, not its lifetime: the future resolves - // once the driver accepts the watch. - let mut stream = match self - .driver - .call("driver.watch_sandboxes", None, |driver| async move { - driver - .watch_sandboxes(Request::new(WatchSandboxesRequest {})) - .await - }) - .await - { + let mut stream = match self.driver.watch().await { Ok(response) => response.into_inner(), Err(err) => { warn!(error = %err, "Compute driver watch stream failed to start"); @@ -1886,11 +2705,15 @@ impl ComputeRuntime { let sweep_started_at_ms = openshell_core::time::now_ms(); let backend_sandboxes = self .driver - .call("driver.list_sandboxes", None, |driver| async move { - driver - .list_sandboxes(Request::new(ListSandboxesRequest {})) - .await - }) + .call( + openshell_otel::rpc::LIST_SANDBOXES, + None, + |driver| async move { + driver + .list_sandboxes(Request::new(ListSandboxesRequest {})) + .await + }, + ) .await .map_err(|e| e.to_string()) .inspect_err(|_| crate::otel_tracing::mark_error(&tracing::Span::current()))? @@ -1979,7 +2802,7 @@ impl ComputeRuntime { match event.payload { Some(watch_sandboxes_event::Payload::Sandbox(sandbox)) => { if let Some(sandbox) = sandbox.sandbox { - self.apply_sandbox_update(sandbox).await?; + Box::pin(self.apply_sandbox_update(sandbox)).await?; } } Some(watch_sandboxes_event::Payload::Deleted(deleted)) => { @@ -2004,13 +2827,67 @@ impl ComputeRuntime { Ok(()) } - async fn apply_sandbox_update(&self, incoming: DriverSandbox) -> Result<(), String> { + async fn apply_sandbox_update(&self, mut incoming: DriverSandbox) -> Result<(), String> { + let guard = self.sync_lock.lock().await; + let mut existing = self + .store + .get(Sandbox::object_type(), &incoming.id) + .await + .map_err(|e| e.to_string())?; + let existing_sandbox = existing.as_ref().map(decode_sandbox_record).transpose()?; + let existing_phase = existing_sandbox + .as_ref() + .map_or(SandboxPhase::Unknown, |sandbox| { + SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown) + }); + + if !driver_snapshot_reports_terminal_container_exit(&incoming) + || existing_phase != SandboxPhase::Starting + { + return self.apply_sandbox_update_locked(incoming, existing).await; + } + + // A terminal snapshot can already be queued when StartSandbox moves + // the durable phase to Starting. Release the global watch lock, wait + // for that lifecycle operation, and then reread both the driver and + // store before applying the terminal observation. Taking the + // per-sandbox gate only for this ambiguous phase avoids delaying + // unrelated watch events behind slow lifecycle operations. + let existing_name = existing_sandbox.as_ref().map_or_else( + || incoming.name.clone(), + |sandbox| sandbox.object_name().to_string(), + ); + drop(guard); + let _lifecycle_guard = self.lifecycle_gates.lock_for(&incoming.id).await; + let observed = self.get_driver_sandbox(&incoming.id, &existing_name).await; let _guard = self.sync_lock.lock().await; - let existing = self + existing = self .store .get(Sandbox::object_type(), &incoming.id) .await .map_err(|e| e.to_string())?; + let current_phase = existing + .as_ref() + .map(decode_sandbox_record) + .transpose()? + .map_or(SandboxPhase::Unknown, |sandbox| { + SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown) + }); + + match observed { + Ok(Some(live)) if live.id == incoming.id && live.status.is_some() => incoming = live, + Ok(Some(_) | None) | Err(_) + if matches!(current_phase, SandboxPhase::Starting | SandboxPhase::Ready) => + { + warn!( + sandbox_id = %incoming.id, + "Could not validate terminal driver snapshot; retaining current sandbox state" + ); + return Ok(()); + } + Ok(Some(_) | None) | Err(_) => {} + } + self.apply_sandbox_update_locked(incoming, existing).await } @@ -2040,7 +2917,9 @@ impl ComputeRuntime { return Ok(()); } - self.update_sandbox_record(incoming, existing_record.resource_version) + let existing_phase = + SandboxPhase::try_from(existing.phase()).unwrap_or(SandboxPhase::Unknown); + self.update_sandbox_record(incoming, existing_record.resource_version, existing_phase) .await } @@ -2050,6 +2929,7 @@ impl ComputeRuntime { &self, incoming: DriverSandbox, expected_resource_version: u64, + existing_phase: SandboxPhase, ) -> Result<(), String> { let session_connected = self.supervisor_sessions.has_session(&incoming.id); let sandbox = self @@ -2057,7 +2937,14 @@ impl ComputeRuntime { .update_message_cas::( &incoming.id, expected_resource_version, - |sandbox| apply_driver_snapshot(sandbox, &incoming, session_connected), + |sandbox| { + apply_driver_snapshot( + sandbox, + &incoming, + session_connected, + self.driver_info.driver_reports_runtime_readiness, + ); + }, ) .await .map_err(|e| match e { @@ -2073,23 +2960,40 @@ impl ComputeRuntime { self.sandbox_index.update_from_sandbox(&sandbox); self.sandbox_watch_bus.notify(sandbox.object_id()); + if existing_phase != SandboxPhase::Stopped + && sandbox.phase() == SandboxPhase::Stopped as i32 + { + self.cleanup_stopped_sandbox_sessions(&sandbox).await?; + } Ok(()) } - pub async fn supervisor_session_connected(&self, sandbox_id: &str) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, true).await + pub async fn supervisor_session_connected( + &self, + sandbox_id: &str, + instance_id: &str, + ) -> Result<(), String> { + self.set_supervisor_session_state(sandbox_id, true, Some(instance_id), false) + .await } - pub async fn supervisor_session_disconnected(&self, sandbox_id: &str) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, false).await + pub async fn supervisor_session_disconnected( + &self, + sandbox_id: &str, + terminal_delivery_finalized: bool, + ) -> Result<(), String> { + self.set_supervisor_session_state(sandbox_id, false, None, terminal_delivery_finalized) + .await } async fn set_supervisor_session_state( &self, sandbox_id: &str, connected: bool, + instance_id: Option<&str>, + terminal_delivery_finalized: bool, ) -> Result<(), String> { - let _guard = self.sync_lock.lock().await; + let guard = self.sync_lock.lock().await; let Some(existing) = self .store @@ -2101,7 +3005,22 @@ impl ComputeRuntime { }; let current_phase = SandboxPhase::try_from(existing.phase()).unwrap_or(SandboxPhase::Unknown); - if current_phase == SandboxPhase::Deleting || current_phase == SandboxPhase::Error { + if !connected + && matches!(current_phase, SandboxPhase::Error | SandboxPhase::Completed) + && terminal_delivery_finalized + { + drop(guard); + self.schedule_ephemeral_sandbox_delete(&existing); + return Ok(()); + } + if matches!( + current_phase, + SandboxPhase::Deleting + | SandboxPhase::Error + | SandboxPhase::Stopping + | SandboxPhase::Stopped + | SandboxPhase::Completed + ) { return Ok(()); } if !connected && current_phase != SandboxPhase::Ready { @@ -2116,6 +3035,9 @@ impl ComputeRuntime { let sandbox_name = sandbox.object_name().to_string(); if connected { ensure_supervisor_ready_status(&mut sandbox.status, &sandbox_name); + let status = sandbox.status.get_or_insert_with(Default::default); + status.main_process_instance_id = instance_id.unwrap_or_default().to_string(); + status.exit_code = None; sandbox.set_phase(SandboxPhase::Ready as i32); } else { ensure_supervisor_not_ready_status(&mut sandbox.status, &sandbox_name); @@ -2149,28 +3071,186 @@ impl ComputeRuntime { Ok(()) } - async fn apply_deleted(&self, sandbox_id: &str) -> Result<(), String> { - let _guard = self.sync_lock.lock().await; - self.apply_deleted_locked(sandbox_id).await + /// Persist a terminal canonical-process result. Successful completion is + /// distinct from a nonzero command result and from infrastructure error. + pub async fn main_process_exited( + &self, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, + ) -> Result<(), String> { + self.report_main_process_exit(sandbox_id, instance_id, exit_code) + .await } - async fn apply_deleted_locked(&self, sandbox_id: &str) -> Result<(), String> { - let sandbox = self + pub async fn report_main_process_exit( + &self, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, + ) -> Result<(), String> { + let _guard = self.sync_lock.lock().await; + let Some(existing) = self .store .get_message::(sandbox_id) .await - .map_err(|e| e.to_string())?; - if let Some(sandbox) = sandbox.as_ref() { - self.cleanup_sandbox_owned_records(sandbox).await?; + .map_err(|error| error.to_string())? + else { + return Ok(()); + }; + let phase = SandboxPhase::try_from(existing.phase()).unwrap_or(SandboxPhase::Unknown); + if matches!( + phase, + SandboxPhase::Deleting | SandboxPhase::Stopping | SandboxPhase::Stopped + ) { + return Ok(()); } - - let _ = self + if let Some(status) = existing.status.as_ref() { + if !status.main_process_instance_id.is_empty() { + // While Starting, the stored id belongs to the stopped + // instance. A different id is the new process and its early + // exit must still be recorded. + if phase == SandboxPhase::Starting && status.main_process_instance_id == instance_id + { + tracing::warn!( + sandbox_id, + instance_id, + "ignoring main-process exit report from stopped instance while sandbox is starting" + ); + return Ok(()); + } + if phase != SandboxPhase::Starting && status.main_process_instance_id != instance_id + { + tracing::warn!( + sandbox_id, + instance_id, + active_instance_id = %status.main_process_instance_id, + "ignoring stale main-process exit report" + ); + return Ok(()); + } + } + if let Some(current_exit_code) = status.exit_code { + if current_exit_code != exit_code { + tracing::warn!( + sandbox_id, + instance_id, + current_exit_code, + reported_exit_code = exit_code, + "ignoring conflicting duplicate main-process exit report" + ); + return Ok(()); + } + return Ok(()); + } + } + let expected_resource_version = sandbox_resource_version(&existing); + let sandbox = self .store - .delete(Sandbox::object_type(), sandbox_id) + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + apply_main_process_exit(sandbox, instance_id, exit_code); + }) .await - .map_err(|e| e.to_string())?; - self.cleanup_removed_sandbox_state(sandbox_id); - Ok(()) + .map_err(|error| error.to_string())?; + self.sandbox_index.update_from_sandbox(&sandbox); + self.sandbox_watch_bus.notify(sandbox_id); + Ok(()) + } + + /// Permit cleanup after the main-process terminal transport has finished. + pub async fn finalize_main_process_exit( + &self, + sandbox_id: &str, + instance_id: &str, + ) -> Result<(), String> { + let _guard = self.sync_lock.lock().await; + let Some(sandbox) = self + .store + .get_message::(sandbox_id) + .await + .map_err(|error| error.to_string())? + else { + return Ok(()); + }; + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + if matches!( + phase, + SandboxPhase::Deleting | SandboxPhase::Stopping | SandboxPhase::Stopped + ) { + // Lifecycle shutdown intentionally discards the canonical process + // result. Finalization must still acknowledge that discarded + // result so the supervisor can exit before the compute backend's + // termination grace period expires. + return Ok(()); + } + let Some(status) = sandbox.status.as_ref() else { + return Err("main-process exit has not been reported".to_string()); + }; + if status.exit_code.is_none() { + return Err("main-process exit has not been reported".to_string()); + } + if !status.main_process_instance_id.is_empty() + && status.main_process_instance_id != instance_id + { + return Err("main-process instance does not match the terminal result".to_string()); + } + Ok(()) + } + + fn schedule_ephemeral_sandbox_delete(&self, sandbox: &Sandbox) { + let ephemeral = sandbox.metadata.as_ref().is_some_and(|metadata| { + metadata + .annotations + .get("openshell.nvidia.com/retention") + .is_some_and(|value| value == "ephemeral") + }); + if !ephemeral { + return; + } + + let runtime = self.clone(); + let workspace = sandbox.object_workspace().to_string(); + let name = sandbox.object_name().to_string(); + tokio::spawn(async move { + if let Err(error) = runtime.delete_sandbox(&workspace, &name).await { + tracing::warn!( + sandbox_name = %name, + error = %error, + "Failed to delete completed ephemeral sandbox" + ); + } + }); + } + + async fn apply_deleted(&self, sandbox_id: &str) -> Result<(), String> { + let _guard = self.sync_lock.lock().await; + self.apply_deleted_locked(sandbox_id).await + } + + async fn apply_deleted_locked(&self, sandbox_id: &str) -> Result<(), String> { + let sandbox = self + .store + .get_message::(sandbox_id) + .await + .map_err(|e| e.to_string())?; + if let Some(sandbox) = sandbox.as_ref() { + // The watcher told us this sandbox's compute resource is gone, so + // no request-side DeleteSandbox call is coming — release + // driver-owned resources in the background. Watch events are + // processed sequentially, so this must not block on the driver + // call itself, only on the (instant, non-blocking) decision to + // make it. + self.spawn_driver_sandbox_cleanup(sandbox.object_id(), sandbox.object_name()); + self.cleanup_sandbox_owned_records(sandbox).await?; + } + + let _ = self + .store + .delete(Sandbox::object_type(), sandbox_id) + .await + .map_err(|e| e.to_string())?; + self.cleanup_removed_sandbox_state(sandbox_id); + Ok(()) } async fn apply_deleted_if_version_locked( @@ -2212,31 +3292,157 @@ impl ComputeRuntime { Ok(()) } + /// Best-effort driver-side cleanup for a sandbox discovered gone + /// out-of-band — a watch deletion event, or the periodic prune sweep + /// finding no matching driver resource — rather than through an + /// explicit `DeleteSandbox` request. + /// + /// Skips the driver call entirely if a request-side lifecycle operation + /// (e.g. an in-flight explicit delete) already holds this sandbox's + /// lifecycle gate: that operation already owns driver-side cleanup for + /// it, and calling `DeleteSandbox` again here would race its own + /// in-flight call. + /// + /// The gate check is synchronous, but the actual `DeleteSandbox` RPC is + /// always deferred to a background task, never awaited inline: both + /// call sites run while holding a broader lock (the watch loop's + /// sequential event processing; the prune sweep's gateway-wide + /// `sync_lock`), and a slow or stuck driver call must never block that + /// wider scope. The gate itself is held for the background call's + /// duration, so this still can't race a concurrent request-side + /// operation — only the potentially-slow RPC is backgrounded. + fn spawn_driver_sandbox_cleanup(&self, sandbox_id: &str, sandbox_name: &str) { + let gate = self.lifecycle_gates.gate_for(sandbox_id); + let Ok(guard) = gate.try_lock_owned() else { + debug!( + sandbox_id, + sandbox_name, + "Skipping driver cleanup while a lifecycle operation is already in flight for this sandbox" + ); + return; + }; + + let runtime = self.clone(); + let sandbox_id = sandbox_id.to_string(); + let sandbox_name = sandbox_name.to_string(); + tokio::spawn(async move { + let _guard = guard; + runtime + .call_driver_delete_sandbox(&sandbox_id, &sandbox_name) + .await; + }); + } + + /// `DeleteSandbox` is idempotent: drivers must reclaim owned + /// secrets/volumes/etc. even when the underlying compute resource is + /// already gone. Failures here are logged, not propagated — callers + /// already consider this sandbox gone, so a driver hiccup must not + /// block store cleanup. + async fn call_driver_delete_sandbox(&self, sandbox_id: &str, sandbox_name: &str) { + let result = self + .driver + .call( + openshell_otel::rpc::DELETE_SANDBOX, + Some(sandbox_id), + |driver| { + let sandbox_id = sandbox_id.to_string(); + let sandbox_name = sandbox_name.to_string(); + async move { + driver + .delete_sandbox(Request::new(DeleteSandboxRequest { + sandbox_id, + sandbox_name, + })) + .await + } + }, + ) + .await; + + if let Err(status) = result { + warn!( + sandbox_id, + sandbox_name, + error = %status, + "Failed to release driver-owned resources while cleaning up a sandbox discovered gone out-of-band" + ); + } + } + async fn cleanup_sandbox_ssh_sessions( &self, sandbox_id: &str, workspace: &str, ) -> Result<(), String> { - let records = self + let started = Instant::now(); + let mut cursor = None; + let mut scanned = 0_usize; + let mut decode_failures = 0_usize; + let mut session_ids = Vec::new(); + + loop { + let records = self + .store + .list_after( + SshSession::object_type(), + workspace, + cursor.as_ref(), + LIFECYCLE_SWEEP_PAGE_SIZE, + ) + .await + .map_err(|e| format!("list SSH sessions: {e}"))?; + let page_len = records.len(); + scanned += page_len; + + cursor = records.last().map(ObjectCursor::from); + for record in records { + match SshSession::decode(record.payload.as_slice()) { + Ok(session) if session.sandbox_id == sandbox_id => { + session_ids.push(session.object_id().to_string()); + } + Ok(_) => {} + Err(_) => decode_failures += 1, + } + } + + if page_len < LIFECYCLE_SWEEP_PAGE_SIZE as usize { + break; + } + } + + let matched = session_ids.len(); + let deleted = self .store - .list(SshSession::object_type(), workspace, 1000, 0) + .delete_many(SshSession::object_type(), &session_ids) .await - .map_err(|e| format!("list SSH sessions: {e}"))?; + .map_err(|e| format!("delete sandbox SSH sessions: {e}"))?; - for record in records { - if let Ok(session) = SshSession::decode(record.payload.as_slice()) - && session.sandbox_id == sandbox_id - { - self.store - .delete(SshSession::object_type(), session.object_id()) - .await - .map_err(|e| format!("delete SSH session {}: {e}", session.object_id()))?; - } + if matched > 0 || decode_failures > 0 { + debug!( + sandbox_id, + workspace, + scanned, + matched, + deleted, + decode_failures, + elapsed_ms = started.elapsed().as_millis(), + "Sandbox SSH session cleanup complete" + ); } Ok(()) } + async fn cleanup_stopped_sandbox_sessions(&self, sandbox: &Sandbox) -> Result<(), String> { + // Disconnect first so a store failure cannot leave the stopped + // sandbox reachable through an existing supervisor stream. Both + // operations are idempotent and are retried for durable Stopped + // records during explicit stop requests and startup recovery. + self.supervisor_sessions.disconnect(sandbox.object_id()); + self.cleanup_sandbox_ssh_sessions(sandbox.object_id(), sandbox.object_workspace()) + .await + } + // TODO: introduce a per-sandbox cap on service endpoints and paginate // this cleanup loop, or query by sandbox label instead of scanning the // full workspace. Without a cap the flat 1,000-record page could miss @@ -2270,10 +3476,10 @@ impl ComputeRuntime { async fn cleanup_local_state_if_sandbox_absent( &self, - delete_guard: &SandboxDeleteGuard, + delete_guard: &SandboxLifecycleGuard, sandbox_id: &str, ) -> Result<(), Status> { - let _guard = self.lock_global_for_delete(delete_guard).await; + let _guard = self.lock_global_for_lifecycle(delete_guard).await; let record = self .store .get(Sandbox::object_type(), sandbox_id) @@ -2405,12 +3611,64 @@ impl ComputeRuntime { } let sandbox = decode_sandbox_record(¤t_record)?; + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + if phase == SandboxPhase::Completed || is_failed_main_process_result(&sandbox) { + // A terminal canonical process may legitimately have removed its + // transient compute object. Keep the durable command result. + return Ok(()); + } + if matches!( + phase, + SandboxPhase::Stopping | SandboxPhase::Stopped | SandboxPhase::Starting + ) { + let updated = self + .store + .update_message_cas::( + &sandbox_id, + expected_resource_version, + |sandbox| { + sandbox.set_phase(SandboxPhase::Error as i32); + let name = sandbox.object_name().to_string(); + upsert_ready_condition( + &mut sandbox.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "ComputeResourceMissing".to_string(), + message: "The compute driver could not find the retained sandbox resource; delete the sandbox to clean up its remaining state" + .to_string(), + last_transition_time: String::new(), + }, + ); + }, + ) + .await + .map_err(|err| err.to_string())?; + warn!( + sandbox_id = %sandbox_id, + sandbox_name = %sandbox_name, + phase = ?phase, + "Retained sandbox resource disappeared from the compute driver" + ); + self.sandbox_index.update_from_sandbox(&updated); + self.sandbox_watch_bus.notify(&sandbox_id); + return Ok(()); + } info!( sandbox_id = %sandbox_id, sandbox_name = %sandbox_name, age_secs = age_ms / 1000, "Removing sandbox from store after it disappeared from the compute driver snapshot" ); + // The driver's own snapshot never reported this sandbox, so no + // request-side DeleteSandbox call is coming for it either — release + // driver-owned resources in the background. This function holds + // `sync_lock` (the gateway-wide state guard) through the rest of its + // body, so the driver call must not be awaited here: doing so would + // block every other sandbox operation gateway-wide on a single, + // potentially slow or stuck driver RPC. + self.spawn_driver_sandbox_cleanup(&sandbox_id, &sandbox_name); self.apply_deleted_if_version_locked(&sandbox, expected_resource_version) .await } @@ -2422,18 +3680,22 @@ impl ComputeRuntime { ) -> Result, String> { match self .driver - .call("driver.get_sandbox", Some(sandbox_id), |driver| { - let sandbox_id = sandbox_id.to_string(); - let sandbox_name = sandbox_name.to_string(); - async move { - driver - .get_sandbox(Request::new(GetSandboxRequest { - sandbox_id, - sandbox_name, - })) - .await - } - }) + .call( + openshell_otel::rpc::GET_SANDBOX, + Some(sandbox_id), + |driver| { + let sandbox_id = sandbox_id.to_string(); + let sandbox_name = sandbox_name.to_string(); + async move { + driver + .get_sandbox(Request::new(GetSandboxRequest { + sandbox_id, + sandbox_name, + })) + .await + } + }, + ) .await { Ok(response) => { @@ -2454,6 +3716,67 @@ impl ComputeRuntime { } } +fn apply_main_process_exit(sandbox: &mut Sandbox, instance_id: &str, exit_code: i32) { + let sandbox_name = sandbox.object_name().to_string(); + // A driver can observe the container exit before the supervisor's + // authoritative main-process report arrives. In that ordering, + // ContainerExited is only a provisional classification: replace it with + // the canonical process result once its exit code is known. Preserve all + // other infrastructure errors. + let preserve_infrastructure_error = sandbox.phase() == SandboxPhase::Error as i32 + && !sandbox.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.r#type == "Ready" && condition.reason == "ContainerExited" + }) + }); + let status = sandbox.status.get_or_insert_with(|| SandboxStatus { + sandbox_name: sandbox_name.clone(), + ..Default::default() + }); + status.main_process_instance_id = instance_id.to_string(); + status.exit_code = Some(exit_code); + if preserve_infrastructure_error { + return; + } + let (phase, reason, message) = if exit_code == 0 { + ( + SandboxPhase::Completed, + "MainProcessCompleted", + "Canonical main process completed successfully".to_string(), + ) + } else { + ( + SandboxPhase::Error, + "MainProcessFailed", + format!("Canonical main process exited with status {exit_code}"), + ) + }; + upsert_ready_condition( + &mut sandbox.status, + &sandbox_name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.to_string(), + message, + last_transition_time: String::new(), + }, + ); + sandbox.set_phase(phase as i32); +} + +fn is_failed_main_process_result(sandbox: &Sandbox) -> bool { + sandbox.phase() == SandboxPhase::Error as i32 + && sandbox.status.as_ref().is_some_and(|status| { + status.exit_code.is_some() + && status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status.eq_ignore_ascii_case("false") + && condition.reason == "MainProcessFailed" + }) + }) +} + /// Connect to an unmanaged remote compute driver that is already listening on /// `socket_path` and return the acquired endpoint. /// @@ -2466,20 +3789,34 @@ pub async fn connect_remote_compute_driver( name: impl Into, socket_path: &Path, ) -> Result { - let socket_path: PathBuf = socket_path.to_path_buf(); - let display_path = socket_path.clone(); - let channel = Endpoint::from_static("http://[::]:50051") - .connect_with_connector(service_fn(move |_: tonic::transport::Uri| { - let socket_path = socket_path.clone(); - async move { UnixStream::connect(socket_path).await.map(TokioIo::new) } - })) - .await - .map_err(|e| { - ComputeError::Message(format!( - "failed to connect to remote compute driver socket '{}': {e}", - display_path.display() - )) - })?; + let socket_path = socket_path.to_path_buf(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let channel = loop { + let connector_path = socket_path.clone(); + match Endpoint::from_static("http://[::]:50051") + .connect_with_connector(service_fn(move |_: tonic::transport::Uri| { + let connector_path = connector_path.clone(); + async move { UnixStream::connect(connector_path).await.map(TokioIo::new) } + })) + .await + { + Ok(channel) => break channel, + Err(error) if tokio::time::Instant::now() < deadline => { + tracing::debug!( + socket = %socket_path.display(), + %error, + "waiting for remote compute driver socket" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } + Err(error) => { + return Err(ComputeError::Message(format!( + "failed to connect to remote compute driver socket '{}' within 30s: {error}", + socket_path.display() + ))); + } + } + }; Ok(AcquiredRemoteDriverEndpoint::unmanaged(name, channel)) } @@ -2523,6 +3860,7 @@ fn driver_sandbox_spec_from_public( .as_ref() .map(|template| driver_sandbox_template_from_public(template, driver_name)) .transpose()?, + policy: spec.policy.clone(), resource_requirements: spec.resource_requirements.as_ref().map(|requirements| { DriverSandboxResourceRequirements { gpu: requirements @@ -2532,6 +3870,9 @@ fn driver_sandbox_spec_from_public( } }), sandbox_token: String::new(), + command: spec.command.clone(), + tty: spec.tty, + await_main_process_attachment: false, }) } @@ -2547,6 +3888,7 @@ fn driver_sandbox_template_from_public( resources: extract_typed_resources(&template.resources), platform_config: build_platform_config(template), driver_config: select_driver_config(&template.driver_config, driver_name)?, + user_namespaces: template.user_namespaces, }) } @@ -2652,19 +3994,6 @@ fn build_platform_config(template: &SandboxTemplate) -> Option &'static str { + "sandbox_workload_template" + } +} + fn compute_error_from_status(status: Status) -> ComputeError { match status.code() { Code::AlreadyExists => ComputeError::AlreadyExists, @@ -2803,15 +4138,35 @@ fn public_status_from_driver( .collect(), phase: phase as i32, current_policy_version, + main_process_instance_id: String::new(), + exit_code: None, } } -fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, session_connected: bool) { +fn apply_driver_snapshot( + sandbox: &mut Sandbox, + incoming: &DriverSandbox, + session_connected: bool, + driver_reports_runtime_readiness: bool, +) { let old_phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); let sandbox_name = &incoming.name; + // Infrastructure errors and successful main-process completions are + // sticky until an explicit lifecycle operation changes desired state. A + // late signal-exit snapshot must also not overwrite the terminal reason + // recorded by a completed explicit stop. + if matches!(old_phase, SandboxPhase::Error | SandboxPhase::Completed) + || (old_phase == SandboxPhase::Stopped && driver_snapshot_reports_runtime_restart(incoming)) + { + if let Some(metadata) = sandbox.metadata.as_mut() { + metadata.name.clone_from(sandbox_name); + } + return; + } + let cpv = sandbox.current_policy_version(); - let (phase, mut status) = incoming.status.as_ref().map_or_else( + let (mut phase, mut status) = incoming.status.as_ref().map_or_else( || { let mut phase = old_phase; let supervisor_promoted = session_connected @@ -2828,7 +4183,11 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio (phase, status) }, |incoming_status| { - let composed = ComposedPhase::new(incoming_status, session_connected); + let composed = ComposedPhase::new( + incoming_status, + session_connected, + driver_reports_runtime_readiness, + ); let mut status = Some(public_status_from_driver( incoming_status, composed.phase, @@ -2839,12 +4198,46 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio }, ); + phase = match old_phase { + // SIGTERM-driven runtime exits are reported as a runtime restart by + // Docker and Podman. While an explicit stop owns this durable + // transition, preserve Stopping so the stop result chooses whether + // the sandbox actually reached Stopped or needs recovery. + SandboxPhase::Stopping if driver_snapshot_reports_runtime_restart(incoming) => { + SandboxPhase::Stopping + } + SandboxPhase::Stopping + if phase == SandboxPhase::Stopped || driver_snapshot_confirms_stopped(incoming) => + { + SandboxPhase::Stopped + } + SandboxPhase::Stopping if driver_snapshot_confirms_stopping(incoming) => { + SandboxPhase::Stopping + } + SandboxPhase::Stopping if phase != SandboxPhase::Error => SandboxPhase::Stopping, + SandboxPhase::Stopped => SandboxPhase::Stopped, + SandboxPhase::Completed => SandboxPhase::Completed, + SandboxPhase::Starting if !matches!(phase, SandboxPhase::Ready | SandboxPhase::Error) => { + SandboxPhase::Starting + } + _ => phase, + }; + + if let Some(status) = status.as_mut() { + status.phase = phase as i32; + } + if let Some(status) = status.as_mut() && status.sandbox_name.is_empty() { status.sandbox_name.clone_from(sandbox_name); } - + if let (Some(status), Some(current_status)) = (status.as_mut(), sandbox.status.as_ref()) { + status + .main_process_instance_id + .clone_from(¤t_status.main_process_instance_id); + status.exit_code = current_status.exit_code; + } if old_phase != phase { info!( sandbox_id = %incoming.id, @@ -2882,6 +4275,54 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio sandbox.set_current_policy_version(cpv); } +fn driver_snapshot_confirms_stopped(incoming: &DriverSandbox) -> bool { + incoming.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.status.eq_ignore_ascii_case("false") + && matches!( + condition.reason.to_ascii_lowercase().as_str(), + "containerexited" | "containerstopped" + ) + }) + }) +} + +fn driver_snapshot_reports_terminal_container_exit(incoming: &DriverSandbox) -> bool { + incoming.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.status.eq_ignore_ascii_case("false") + && matches!( + condition.reason.to_ascii_lowercase().as_str(), + "containerexited" | "containerstopped" | "containerruntimerestart" + ) + }) + }) +} + +fn driver_snapshot_reports_runtime_restart(incoming: &DriverSandbox) -> bool { + incoming.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.status.eq_ignore_ascii_case("false") + && condition + .reason + .eq_ignore_ascii_case("ContainerRuntimeRestart") + }) + }) +} + +fn driver_snapshot_confirms_stopping(incoming: &DriverSandbox) -> bool { + incoming.status.as_ref().is_some_and(|status| { + status.conditions.iter().any(|condition| { + condition.r#type.eq_ignore_ascii_case("Suspended") + && condition.status.eq_ignore_ascii_case("false") + && matches!( + condition.reason.to_ascii_lowercase().as_str(), + "podterminating" | "podnotterminated" + ) + }) + }) +} + fn ensure_supervisor_ready_status(status: &mut Option, sandbox_name: &str) { upsert_ready_condition( status, @@ -2909,21 +4350,27 @@ struct ComposedPhase { } impl ComposedPhase { - fn new(incoming_status: &DriverSandboxStatus, session_connected: bool) -> Self { + fn new( + incoming_status: &DriverSandboxStatus, + session_connected: bool, + driver_reports_runtime_readiness: bool, + ) -> Self { let backend_phase = derive_phase(Some(incoming_status)); // A live supervisor session is a stronger readiness signal than the backend phase. // set_supervisor_session_state may have already promoted the store record to Ready // before this driver snapshot arrived. Keep Ready rather than letting a lagging // backend phase overwrite it. let phase = match backend_phase { - SandboxPhase::Error | SandboxPhase::Deleting => backend_phase, + SandboxPhase::Error | SandboxPhase::Deleting | SandboxPhase::Stopped => backend_phase, + SandboxPhase::Ready if driver_reports_runtime_readiness => SandboxPhase::Ready, _ if session_connected => SandboxPhase::Ready, _ => SandboxPhase::Provisioning, }; Self { phase, session_connected, - backend_ready_without_session: backend_phase == SandboxPhase::Ready + backend_ready_without_session: !driver_reports_runtime_readiness + && backend_phase == SandboxPhase::Ready && !session_connected, } } @@ -3019,6 +4466,27 @@ fn derive_phase(status: Option<&DriverSandboxStatus>) -> SandboxPhase { return SandboxPhase::Deleting; } + // `Ready=True` means the sandbox is usable through this gateway and must + // win over a `Suspended=True` condition. Agent Sandbox v1beta1 sets + // `Suspended=True (PodTerminated)` on stop and does not clear it on resume, + // so a resumed CR carries both `Ready=True` and a stale `Suspended=True`. + // Treating any `Suspended=True` as Stopped would pin the resumed sandbox at + // Starting forever (issue #2932). A genuine stop leaves `Ready` unset or + // False, so `Suspended` still resolves to Stopped in that case. + let ready = status.conditions.iter().any(|condition| { + condition.r#type.eq_ignore_ascii_case("Ready") + && condition.status.eq_ignore_ascii_case("true") + }); + + if !ready + && status.conditions.iter().any(|condition| { + condition.r#type.eq_ignore_ascii_case("Suspended") + && condition.status.eq_ignore_ascii_case("true") + }) + { + return SandboxPhase::Stopped; + } + for condition in &status.conditions { if condition.r#type == "Ready" { return if condition.status.eq_ignore_ascii_case("true") { @@ -3072,10 +4540,27 @@ fn sandbox_phase_should_be_running(phase: SandboxPhase) -> bool { SandboxPhase::Unspecified | SandboxPhase::Provisioning | SandboxPhase::Ready + | SandboxPhase::Starting | SandboxPhase::Unknown ) } +/// Error-phase sandboxes are only eligible for startup recovery when their +/// Ready condition reason indicates the runtime went away underneath a running +/// container — a machine/daemon restart that terminated it by signal +/// (`CONDITION_RUNTIME_RESTART`) or an explicit runtime stop +/// (`CONDITION_STOPPED`). Ordinary application exits (`CONDITION_EXITED`, which +/// covers crashes and non-zero exits) stay terminal so a genuine failure keeps +/// its error signal instead of being relaunched on every gateway startup. +fn is_recoverable_error_reason(sandbox: &Sandbox) -> bool { + use openshell_core::driver_utils::{CONDITION_RUNTIME_RESTART, CONDITION_STOPPED}; + sandbox + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .is_some_and(|c| c.reason == CONDITION_RUNTIME_RESTART || c.reason == CONDITION_STOPPED) +} + fn is_terminal_failure_reason(reason: &str) -> bool { let reason = reason.to_ascii_lowercase(); let transient_reasons = [ @@ -3092,12 +4577,69 @@ fn is_terminal_failure_reason(reason: &str) -> bool { } #[cfg(test)] -#[derive(Debug, Default)] -pub struct NoopTestDriver; +#[derive(Debug)] +pub struct NoopTestDriver { + workspace_delete_failures: std::sync::atomic::AtomicUsize, + sandbox_authentication: Option>, +} #[cfg(test)] -#[tonic::async_trait] -impl ComputeDriver for NoopTestDriver { +impl NoopTestDriver { + pub fn failing_workspace_deletes(count: usize) -> Self { + Self { + workspace_delete_failures: std::sync::atomic::AtomicUsize::new(count), + sandbox_authentication: None, + } + } + + pub fn authenticating_sandbox(sandbox_id: impl Into) -> Self { + Self { + workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), + sandbox_authentication: Some(Ok(sandbox_id.into())), + } + } + + pub fn failing_sandbox_authentication(code: Code, message: impl Into) -> Self { + Self { + workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), + sandbox_authentication: Some(Err((code, message.into()))), + } + } +} + +#[cfg(test)] +impl Default for NoopTestDriver { + fn default() -> Self { + Self { + workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), + sandbox_authentication: None, + } + } +} + +#[cfg(test)] +#[tonic::async_trait] +impl ComputeDriver for NoopTestDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + match &self.sandbox_authentication { + Some(Ok(sandbox_id)) => Ok(tonic::Response::new( + openshell_core::proto::compute::v1::AuthenticateSandboxResponse { + sandbox_id: sandbox_id.clone(), + }, + )), + Some(Err((code, message))) => Err(Status::new(*code, message.clone())), + None => Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )), + } + } + type WatchSandboxesStream = DriverWatchStream; async fn get_capabilities( @@ -3110,6 +4652,9 @@ impl ComputeDriver for NoopTestDriver { driver_name: "noop-test-driver".to_string(), driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: self.sandbox_authentication.is_some(), + driver_reports_runtime_readiness: false, }, )) } @@ -3167,7 +4712,7 @@ impl ComputeDriver for NoopTestDriver { async fn stop_sandbox( &self, - _request: Request, + _request: Request, ) -> Result, Status> { Ok(tonic::Response::new( @@ -3175,6 +4720,16 @@ impl ComputeDriver for NoopTestDriver { )) } + async fn start_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Ok(tonic::Response::new( + openshell_core::proto::compute::v1::StartSandboxResponse {}, + )) + } + async fn delete_sandbox( &self, _request: Request, @@ -3191,6 +4746,31 @@ impl ComputeDriver for NoopTestDriver { ) -> Result, Status> { Ok(tonic::Response::new(Box::pin(futures::stream::empty()))) } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(tonic::Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + if self + .workspace_delete_failures + .fetch_update( + std::sync::atomic::Ordering::Relaxed, + std::sync::atomic::Ordering::Relaxed, + |remaining| remaining.checked_sub(1), + ) + .is_ok() + { + return Err(Status::unavailable("injected workspace cleanup failure")); + } + Ok(tonic::Response::new(DeleteWorkspaceResponse {})) + } } #[cfg(test)] @@ -3200,15 +4780,27 @@ pub async fn new_test_runtime(store: Arc) -> ComputeRuntime { #[cfg(test)] pub async fn new_test_runtime_for_driver(store: Arc, driver_name: &str) -> ComputeRuntime { + new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())).await +} + +#[cfg(test)] +pub async fn new_test_runtime_with_driver( + store: Arc, + driver_name: &str, + driver: Arc, +) -> ComputeRuntime { + let supports_sandbox_authentication = driver.sandbox_authentication.is_some(); ComputeRuntime { - driver: TracedDriver::new(Arc::new(NoopTestDriver), "test".to_string()), + driver: TracedDriver::new(driver, "test".to_string()), driver_info: ComputeDriverInfoSnapshot { name: driver_name.to_string(), driver_name: driver_name.to_string(), driver_version: "test".to_string(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication, + driver_reports_runtime_readiness: false, }, - shutdown_cleanup: None, - startup_resume: None, + telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), store, @@ -3217,7 +4809,7 @@ pub async fn new_test_runtime_for_driver(store: Arc, driver_name: &str) - tracing_log_bus: TracingLogBus::new(), supervisor_sessions: Arc::new(SupervisorSessionRegistry::new()), sync_lock: Arc::new(Mutex::new(())), - delete_gates: Arc::new(DeleteGateRegistry::default()), + lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), } @@ -3229,8 +4821,8 @@ mod tests { use futures::stream; use openshell_core::proto::compute::v1::{ CreateSandboxResponse, DeleteSandboxResponse, GetCapabilitiesResponse, GetSandboxRequest, - GetSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateResponse, - WatchSandboxesDeletedEvent, WatchSandboxesSandboxEvent, + GetSandboxResponse, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, + ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesSandboxEvent, }; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -3355,10 +4947,23 @@ mod tests { struct TestDriver { listed_sandboxes: Vec, current_sandboxes: Vec, + workspace_rpcs_unimplemented: bool, } #[tonic::async_trait] impl ComputeDriver for TestDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = DriverWatchStream; async fn get_capabilities( @@ -3369,6 +4974,9 @@ mod tests { driver_name: "test-driver".to_string(), driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, })) } @@ -3446,6 +5054,13 @@ mod tests { Ok(tonic::Response::new(StopSandboxResponse {})) } + async fn start_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Ok(tonic::Response::new(StartSandboxResponse {})) + } + async fn delete_sandbox( &self, _request: Request, @@ -3461,6 +5076,38 @@ mod tests { ) -> Result, Status> { Ok(tonic::Response::new(Box::pin(stream::empty()))) } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + if self.workspace_rpcs_unimplemented { + return Err(Status::unimplemented("workspace lifecycle is unsupported")); + } + Ok(tonic::Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + if self.workspace_rpcs_unimplemented { + return Err(Status::unimplemented("workspace lifecycle is unsupported")); + } + Ok(tonic::Response::new(DeleteWorkspaceResponse {})) + } + } + + #[tokio::test] + async fn workspace_lifecycle_allows_legacy_driver_without_workspace_rpcs() { + let runtime = test_runtime(Arc::new(TestDriver { + workspace_rpcs_unimplemented: true, + ..Default::default() + })) + .await; + + runtime.ensure_workspace("legacy").await.unwrap(); + runtime.delete_workspace("legacy").await.unwrap(); } #[derive(Clone)] @@ -3476,6 +5123,13 @@ mod tests { Error(&'static str), } + #[derive(Clone)] + enum ControlledLifecycleOutcome { + Ok, + NotFound, + Error(&'static str), + } + struct ControlledDriver { watch_tx: mpsc::UnboundedSender>, watch_rx: TestMutex>>>, @@ -3484,7 +5138,22 @@ mod tests { delete_release: Semaphore, delete_blocked: AtomicBool, delete_calls: AtomicUsize, + delete_requests: TestMutex>, delete_outcome: TestMutex, + stop_started: Notify, + stop_finished: Notify, + stop_release: Semaphore, + stop_blocked: AtomicBool, + stop_calls: AtomicUsize, + stop_requests: TestMutex>, + stop_outcome: TestMutex, + start_started: Notify, + start_finished: Notify, + start_release: Semaphore, + start_blocked: AtomicBool, + start_calls: AtomicUsize, + start_requests: TestMutex>, + start_outcome: TestMutex, get_started: Notify, get_release: Semaphore, get_blocked: AtomicBool, @@ -3502,7 +5171,22 @@ mod tests { delete_release: Semaphore::new(0), delete_blocked: AtomicBool::new(false), delete_calls: AtomicUsize::new(0), + delete_requests: TestMutex::new(Vec::new()), delete_outcome: TestMutex::new(ControlledDeleteOutcome::Ok(true)), + stop_started: Notify::new(), + stop_finished: Notify::new(), + stop_release: Semaphore::new(0), + stop_blocked: AtomicBool::new(false), + stop_calls: AtomicUsize::new(0), + stop_requests: TestMutex::new(Vec::new()), + stop_outcome: TestMutex::new(ControlledLifecycleOutcome::Ok), + start_started: Notify::new(), + start_finished: Notify::new(), + start_release: Semaphore::new(0), + start_blocked: AtomicBool::new(false), + start_calls: AtomicUsize::new(0), + start_requests: TestMutex::new(Vec::new()), + start_outcome: TestMutex::new(ControlledLifecycleOutcome::Ok), get_started: Notify::new(), get_release: Semaphore::new(0), get_blocked: AtomicBool::new(false), @@ -3518,6 +5202,22 @@ mod tests { self.delete_release.add_permits(1); } + fn block_stop(&self) { + self.stop_blocked.store(true, Ordering::SeqCst); + } + + fn release_stop(&self) { + self.stop_release.add_permits(1); + } + + fn block_start(&self) { + self.start_blocked.store(true, Ordering::SeqCst); + } + + fn release_start(&self) { + self.start_release.add_permits(1); + } + fn block_get(&self) { self.get_blocked.store(true, Ordering::SeqCst); } @@ -3533,6 +5233,20 @@ mod tests { .expect("delete outcome lock poisoned") = outcome; } + fn set_stop_outcome(&self, outcome: ControlledLifecycleOutcome) { + *self + .stop_outcome + .lock() + .expect("stop outcome lock poisoned") = outcome; + } + + fn set_start_outcome(&self, outcome: ControlledLifecycleOutcome) { + *self + .start_outcome + .lock() + .expect("start outcome lock poisoned") = outcome; + } + fn set_get_outcome(&self, outcome: ControlledGetOutcome) { *self.get_outcome.lock().expect("get outcome lock poisoned") = outcome; } @@ -3541,6 +5255,35 @@ mod tests { self.delete_calls.load(Ordering::SeqCst) } + fn delete_requests(&self) -> Vec<(String, String)> { + self.delete_requests + .lock() + .expect("delete requests lock poisoned") + .clone() + } + + fn stop_calls(&self) -> usize { + self.stop_calls.load(Ordering::SeqCst) + } + + fn stop_requests(&self) -> Vec<(String, String)> { + self.stop_requests + .lock() + .expect("stop requests lock poisoned") + .clone() + } + + fn start_calls(&self) -> usize { + self.start_calls.load(Ordering::SeqCst) + } + + fn start_requests(&self) -> Vec<(String, String)> { + self.start_requests + .lock() + .expect("start requests lock poisoned") + .clone() + } + fn send_event(&self, event: WatchSandboxesEvent) { self.watch_tx .send(Ok(event)) @@ -3550,6 +5293,18 @@ mod tests { #[tonic::async_trait] impl ComputeDriver for ControlledDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = DriverWatchStream; async fn get_capabilities( @@ -3560,6 +5315,9 @@ mod tests { driver_name: "controlled-test-driver".to_string(), driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, })) } @@ -3630,15 +5388,75 @@ mod tests { async fn stop_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Ok(tonic::Response::new(StopSandboxResponse {})) + let request = request.into_inner(); + self.stop_requests + .lock() + .expect("stop requests lock poisoned") + .push((request.sandbox_id, request.sandbox_name)); + self.stop_calls.fetch_add(1, Ordering::SeqCst); + self.stop_started.notify_one(); + if self.stop_blocked.load(Ordering::SeqCst) { + self.stop_release + .acquire() + .await + .expect("stop release semaphore closed") + .forget(); + } + self.stop_finished.notify_one(); + let outcome = self + .stop_outcome + .lock() + .expect("stop outcome lock poisoned") + .clone(); + match outcome { + ControlledLifecycleOutcome::Ok => Ok(tonic::Response::new(StopSandboxResponse {})), + ControlledLifecycleOutcome::NotFound => Err(Status::not_found("sandbox not found")), + ControlledLifecycleOutcome::Error(message) => Err(Status::internal(message)), + } + } + + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + self.start_requests + .lock() + .expect("start requests lock poisoned") + .push((request.sandbox_id, request.sandbox_name)); + self.start_calls.fetch_add(1, Ordering::SeqCst); + self.start_started.notify_one(); + if self.start_blocked.load(Ordering::SeqCst) { + self.start_release + .acquire() + .await + .expect("start release semaphore closed") + .forget(); + } + self.start_finished.notify_one(); + let outcome = self + .start_outcome + .lock() + .expect("start outcome lock poisoned") + .clone(); + match outcome { + ControlledLifecycleOutcome::Ok => Ok(tonic::Response::new(StartSandboxResponse {})), + ControlledLifecycleOutcome::NotFound => Err(Status::not_found("sandbox not found")), + ControlledLifecycleOutcome::Error(message) => Err(Status::internal(message)), + } } async fn delete_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { + let request = request.into_inner(); + self.delete_requests + .lock() + .expect("delete requests lock poisoned") + .push((request.sandbox_id, request.sandbox_name)); self.delete_calls.fetch_add(1, Ordering::SeqCst); self.delete_started.notify_one(); if self.delete_blocked.load(Ordering::SeqCst) { @@ -3676,26 +5494,42 @@ mod tests { UnboundedReceiverStream::new(receiver), ))) } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(tonic::Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(tonic::Response::new(DeleteWorkspaceResponse {})) + } } async fn test_runtime(driver: SharedComputeDriver) -> ComputeRuntime { - test_runtime_with_resume(driver, None).await + test_runtime_for_driver(driver, "test-driver").await } - async fn test_runtime_with_resume( + async fn test_runtime_for_driver( driver: SharedComputeDriver, - startup_resume: Option>, + driver_name: &str, ) -> ComputeRuntime { let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); ComputeRuntime { driver: TracedDriver::new(driver, "test-driver".to_string()), driver_info: ComputeDriverInfoSnapshot { - name: "test-driver".to_string(), - driver_name: "test-driver".to_string(), + name: driver_name.to_string(), + driver_name: driver_name.to_string(), driver_version: "test".to_string(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, }, - shutdown_cleanup: None, - startup_resume, + telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), store, @@ -3704,12 +5538,21 @@ mod tests { tracing_log_bus: TracingLogBus::new(), supervisor_sessions: Arc::new(SupervisorSessionRegistry::new()), sync_lock: Arc::new(Mutex::new(())), - delete_gates: Arc::new(DeleteGateRegistry::default()), + lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), } } + async fn test_runtime_with_gateway_managed_lifecycle( + driver: SharedComputeDriver, + driver_name: &str, + ) -> ComputeRuntime { + let mut runtime = test_runtime_for_driver(driver, driver_name).await; + runtime.driver_info.gateway_manages_lifecycle = true; + runtime + } + fn register_test_supervisor_session(runtime: &ComputeRuntime, sandbox_id: &str) { let (tx, _rx) = mpsc::channel(1); let (shutdown_tx, _shutdown_rx) = oneshot::channel(); @@ -3739,734 +5582,2052 @@ mod tests { sandbox } - fn ssh_session_record(id: &str, sandbox_id: &str) -> SshSession { - SshSession { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: id.to_string(), - name: format!("session-{id}"), - created_at_ms: 1_000_000, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - sandbox_id: sandbox_id.to_string(), - token: format!("token-{id}"), - revoked: false, - expires_at_ms: 0, - } + #[test] + fn main_process_exit_zero_is_completed() { + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + apply_main_process_exit(&mut sandbox, "instance-1", 0); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Completed) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(0)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status == "False" + && condition.reason == "MainProcessCompleted" + })); } - fn service_endpoint_record(id: &str, sandbox: &Sandbox) -> ServiceEndpoint { - ServiceEndpoint { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: id.to_string(), - name: format!("{}--web", sandbox.object_name()), - created_at_ms: 1_000_000, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: sandbox.object_workspace().to_string(), - deletion_timestamp_ms: 0, - }), - sandbox_id: sandbox.object_id().to_string(), - sandbox_name: sandbox.object_name().to_string(), - service_name: "web".to_string(), - target_port: 8080, - domain: true, - } + #[test] + fn main_process_nonzero_exit_is_error() { + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + apply_main_process_exit(&mut sandbox, "instance-1", 7); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Error) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(7)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status == "False" + && condition.reason == "MainProcessFailed" + })); } - async fn seed_sandbox_owned_records(runtime: &ComputeRuntime, sandbox: &Sandbox) -> SshSession { + #[test] + fn main_process_exit_replaces_provisional_container_exit() { + let mut sandbox = error_sandbox_record("sb-1", "sandbox-a", "ContainerExited"); + apply_main_process_exit(&mut sandbox, "instance-1", 0); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Completed) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(0)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status == "False" + && condition.reason == "MainProcessCompleted" + })); + } + + #[test] + fn main_process_exit_preserves_specific_infrastructure_error() { + let mut sandbox = error_sandbox_record("sb-1", "sandbox-a", "BackendResourceMissing"); + apply_main_process_exit(&mut sandbox, "instance-1", 0); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Error) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(0)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" && condition.reason == "BackendResourceMissing" + })); + } + + #[tokio::test] + async fn stale_main_process_exit_is_acknowledged_without_replacing_active_instance() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); runtime - .store - .put( - SANDBOX_SETTINGS_OBJECT_TYPE, - &format!("settings-{}", sandbox.object_id()), - sandbox.object_name(), - sandbox.object_workspace(), - br#"{"revision":1,"settings":{}}"#, - None, - ) + .supervisor_session_connected("sb-1", "instance-2") .await .unwrap(); - let session = ssh_session_record("owned", sandbox.object_id()); - runtime.store.put_message(&session).await.unwrap(); - - let endpoint = service_endpoint_record("endpoint-owned", sandbox); - runtime.store.put_message(&endpoint).await.unwrap(); runtime - .store - .put_scoped( - POLICY_OBJECT_TYPE, - "policy-owned", - "policy-owned", - sandbox.object_workspace(), - sandbox.object_id(), - br#"{"version":1}"#, - None, - ) + .main_process_exited("sb-1", "instance-1", 0) .await .unwrap(); - runtime + let stored = runtime .store - .put_scoped( - DRAFT_CHUNK_OBJECT_TYPE, - "draft-owned", - "draft-owned", - sandbox.object_workspace(), - sandbox.object_id(), - br#"{"chunk":1}"#, - None, - ) + .get_message::("sb-1") .await + .unwrap() .unwrap(); - session + assert_eq!(stored.phase(), SandboxPhase::Ready as i32); + assert_eq!( + stored.status.unwrap().main_process_instance_id, + "instance-2" + ); } - async fn remove_sandbox_owned_records_from_store(runtime: &ComputeRuntime, sandbox: &Sandbox) { + #[tokio::test] + async fn missing_sandbox_main_process_exit_is_acknowledged() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + runtime - .cleanup_sandbox_owned_records(sandbox) + .main_process_exited("missing", "instance-1", 0) + .await + .unwrap(); + } + + #[tokio::test] + async fn duplicate_main_process_exit_is_idempotent() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "instance-1") .await .unwrap(); runtime + .main_process_exited("sb-1", "instance-1", 9) + .await + .unwrap(); + runtime + .main_process_exited("sb-1", "instance-1", 9) + .await + .unwrap(); + let stored = runtime .store - .delete(Sandbox::object_type(), sandbox.object_id()) + .get_message::("sb-1") .await + .unwrap() .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Error as i32); + assert_eq!(stored.status.unwrap().exit_code, Some(9)); } - async fn assert_sandbox_owned_records( - runtime: &ComputeRuntime, - sandbox: &Sandbox, - session: &SshSession, - expected: bool, - ) { - assert_eq!( - runtime - .store - .get_by_name( - SANDBOX_SETTINGS_OBJECT_TYPE, - sandbox.object_workspace(), - sandbox.object_name(), - ) - .await - .unwrap() - .is_some(), - expected - ); - assert_eq!( - runtime - .store - .get_message::(session.object_id()) - .await - .unwrap() - .is_some(), - expected - ); - assert_eq!( - runtime - .store - .get_message::("endpoint-owned") - .await - .unwrap() - .is_some(), - expected + #[tokio::test] + async fn ephemeral_cleanup_waits_for_terminal_finalization() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + sandbox.metadata.as_mut().unwrap().annotations.insert( + "openshell.nvidia.com/retention".to_string(), + "ephemeral".to_string(), ); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "instance-1") + .await + .unwrap(); + + runtime + .report_main_process_exit("sb-1", "instance-1", 0) + .await + .unwrap(); + assert_eq!(driver.delete_calls(), 0); assert_eq!( runtime .store - .get(POLICY_OBJECT_TYPE, "policy-owned") + .get_message::("sb-1") .await .unwrap() - .is_some(), - expected - ); - assert_eq!( - runtime - .store - .get(DRAFT_CHUNK_OBJECT_TYPE, "draft-owned") - .await .unwrap() - .is_some(), - expected + .phase(), + SandboxPhase::Completed as i32 ); - } - - fn make_driver_condition(reason: &str, message: &str) -> DriverCondition { - DriverCondition { - r#type: "Ready".to_string(), - status: "False".to_string(), - reason: reason.to_string(), - message: message.to_string(), - last_transition_time: String::new(), - } - } - fn make_driver_status(condition: DriverCondition) -> DriverSandboxStatus { - DriverSandboxStatus { - sandbox_name: "test".to_string(), - instance_id: "test-pod".to_string(), - agent_fd: String::new(), - sandbox_fd: String::new(), - conditions: vec![condition], - deleting: false, - } + runtime + .finalize_main_process_exit("sb-1", "instance-1") + .await + .unwrap(); + assert_eq!(driver.delete_calls(), 0); + runtime + .supervisor_session_disconnected("sb-1", true) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while driver.delete_calls() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("terminal finalization should release ephemeral cleanup"); } - fn ready_driver_sandbox(id: &str, name: &str) -> DriverSandbox { - DriverSandbox { - id: id.to_string(), - name: name.to_string(), - namespace: "default".to_string(), - workspace: "default".to_string(), - spec: None, - status: Some(DriverSandboxStatus { - sandbox_name: name.to_string(), - instance_id: format!("{name}-pod"), - agent_fd: String::new(), - sandbox_fd: String::new(), - conditions: vec![DriverCondition { - r#type: "Ready".to_string(), - status: "True".to_string(), - reason: "BackendReady".to_string(), - message: "Container is running".to_string(), - last_transition_time: String::new(), - }], - deleting: false, - }), - } - } + #[tokio::test] + async fn conflicting_duplicate_main_process_exit_is_acknowledged() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "instance-1") + .await + .unwrap(); + runtime + .main_process_exited("sb-1", "instance-1", 9) + .await + .unwrap(); + runtime + .main_process_exited("sb-1", "instance-1", 7) + .await + .unwrap(); - fn sandbox_watch_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { - sandbox: Some(sandbox), - }, - )), - } + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status.unwrap().exit_code, Some(9)); } - fn deleted_watch_event(sandbox_id: &str) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Deleted( - WatchSandboxesDeletedEvent { - sandbox_id: sandbox_id.to_string(), - }, - )), - } - } + #[tokio::test] + async fn precise_exit_enriches_driver_terminal_fallback() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + sandbox.status = Some(SandboxStatus { + phase: SandboxPhase::Error as i32, + main_process_instance_id: "instance-1".into(), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); - async fn start_watch_loop( - runtime: &ComputeRuntime, - driver: &ControlledDriver, - ) -> (watch::Sender, tokio::task::JoinHandle<()>) { - let (shutdown_tx, shutdown_rx) = watch::channel(false); - let runtime = Arc::new(runtime.clone()); - let handle = tokio::spawn(async move { runtime.watch_loop(shutdown_rx).await }); - tokio::time::timeout(Duration::from_secs(1), driver.watch_started.notified()) + runtime + .main_process_exited("sb-1", "instance-1", 7) .await - .expect("watch loop did not start"); - (shutdown_tx, handle) - } + .unwrap(); - async fn stop_watch_loop( - shutdown_tx: watch::Sender, - handle: tokio::task::JoinHandle<()>, - ) { - shutdown_tx.send(true).unwrap(); - tokio::time::timeout(Duration::from_secs(1), handle) + let stored = runtime + .store + .get_message::("sb-1") .await - .expect("watch loop did not stop") + .unwrap() .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Error as i32); + assert_eq!(stored.status.unwrap().exit_code, Some(7)); } #[tokio::test] - async fn sqlite_store_is_single_replica() { - let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); - assert!(store.is_single_replica()); - } + async fn intentional_stop_ignores_main_process_exit_report() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + for (id, phase) in [ + ("sb-stopping", SandboxPhase::Stopping), + ("sb-stopped", SandboxPhase::Stopped), + ] { + let mut sandbox = sandbox_record(id, id, phase); + sandbox.status = Some(SandboxStatus { + phase: phase as i32, + main_process_instance_id: "instance-1".into(), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); - #[test] - fn delete_gate_registry_removes_stale_entries() { - let registry = DeleteGateRegistry::default(); - let first = registry.gate_for("sb-1"); - assert_eq!(registry.entry_count(), 1); - drop(first); + runtime + .main_process_exited(id, "instance-1", 143) + .await + .unwrap(); + runtime + .finalize_main_process_exit(id, "instance-1") + .await + .expect("intentional shutdown finalization should be acknowledged"); - let _second = registry.gate_for("sb-2"); - assert_eq!(registry.entry_count(), 1); + let stored = runtime + .store + .get_message::(id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), phase as i32); + assert_eq!(stored.status.unwrap().exit_code, None); + } } - #[test] - fn terminal_failure_treats_unknown_reasons_as_terminal() { - let terminal_cases = [ - ("Failed", "Something went wrong"), - ("CrashLoopBackOff", "Container keeps crashing"), - ("ImagePullBackOff", "Failed to pull image"), - ("ErrImagePull", "Error pulling image"), - ("Unschedulable", "No nodes match"), - ("SomeOtherReason", "Any other reason is terminal"), - ]; - - for (reason, message) in terminal_cases { - assert!( - is_terminal_failure_reason(reason), - "Expected terminal failure for reason={reason}, message={message}" - ); + #[tokio::test] + async fn starting_uses_previous_main_process_instance_as_exit_tombstone() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Stopped); + sandbox.status = Some(SandboxStatus { + phase: SandboxPhase::Stopped as i32, + main_process_instance_id: "instance-old".into(), + exit_code: Some(143), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + + let starting = runtime + .write_lifecycle_phase( + &stored, + SandboxPhase::Starting, + "Starting", + "Sandbox start requested", + ) + .await + .unwrap(); + let status = starting.status.as_ref().unwrap(); + assert_eq!(status.main_process_instance_id, "instance-old"); + assert_eq!(status.exit_code, None); + + runtime + .main_process_exited("sb-1", "instance-old", 143) + .await + .unwrap(); + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Starting as i32); + let status = stored.status.as_ref().unwrap(); + assert_eq!(status.main_process_instance_id, "instance-old"); + assert_eq!(status.exit_code, None); + + runtime + .main_process_exited("sb-1", "instance-new", 1) + .await + .unwrap(); + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Error as i32); + let status = stored.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-new"); + assert_eq!(status.exit_code, Some(1)); + } + + fn error_sandbox_record(id: &str, name: &str, reason: &str) -> Sandbox { + let mut sandbox = sandbox_record(id, name, SandboxPhase::Error); + let status = sandbox.status.get_or_insert_with(Default::default); + status.conditions.push(SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.to_string(), + message: String::new(), + last_transition_time: String::new(), + }); + sandbox + } + + fn ssh_session_record(id: &str, sandbox_id: &str) -> SshSession { + SshSession { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: id.to_string(), + name: format!("session-{id}"), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + sandbox_id: sandbox_id.to_string(), + token: format!("token-{id}"), + revoked: false, + expires_at_ms: 0, } } - #[test] - fn terminal_failure_ignores_transient_reasons() { - let transient_cases = [ - ( - "ReconcilerError", - "Error seen: failed to update pod: Operation cannot be fulfilled", - ), - ("reconcilererror", "lowercase also works"), - ("RECONCILERERROR", "uppercase also works"), - ( - "DependenciesNotReady", - "Pod exists with phase: Pending; Service Exists", - ), - ("dependenciesnotready", "lowercase also works"), - ( - "SupervisorNotConnected", - "Backend ready; waiting for supervisor session", - ), - ("Starting", "VM is starting"), - ( - "ContainerCreated", - "Podman created the container before starting it", - ), - ]; + fn service_endpoint_record(id: &str, sandbox: &Sandbox) -> ServiceEndpoint { + ServiceEndpoint { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: id.to_string(), + name: format!("{}--web", sandbox.object_name()), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: sandbox.object_workspace().to_string(), + deletion_timestamp_ms: 0, + }), + sandbox_id: sandbox.object_id().to_string(), + sandbox_name: sandbox.object_name().to_string(), + service_name: "web".to_string(), + target_port: 8080, + domain: true, + } + } - for (reason, message) in transient_cases { - assert!( - !is_terminal_failure_reason(reason), - "Expected transient (non-terminal) for reason={reason}, message={message}" - ); + async fn seed_sandbox_owned_records(runtime: &ComputeRuntime, sandbox: &Sandbox) -> SshSession { + runtime + .store + .put( + SANDBOX_SETTINGS_OBJECT_TYPE, + &format!("settings-{}", sandbox.object_id()), + sandbox.object_name(), + sandbox.object_workspace(), + br#"{"revision":1,"settings":{}}"#, + None, + ) + .await + .unwrap(); + let session = ssh_session_record("owned", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + + let endpoint = service_endpoint_record("endpoint-owned", sandbox); + runtime.store.put_message(&endpoint).await.unwrap(); + + runtime + .store + .put_scoped( + POLICY_OBJECT_TYPE, + "policy-owned", + "policy-owned", + sandbox.object_workspace(), + sandbox.object_id(), + br#"{"version":1}"#, + None, + ) + .await + .unwrap(); + runtime + .store + .put_scoped( + DRAFT_CHUNK_OBJECT_TYPE, + "draft-owned", + "draft-owned", + sandbox.object_workspace(), + sandbox.object_id(), + br#"{"chunk":1}"#, + None, + ) + .await + .unwrap(); + session + } + + async fn remove_sandbox_owned_records_from_store(runtime: &ComputeRuntime, sandbox: &Sandbox) { + runtime + .cleanup_sandbox_owned_records(sandbox) + .await + .unwrap(); + runtime + .store + .delete(Sandbox::object_type(), sandbox.object_id()) + .await + .unwrap(); + } + + async fn assert_sandbox_owned_records( + runtime: &ComputeRuntime, + sandbox: &Sandbox, + session: &SshSession, + expected: bool, + ) { + assert_eq!( + runtime + .store + .get_by_name( + SANDBOX_SETTINGS_OBJECT_TYPE, + sandbox.object_workspace(), + sandbox.object_name(), + ) + .await + .unwrap() + .is_some(), + expected + ); + assert_eq!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_some(), + expected + ); + assert_eq!( + runtime + .store + .get_message::("endpoint-owned") + .await + .unwrap() + .is_some(), + expected + ); + assert_eq!( + runtime + .store + .get(POLICY_OBJECT_TYPE, "policy-owned") + .await + .unwrap() + .is_some(), + expected + ); + assert_eq!( + runtime + .store + .get(DRAFT_CHUNK_OBJECT_TYPE, "draft-owned") + .await + .unwrap() + .is_some(), + expected + ); + } + + fn make_driver_condition(reason: &str, message: &str) -> DriverCondition { + DriverCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.to_string(), + message: message.to_string(), + last_transition_time: String::new(), } } - #[test] - fn derive_phase_returns_unknown_without_status() { - assert_eq!(derive_phase(None), SandboxPhase::Unknown); + fn make_driver_status(condition: DriverCondition) -> DriverSandboxStatus { + DriverSandboxStatus { + sandbox_name: "test".to_string(), + instance_id: "test-pod".to_string(), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![condition], + deleting: false, + } } - #[test] - fn derive_phase_returns_deleting_when_driver_marks_deleting() { - let status = DriverSandboxStatus { - deleting: true, - ..make_driver_status(make_driver_condition( - "DependenciesNotReady", - "Pod still pending", - )) - }; + fn ready_driver_sandbox(id: &str, name: &str) -> DriverSandbox { + DriverSandbox { + id: id.to_string(), + name: name.to_string(), + namespace: "default".to_string(), + workspace: "default".to_string(), + spec: None, + status: Some(DriverSandboxStatus { + sandbox_name: name.to_string(), + instance_id: format!("{name}-pod"), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![DriverCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "BackendReady".to_string(), + message: "Container is running".to_string(), + last_transition_time: String::new(), + }], + deleting: false, + }), + } + } + + fn sandbox_watch_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), + } + } + + fn deleted_watch_event(sandbox_id: &str) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { + sandbox_id: sandbox_id.to_string(), + }, + )), + } + } + + async fn start_watch_loop( + runtime: &ComputeRuntime, + driver: &ControlledDriver, + ) -> (watch::Sender, tokio::task::JoinHandle<()>) { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let runtime = Arc::new(runtime.clone()); + let handle = tokio::spawn(async move { Box::pin(runtime.watch_loop(shutdown_rx)).await }); + tokio::time::timeout(Duration::from_secs(1), driver.watch_started.notified()) + .await + .expect("watch loop did not start"); + (shutdown_tx, handle) + } + + async fn stop_watch_loop( + shutdown_tx: watch::Sender, + handle: tokio::task::JoinHandle<()>, + ) { + shutdown_tx.send(true).unwrap(); + tokio::time::timeout(Duration::from_secs(1), handle) + .await + .expect("watch loop did not stop") + .unwrap(); + } + + #[tokio::test] + async fn sqlite_store_is_single_replica() { + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + assert!(store.is_single_replica()); + } + + #[test] + fn driver_reported_runtime_uses_driver_readiness() { + let status = make_driver_status(DriverCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "AgentRunning".to_string(), + message: "MXC workload is running".to_string(), + last_transition_time: String::new(), + }); + + let composed = ComposedPhase::new(&status, false, true); + + assert_eq!(composed.phase, SandboxPhase::Ready); + assert!(!composed.backend_ready_without_session); + } + + #[test] + fn delete_gate_registry_removes_stale_entries() { + let registry = LifecycleGateRegistry::default(); + let first = registry.gate_for("sb-1"); + assert_eq!(registry.entry_count(), 1); + drop(first); + + let _second = registry.gate_for("sb-2"); + assert_eq!(registry.entry_count(), 1); + } + + #[test] + fn terminal_failure_treats_unknown_reasons_as_terminal() { + let terminal_cases = [ + ("Failed", "Something went wrong"), + ("CrashLoopBackOff", "Container keeps crashing"), + ("ImagePullBackOff", "Failed to pull image"), + ("ErrImagePull", "Error pulling image"), + ("Unschedulable", "No nodes match"), + ("SomeOtherReason", "Any other reason is terminal"), + ]; + + for (reason, message) in terminal_cases { + assert!( + is_terminal_failure_reason(reason), + "Expected terminal failure for reason={reason}, message={message}" + ); + } + } + + #[test] + fn terminal_failure_ignores_transient_reasons() { + let transient_cases = [ + ( + "ReconcilerError", + "Error seen: failed to update pod: Operation cannot be fulfilled", + ), + ("reconcilererror", "lowercase also works"), + ("RECONCILERERROR", "uppercase also works"), + ( + "DependenciesNotReady", + "Pod exists with phase: Pending; Service Exists", + ), + ("dependenciesnotready", "lowercase also works"), + ( + "SupervisorNotConnected", + "Backend ready; waiting for supervisor session", + ), + ("Starting", "VM is starting"), + ( + "ContainerCreated", + "Podman created the container before starting it", + ), + ]; + + for (reason, message) in transient_cases { + assert!( + !is_terminal_failure_reason(reason), + "Expected transient (non-terminal) for reason={reason}, message={message}" + ); + } + } + + #[test] + fn derive_phase_returns_unknown_without_status() { + assert_eq!(derive_phase(None), SandboxPhase::Unknown); + } + + #[test] + fn derive_phase_returns_deleting_when_driver_marks_deleting() { + let status = DriverSandboxStatus { + deleting: true, + ..make_driver_status(make_driver_condition( + "DependenciesNotReady", + "Pod still pending", + )) + }; + + assert_eq!(derive_phase(Some(&status)), SandboxPhase::Deleting); + } + + #[test] + fn derive_phase_returns_provisioning_for_transient_conditions() { + let transient_conditions = [ + ("ReconcilerError", "Error seen: failed to update pod"), + ( + "DependenciesNotReady", + "Pod exists with phase: Pending; Service Exists", + ), + ("Starting", "VM is starting"), + ( + "ContainerCreated", + "Container exists but has not started yet", + ), + ]; + + for (reason, message) in transient_conditions { + let status = make_driver_status(make_driver_condition(reason, message)); + assert_eq!( + derive_phase(Some(&status)), + SandboxPhase::Provisioning, + "Expected Provisioning for transient reason={reason}" + ); + } + } + + #[test] + fn derive_phase_returns_error_for_terminal_ready_false() { + let status = make_driver_status(make_driver_condition( + "ImagePullBackOff", + "Failed to pull image", + )); + + assert_eq!(derive_phase(Some(&status)), SandboxPhase::Error); + } + + #[test] + fn derive_phase_returns_ready_for_ready_true() { + let status = DriverSandboxStatus { + conditions: vec![DriverCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "DependenciesReady".to_string(), + message: "Pod is Ready; Service Exists".to_string(), + last_transition_time: String::new(), + }], + ..make_driver_status(make_driver_condition("", "")) + }; + + assert_eq!(derive_phase(Some(&status)), SandboxPhase::Ready); + } + + #[test] + fn build_platform_config_omits_typed_cpu_and_memory_resources() { + let template = SandboxTemplate { + resources: Some(prost_types::Struct { + fields: [ + ( + "limits", + struct_value([("cpu", string_value("2")), ("memory", string_value("1Gi"))]), + ), + ( + "requests", + struct_value([ + ("cpu", string_value("500m")), + ("memory", string_value("512Mi")), + ]), + ), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(), + }), + ..Default::default() + }; + + assert!(build_platform_config(&template).is_none()); + } + + #[test] + fn build_platform_config_preserves_non_typed_resource_fields() { + let template = SandboxTemplate { + resources: Some(prost_types::Struct { + fields: [ + ( + "limits", + struct_value([ + ("cpu", string_value("2")), + ("memory", string_value("1Gi")), + ("nvidia.com/gpu", string_value("1")), + ]), + ), + ( + "requests", + struct_value([ + ("cpu", string_value("500m")), + ("memory", string_value("512Mi")), + ("hugepages-2Mi", string_value("4Mi")), + ]), + ), + ("opaque_cpu", number_value(2.0)), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(), + }), + ..Default::default() + }; + + let platform_config = build_platform_config(&template).unwrap(); + let resources_raw = platform_config + .fields + .get("resources_raw") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StructValue(inner) => Some(inner), + _ => None, + }) + .unwrap(); + + let limits = resources_raw + .fields + .get("limits") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StructValue(inner) => Some(inner), + _ => None, + }) + .unwrap(); + assert!(!limits.fields.contains_key("cpu")); + assert!(!limits.fields.contains_key("memory")); + assert_eq!( + limits + .fields + .get("nvidia.com/gpu") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StringValue(value) => Some(value.as_str()), + _ => None, + }), + Some("1") + ); + + let requests = resources_raw + .fields + .get("requests") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StructValue(inner) => Some(inner), + _ => None, + }) + .unwrap(); + assert!(!requests.fields.contains_key("cpu")); + assert!(!requests.fields.contains_key("memory")); + assert_eq!( + requests + .fields + .get("hugepages-2Mi") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StringValue(value) => Some(value.as_str()), + _ => None, + }), + Some("4Mi") + ); + + assert!(resources_raw.fields.contains_key("opaque_cpu")); + } + + #[test] + fn rewrite_user_facing_conditions_rewrites_gpu_unschedulable_message() { + let mut status = Some(SandboxStatus { + sandbox_name: "test".to_string(), + agent_pod: "test-pod".to_string(), + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "Unschedulable".to_string(), + message: "0/1 nodes are available: 1 Insufficient nvidia.com/gpu.".to_string(), + last_transition_time: String::new(), + }], + ..Default::default() + }); + + rewrite_user_facing_conditions( + &mut status, + Some(&SandboxSpec { + resource_requirements: Some(openshell_core::proto::ResourceRequirements { + gpu: Some(openshell_core::proto::GpuResourceRequirements { count: None }), + }), + ..Default::default() + }), + ); + + let message = &status.unwrap().conditions[0].message; + assert_eq!( + message, + "GPU sandbox could not be scheduled on the active gateway. Another GPU sandbox may already be using the available GPU, or the gateway may not currently be able to satisfy GPU placement. Please refer to documentation and use `openshell doctor` commands to inspect GPU support and gateway configuration." + ); + } + + #[test] + fn rewrite_user_facing_conditions_leaves_non_gpu_unschedulable_message_unchanged() { + let original = "0/1 nodes are available: 1 Insufficient cpu."; + let mut status = Some(SandboxStatus { + sandbox_name: "test".to_string(), + agent_pod: "test-pod".to_string(), + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "Unschedulable".to_string(), + message: original.to_string(), + last_transition_time: String::new(), + }], + ..Default::default() + }); + + rewrite_user_facing_conditions(&mut status, Some(&SandboxSpec::default())); + + assert_eq!(status.unwrap().conditions[0].message, original); + } + + #[test] + fn compute_error_from_status_preserves_driver_status_codes() { + assert!(matches!( + compute_error_from_status(Status::already_exists("sandbox already exists")), + ComputeError::AlreadyExists + )); + + assert!(matches!( + compute_error_from_status(Status::failed_precondition("sandbox agent pod IP is not available")), + ComputeError::Precondition(message) if message == "sandbox agent pod IP is not available" + )); + } + + /// Driver calls are a remote boundary even when the driver is in-process. + #[tokio::test] + async fn driver_calls_export_spans_with_parents() { + use tracing::Instrument as _; + + use crate::otel_tracing::test_exporter; + + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-trace", "sandbox-trace", SandboxPhase::Provisioning); + + let traced = test_exporter::install_traced(); + async { + runtime + .create_sandbox(sandbox, None, false) + .await + .expect("create succeeds"); + } + .instrument(tracing::info_span!("request")) + .await; + + let driver_span = traced.span_with( + "openshell.compute.v1.ComputeDriver/CreateSandbox", + "sandbox.id", + "sb-trace", + ); + test_exporter::assert_has_parent(&driver_span); + assert_eq!( + test_exporter::attribute(&driver_span, "driver.name").as_deref(), + Some("test-driver"), + "the span names which driver was called" + ); + assert_eq!( + test_exporter::attribute(&driver_span, "sandbox.id").as_deref(), + Some("sb-trace"), + ); + assert_eq!( + test_exporter::attribute(&driver_span, "rpc.method").as_deref(), + Some("openshell.compute.v1.ComputeDriver/CreateSandbox"), + ); + assert!( + test_exporter::attribute(&driver_span, "rpc.service").is_none(), + "the current RPC semantic conventions integrate the service into rpc.method" + ); + assert_eq!( + test_exporter::attribute(&driver_span, "rpc.response.status_code").as_deref(), + Some("OK"), + ); + assert_eq!( + driver_span.span_kind, + opentelemetry::trace::SpanKind::Client, + "the gateway is the caller at this boundary" + ); + assert!( + !matches!( + driver_span.status, + opentelemetry::trace::Status::Error { .. } + ), + "a successful driver call is not marked an error, got {:?}", + driver_span.status + ); + } + + #[tokio::test] + async fn driver_watch_client_span_lives_until_the_stream_completes() { + use futures::StreamExt as _; + + use crate::otel_tracing::test_exporter; + + let traced = test_exporter::install_traced(); + let driver = TracedDriver::new(Arc::new(TestDriver::default()), "test-driver".to_string()); + let response = driver.watch().await.expect("watch opens"); + assert!( + traced + .finished_spans() + .iter() + .all(|span| span.name != "openshell.compute.v1.ComputeDriver/WatchSandboxes"), + "the client span must remain open while the response stream is alive" + ); + + let mut stream = response.into_inner(); + assert!(stream.next().await.is_none()); + drop(stream); + + let spans = traced.finished_spans(); + let span = spans + .iter() + .find(|span| span.name == "openshell.compute.v1.ComputeDriver/WatchSandboxes") + .expect("watch client span should finish with the stream"); + assert_eq!(span.span_kind, opentelemetry::trace::SpanKind::Client); + assert_eq!( + test_exporter::attribute(span, "rpc.response.status_code").as_deref(), + Some("OK"), + ); + } + + /// A failing driver call must be visible as a failure in the trace, not + /// just as a span that happens to be followed by nothing. + #[tokio::test] + async fn failed_driver_calls_are_marked_on_the_span() { + use tracing::Instrument as _; + + use crate::otel_tracing::test_exporter; + + /// A driver that behaves normally except that creates fail, so the + /// test exercises only the failure attribute. + #[derive(Debug, Default)] + struct FailingDriver(TestDriver); + + #[tonic::async_trait] + impl ComputeDriver for FailingDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result< + tonic::Response, + Status, + > { + Err(Status::unimplemented( + "test driver does not authenticate sandbox credentials", + )) + } + + type WatchSandboxesStream = DriverWatchStream; + + async fn create_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unavailable("driver is down")) + } + + async fn get_capabilities( + &self, + request: Request, + ) -> Result, Status> { + self.0.get_capabilities(request).await + } + + async fn get_gateway_listener_requirements( + &self, + request: Request, + ) -> Result, Status> + { + self.0.get_gateway_listener_requirements(request).await + } + + async fn validate_sandbox_create( + &self, + request: Request, + ) -> Result, Status> { + self.0.validate_sandbox_create(request).await + } + + async fn get_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.0.get_sandbox(request).await + } + + async fn list_sandboxes( + &self, + request: Request, + ) -> Result< + tonic::Response, + Status, + > { + self.0.list_sandboxes(request).await + } + + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.0.stop_sandbox(request).await + } + + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.0.start_sandbox(request).await + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.0.delete_sandbox(request).await + } + + async fn watch_sandboxes( + &self, + request: Request, + ) -> Result, Status> { + self.0.watch_sandboxes(request).await + } + + async fn ensure_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.0.ensure_workspace(request).await + } + + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + self.0.delete_workspace(request).await + } + } + + let runtime = test_runtime(Arc::new(FailingDriver::default())).await; + let sandbox = sandbox_record("sb-fail", "sandbox-fail", SandboxPhase::Provisioning); + + let traced = test_exporter::install_traced(); + async { + runtime + .create_sandbox(sandbox, None, false) + .await + .expect_err("driver refuses the create"); + } + .instrument(tracing::info_span!("request")) + .await; + + let driver_span = traced.span_with( + "openshell.compute.v1.ComputeDriver/CreateSandbox", + "sandbox.id", + "sb-fail", + ); + + assert!( + matches!( + driver_span.status, + opentelemetry::trace::Status::Error { .. } + ), + "the span carries error status so trace UIs flag it, got {:?}", + driver_span.status + ); + assert_eq!( + test_exporter::attribute(&driver_span, "rpc.response.status_code").as_deref(), + Some("UNAVAILABLE"), + "the gRPC code names the cause without reading the message" + ); + } + + #[tokio::test] + async fn stop_and_start_follow_durable_state_machine() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-lifecycle", "sandbox-lifecycle", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = ssh_session_record("lifecycle-session", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let stopped = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + assert_eq!(stopped.phase(), SandboxPhase::Stopped as i32); + assert_eq!(driver.stop_calls(), 1); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none(), + "stop revokes ephemeral SSH sessions" + ); + + let stopped_again = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + assert_eq!(stopped_again.phase(), SandboxPhase::Stopped as i32); + assert_eq!(driver.stop_calls(), 1, "stable stop is idempotent"); + + let starting = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + assert_eq!(starting.phase(), SandboxPhase::Starting as i32); + assert_eq!(driver.start_calls(), 1); + + let starting_again = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + assert_eq!(starting_again.phase(), SandboxPhase::Starting as i32); + assert_eq!( + driver.start_calls(), + 2, + "explicit retry reissues the idempotent start" + ); + + register_test_supervisor_session(&runtime, sandbox.object_id()); + runtime + .apply_sandbox_update(ready_driver_sandbox( + sandbox.object_id(), + sandbox.object_name(), + )) + .await + .unwrap(); + let ready = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + assert_eq!(ready.phase(), SandboxPhase::Ready as i32); + assert_eq!(driver.start_calls(), 2, "ready start is idempotent"); + } + + #[tokio::test] + async fn completed_sandbox_can_start_a_fresh_main_instance() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = + sandbox_record("sb-completed", "sandbox-completed", SandboxPhase::Completed); + sandbox.status = Some(SandboxStatus { + phase: SandboxPhase::Completed as i32, + main_process_instance_id: "instance-old".to_string(), + exit_code: Some(0), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = ssh_session_record("completed-session", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let starting = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + + assert_eq!(starting.phase(), SandboxPhase::Starting as i32); + let status = starting.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-old"); + assert_eq!(status.exit_code, None); + assert_eq!(driver.start_calls(), 1); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none(), + "restart revokes SSH sessions from the completed instance" + ); + } + + #[tokio::test] + async fn failed_main_process_error_can_start_a_fresh_instance() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-failed", "sandbox-failed", SandboxPhase::Ready); + apply_main_process_exit(&mut sandbox, "instance-old", 130); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = ssh_session_record("failed-session", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let starting = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + + assert_eq!(starting.phase(), SandboxPhase::Starting as i32); + let status = starting.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-old"); + assert_eq!(status.exit_code, None); + assert_eq!(driver.start_calls(), 1); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none(), + "restart revokes SSH sessions from the failed instance" + ); + } + + #[tokio::test] + async fn infrastructure_error_cannot_be_started_as_a_command_result() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-error", "sandbox-error", SandboxPhase::Error); + runtime.store.put_message(&sandbox).await.unwrap(); + + let error = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert_eq!(driver.start_calls(), 0); + } + + #[tokio::test] + async fn retained_stopping_transition_retries_driver_operation() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record( + "sb-retained-stop", + "sandbox-retained-stop", + SandboxPhase::Stopping, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + let stopped = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + + assert_eq!(stopped.phase(), SandboxPhase::Stopped as i32); + assert_eq!(driver.stop_calls(), 1); + } + + #[tokio::test] + async fn retained_starting_transition_retries_driver_operation() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record( + "sb-retained-start", + "sandbox-retained-start", + SandboxPhase::Starting, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + let starting = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap(); + + assert_eq!(starting.phase(), SandboxPhase::Starting as i32); + assert_eq!(driver.start_calls(), 1); + } + + #[tokio::test] + async fn repeated_stop_completes_session_cleanup() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-stopped", "sandbox-stopped", SandboxPhase::Stopped); + let session = ssh_session_record("stale-session", sandbox.object_id()); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let stopped = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .unwrap(); - assert_eq!(derive_phase(Some(&status)), SandboxPhase::Deleting); + assert_eq!(stopped.phase(), SandboxPhase::Stopped as i32); + assert_eq!(driver.stop_calls(), 0); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none() + ); } - #[test] - fn derive_phase_returns_provisioning_for_transient_conditions() { - let transient_conditions = [ - ("ReconcilerError", "Error seen: failed to update pod"), - ( - "DependenciesNotReady", - "Pod exists with phase: Pending; Service Exists", - ), - ("Starting", "VM is starting"), - ( - "ContainerCreated", - "Container exists but has not started yet", - ), - ]; + #[tokio::test] + async fn startup_recovery_completes_stopped_session_cleanup() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let stopped = sandbox_record("sb-stopped", "sandbox-stopped", SandboxPhase::Stopped); + let stopping = sandbox_record("sb-stopping", "sandbox-stopping", SandboxPhase::Stopping); + let stopped_session = ssh_session_record("stopped-session", stopped.object_id()); + let stopping_session = ssh_session_record("stopping-session", stopping.object_id()); + for sandbox in [&stopped, &stopping] { + runtime.store.put_message(sandbox).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + } + for session in [&stopped_session, &stopping_session] { + runtime.store.put_message(session).await.unwrap(); + } - for (reason, message) in transient_conditions { - let status = make_driver_status(make_driver_condition(reason, message)); - assert_eq!( - derive_phase(Some(&status)), - SandboxPhase::Provisioning, - "Expected Provisioning for transient reason={reason}" + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!(driver.stop_calls(), 1); + for (sandbox, session) in [(&stopped, &stopped_session), (&stopping, &stopping_session)] { + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopped as i32); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none() ); } } - #[test] - fn derive_phase_returns_error_for_terminal_ready_false() { - let status = make_driver_status(make_driver_condition( - "ImagePullBackOff", - "Failed to pull image", - )); + #[tokio::test] + async fn request_cancellation_does_not_cancel_stop_worker() { + let driver = ControlledDriver::new(); + driver.block_stop(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-stop", "sandbox-stop", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); - assert_eq!(derive_phase(Some(&status)), SandboxPhase::Error); - } + let request_runtime = runtime.clone(); + let request = tokio::spawn(async move { + request_runtime + .stop_sandbox("default", "sandbox-stop") + .await + }); + tokio::time::timeout(Duration::from_secs(1), driver.stop_started.notified()) + .await + .expect("stop did not reach the driver"); - #[test] - fn derive_phase_returns_ready_for_ready_true() { - let status = DriverSandboxStatus { - conditions: vec![DriverCondition { - r#type: "Ready".to_string(), - status: "True".to_string(), - reason: "DependenciesReady".to_string(), - message: "Pod is Ready; Service Exists".to_string(), - last_transition_time: String::new(), - }], - ..make_driver_status(make_driver_condition("", "")) - }; + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + driver.release_stop(); + tokio::time::timeout(Duration::from_secs(1), driver.stop_finished.notified()) + .await + .expect("detached stop worker did not finish the driver call"); - assert_eq!(derive_phase(Some(&status)), SandboxPhase::Ready); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + if stored.phase() == SandboxPhase::Stopped as i32 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("detached stop worker did not persist Stopped"); + assert_eq!(driver.stop_calls(), 1); } - #[test] - fn build_platform_config_omits_typed_cpu_and_memory_resources() { - let template = SandboxTemplate { - resources: Some(prost_types::Struct { - fields: [ - ( - "limits", - struct_value([("cpu", string_value("2")), ("memory", string_value("1Gi"))]), - ), - ( - "requests", - struct_value([ - ("cpu", string_value("500m")), - ("memory", string_value("512Mi")), - ]), - ), - ] - .into_iter() - .map(|(key, value)| (key.to_string(), value)) - .collect(), - }), - ..Default::default() - }; - - assert!(build_platform_config(&template).is_none()); - } + #[tokio::test] + async fn explicit_stop_completes_after_term_runtime_restart() { + let driver = ControlledDriver::new(); + driver.block_stop(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-term-stop", "sandbox-term-stop", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); - #[test] - fn build_platform_config_preserves_non_typed_resource_fields() { - let template = SandboxTemplate { - resources: Some(prost_types::Struct { - fields: [ - ( - "limits", - struct_value([ - ("cpu", string_value("2")), - ("memory", string_value("1Gi")), - ("nvidia.com/gpu", string_value("1")), - ]), - ), - ( - "requests", - struct_value([ - ("cpu", string_value("500m")), - ("memory", string_value("512Mi")), - ("hugepages-2Mi", string_value("4Mi")), - ]), - ), - ("opaque_cpu", number_value(2.0)), - ] - .into_iter() - .map(|(key, value)| (key.to_string(), value)) - .collect(), - }), - ..Default::default() - }; + let stop_runtime = runtime.clone(); + let stop = tokio::spawn(async move { + stop_runtime + .stop_sandbox("default", "sandbox-term-stop") + .await + }); + tokio::time::timeout(Duration::from_secs(1), driver.stop_started.notified()) + .await + .expect("stop did not reach the driver"); - let platform_config = build_platform_config(&template).unwrap(); - let resources_raw = platform_config - .fields - .get("resources_raw") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StructValue(inner) => Some(inner), - _ => None, - }) - .unwrap(); + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); - let limits = resources_raw - .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 stopping = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() .unwrap(); - assert!(!limits.fields.contains_key("cpu")); - assert!(!limits.fields.contains_key("memory")); + assert_eq!(stopping.phase(), SandboxPhase::Stopping as i32); assert_eq!( - limits - .fields - .get("nvidia.com/gpu") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StringValue(value) => Some(value.as_str()), - _ => None, - }), - Some("1") + stopping.status.unwrap().conditions[0].reason, + "ContainerRuntimeRestart" ); - let requests = resources_raw - .fields - .get("requests") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StructValue(inner) => Some(inner), - _ => None, - }) + driver.release_stop(); + let stopped = tokio::time::timeout(Duration::from_secs(1), stop) + .await + .expect("stop did not finish") + .unwrap() .unwrap(); - assert!(!requests.fields.contains_key("cpu")); - assert!(!requests.fields.contains_key("memory")); - assert_eq!( - requests - .fields - .get("hugepages-2Mi") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StringValue(value) => Some(value.as_str()), - _ => None, - }), - Some("4Mi") - ); - - assert!(resources_raw.fields.contains_key("opaque_cpu")); + assert_eq!(stopped.phase(), SandboxPhase::Stopped as i32); + assert_eq!(stopped.status.unwrap().conditions[0].reason, "Stopped"); } - #[test] - fn rewrite_user_facing_conditions_rewrites_gpu_unschedulable_message() { - let mut status = Some(SandboxStatus { - sandbox_name: "test".to_string(), - agent_pod: "test-pod".to_string(), - conditions: vec![SandboxCondition { - r#type: "Ready".to_string(), - status: "False".to_string(), - reason: "Unschedulable".to_string(), - message: "0/1 nodes are available: 1 Insufficient nvidia.com/gpu.".to_string(), - last_transition_time: String::new(), - }], - ..Default::default() + #[tokio::test] + async fn failed_stop_does_not_report_term_runtime_restart_as_stopped() { + let driver = ControlledDriver::new(); + driver.block_stop(); + driver.set_stop_outcome(ControlledLifecycleOutcome::Error("stop timed out")); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-term-fail", "sandbox-term-fail", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + let stop_runtime = runtime.clone(); + let stop = tokio::spawn(async move { + stop_runtime + .stop_sandbox("default", "sandbox-term-fail") + .await }); + tokio::time::timeout(Duration::from_secs(1), driver.stop_started.notified()) + .await + .expect("stop did not reach the driver"); - rewrite_user_facing_conditions( - &mut status, - Some(&SandboxSpec { - resource_requirements: Some(openshell_core::proto::ResourceRequirements { - gpu: Some(openshell_core::proto::GpuResourceRequirements { count: None }), - }), - ..Default::default() - }), - ); + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new( + runtime_restart.clone(), + ))); + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); + + driver.release_stop(); + let err = tokio::time::timeout(Duration::from_secs(1), stop) + .await + .expect("stop did not finish") + .unwrap() + .unwrap_err(); + assert!(err.message().contains("stop timed out")); - let message = &status.unwrap().conditions[0].message; + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopping as i32); assert_eq!( - message, - "GPU sandbox could not be scheduled on the active gateway. Another GPU sandbox may already be using the available GPU, or the gateway may not currently be able to satisfy GPU placement. Please refer to documentation and use `openshell doctor` commands to inspect GPU support and gateway configuration." + stored.status.unwrap().conditions[0].reason, + "ContainerRuntimeRestart" ); } - #[test] - fn rewrite_user_facing_conditions_leaves_non_gpu_unschedulable_message_unchanged() { - let original = "0/1 nodes are available: 1 Insufficient cpu."; - let mut status = Some(SandboxStatus { - sandbox_name: "test".to_string(), - agent_pod: "test-pod".to_string(), - conditions: vec![SandboxCondition { - r#type: "Ready".to_string(), - status: "False".to_string(), - reason: "Unschedulable".to_string(), - message: original.to_string(), - last_transition_time: String::new(), - }], - ..Default::default() + #[tokio::test] + async fn request_cancellation_does_not_cancel_start_worker() { + let driver = ControlledDriver::new(); + driver.block_start(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-start", "sandbox-start", SandboxPhase::Stopped); + runtime.store.put_message(&sandbox).await.unwrap(); + + let request_runtime = runtime.clone(); + let request = tokio::spawn(async move { + request_runtime + .start_sandbox("default", "sandbox-start") + .await }); + tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified()) + .await + .expect("start did not reach the driver"); - rewrite_user_facing_conditions(&mut status, Some(&SandboxSpec::default())); + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + driver.release_start(); + tokio::time::timeout(Duration::from_secs(1), driver.start_finished.notified()) + .await + .expect("detached start worker did not finish the driver call"); - assert_eq!(status.unwrap().conditions[0].message, original); + driver.release_start(); + let starting = tokio::time::timeout( + Duration::from_secs(1), + runtime.start_sandbox("default", sandbox.object_name()), + ) + .await + .expect("detached start worker did not release the lifecycle gate") + .unwrap(); + assert_eq!(starting.phase(), SandboxPhase::Starting as i32); + assert_eq!(driver.start_calls(), 2); } - #[test] - fn compute_error_from_status_preserves_driver_status_codes() { - assert!(matches!( - compute_error_from_status(Status::already_exists("sandbox already exists")), - ComputeError::AlreadyExists - )); + #[tokio::test] + async fn failed_stop_reconciles_backend_that_already_stopped() { + let driver = ControlledDriver::new(); + driver.set_stop_outcome(ControlledLifecycleOutcome::Error("response lost")); + let sandbox = sandbox_record("sb-stop", "sandbox-stop", SandboxPhase::Ready); + let mut stopped = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + stopped.status = Some(make_driver_status(make_driver_condition( + "ContainerExited", + "container stopped before the response was lost", + ))); + driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(stopped))); + let runtime = test_runtime(driver).await; + runtime.store.put_message(&sandbox).await.unwrap(); + let session = ssh_session_record("lost-response-session", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); - assert!(matches!( - compute_error_from_status(Status::failed_precondition("sandbox agent pod IP is not available")), - ComputeError::Precondition(message) if message == "sandbox agent pod IP is not available" - )); + let err = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .unwrap_err(); + assert!(err.message().contains("response lost")); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopped as i32); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none(), + "reconciled stop revokes ephemeral SSH sessions" + ); } - /// Driver calls are a remote boundary even in-process: they reach the - /// Docker daemon, the Kubernetes API, or a Podman socket. #[tokio::test] - async fn driver_calls_export_spans_with_parents() { - use tracing::Instrument as _; + async fn failed_stop_retains_progress_and_watcher_completes_cleanup() { + let driver = ControlledDriver::new(); + driver.set_stop_outcome(ControlledLifecycleOutcome::Error("stop timed out")); + let sandbox = sandbox_record( + "sb-stop-progressing", + "sandbox-stop-progressing", + SandboxPhase::Ready, + ); + let mut progressing = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + progressing.status = Some(DriverSandboxStatus { + sandbox_name: sandbox.object_name().to_string(), + instance_id: format!("{}-pod", sandbox.object_name()), + conditions: vec![ + DriverCondition { + r#type: "Suspended".to_string(), + status: "False".to_string(), + reason: "PodTerminating".to_string(), + message: "Pod is terminating. Sandbox is stopping".to_string(), + last_transition_time: String::new(), + }, + make_driver_condition("SandboxStopped", "Sandbox is stopping"), + ], + ..Default::default() + }); + driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(progressing.clone()))); + let runtime = test_runtime(driver).await; + runtime.store.put_message(&sandbox).await.unwrap(); + let session = ssh_session_record("progressing-session", sandbox.object_id()); + runtime.store.put_message(&session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); - use crate::otel_tracing::test_exporter; + let err = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .unwrap_err(); + assert!(err.message().contains("stop timed out")); - let runtime = test_runtime(Arc::new(TestDriver::default())).await; - let sandbox = sandbox_record("sb-trace", "sandbox-trace", SandboxPhase::Provisioning); + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopping as i32); + assert!(runtime.supervisor_sessions.has_session(sandbox.object_id())); - let traced = test_exporter::install_traced(); - async { - runtime - .create_sandbox(sandbox, None) - .await - .expect("create succeeds"); - } - .instrument(tracing::info_span!("request")) - .await; + progressing.status.as_mut().unwrap().conditions[0].status = "True".to_string(); + progressing.status.as_mut().unwrap().conditions[0].reason = "PodTerminated".to_string(); + runtime.apply_sandbox_update(progressing).await.unwrap(); - let driver_span = traced.span_with("driver.create_sandbox", "sandbox.id", "sb-trace"); - test_exporter::assert_has_parent(&driver_span); - assert_eq!( - test_exporter::attribute(&driver_span, "driver.name").as_deref(), - Some("test-driver"), - "the span names which driver was called" - ); - assert_eq!( - test_exporter::attribute(&driver_span, "sandbox.id").as_deref(), - Some("sb-trace"), - ); - assert_eq!( - driver_span.span_kind, - opentelemetry::trace::SpanKind::Client, - "the gateway is the caller at this boundary" - ); + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopped as i32); + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); assert!( - !matches!( - driver_span.status, - opentelemetry::trace::Status::Error { .. } - ), - "a successful driver call is not marked an error, got {:?}", - driver_span.status + runtime + .store + .get_message::(session.object_id()) + .await + .unwrap() + .is_none(), + "watcher-driven stop revokes ephemeral SSH sessions" ); } - /// A failing driver call must be visible as a failure in the trace, not - /// just as a span that happens to be followed by nothing. #[tokio::test] - async fn failed_driver_calls_are_marked_on_the_span() { - use tracing::Instrument as _; + async fn failed_start_reconciles_backend_that_already_started() { + let driver = ControlledDriver::new(); + driver.set_start_outcome(ControlledLifecycleOutcome::Error("response lost")); + let sandbox = sandbox_record("sb-start", "sandbox-start", SandboxPhase::Stopped); + driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new( + ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()), + ))); + let runtime = test_runtime(driver).await; + runtime.store.put_message(&sandbox).await.unwrap(); - use crate::otel_tracing::test_exporter; + let err = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap_err(); + assert!(err.message().contains("response lost")); - /// A driver that behaves normally except that creates fail, so the - /// test exercises only the failure attribute. - #[derive(Debug, Default)] - struct FailingDriver(TestDriver); + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Starting as i32); + } - #[tonic::async_trait] - impl ComputeDriver for FailingDriver { - type WatchSandboxesStream = DriverWatchStream; + #[tokio::test] + async fn stale_container_exit_queued_before_start_cannot_regress_restart() { + for reason in [ + "ContainerExited", + "ContainerStopped", + "ContainerRuntimeRestart", + ] { + let driver = ControlledDriver::new(); + driver.block_start(); + let sandbox = + sandbox_record("sb-start-race", "sandbox-start-race", SandboxPhase::Stopped); + driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new( + ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()), + ))); + let runtime = test_runtime(driver.clone()).await; + runtime.store.put_message(&sandbox).await.unwrap(); - async fn create_sandbox( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unavailable("driver is down")) - } + let start_runtime = runtime.clone(); + let sandbox_name = sandbox.object_name().to_string(); + let start = + tokio::spawn( + async move { start_runtime.start_sandbox("default", &sandbox_name).await }, + ); + tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified()) + .await + .expect("start did not reach the driver"); - async fn get_capabilities( - &self, - request: Request, - ) -> Result, Status> { - self.0.get_capabilities(request).await - } + let mut stale_exit = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + stale_exit.status = Some(make_driver_status(make_driver_condition( + reason, + "container stopped before restart", + ))); + let update_runtime = runtime.clone(); + let mut update = + tokio::spawn(async move { update_runtime.apply_sandbox_update(stale_exit).await }); - async fn get_gateway_listener_requirements( - &self, - request: Request, - ) -> Result, Status> - { - self.0.get_gateway_listener_requirements(request).await - } + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut update) + .await + .is_err(), + "{reason} watch update must wait for the active start operation" + ); - async fn validate_sandbox_create( - &self, - request: Request, - ) -> Result, Status> { - self.0.validate_sandbox_create(request).await - } + driver.release_start(); + start.await.unwrap().unwrap(); + update.await.unwrap().unwrap(); - async fn get_sandbox( - &self, - request: Request, - ) -> Result, Status> { - self.0.get_sandbox(request).await - } + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.phase(), + SandboxPhase::Starting as i32, + "{reason} must not regress the restarted sandbox" + ); + } + } - async fn list_sandboxes( - &self, - request: Request, - ) -> Result< - tonic::Response, - Status, - > { - self.0.list_sandboxes(request).await - } + #[tokio::test] + async fn live_container_exit_during_start_still_transitions_to_error() { + for reason in [ + "ContainerExited", + "ContainerStopped", + "ContainerRuntimeRestart", + ] { + let driver = ControlledDriver::new(); + let sandbox = sandbox_record( + "sb-start-exited", + "sandbox-start-exited", + SandboxPhase::Starting, + ); + let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + exited.status = Some(make_driver_status(make_driver_condition( + reason, + "restarted container exited", + ))); + driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(exited.clone()))); + let runtime = test_runtime(driver).await; + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.apply_sandbox_update(exited).await.unwrap(); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.phase(), + SandboxPhase::Error as i32, + "current {reason} snapshot must remain terminal" + ); + } + } + + #[tokio::test] + async fn lifecycle_operations_reject_invalid_source_phases() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record( + "sb-provisioning", + "sandbox-provisioning", + SandboxPhase::Provisioning, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + let stop = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .unwrap_err(); + assert_eq!(stop.code(), Code::FailedPrecondition); + + let start = runtime + .start_sandbox("default", sandbox.object_name()) + .await + .unwrap_err(); + assert_eq!(start.code(), Code::FailedPrecondition); + } + + #[tokio::test] + async fn stale_ready_snapshot_cannot_wake_stopped_sandbox() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-sleeping", "sandbox-sleeping", SandboxPhase::Stopped); + runtime.store.put_message(&sandbox).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); - async fn stop_sandbox( - &self, - request: Request, - ) -> Result, Status> { - self.0.stop_sandbox(request).await - } + runtime + .apply_sandbox_update(ready_driver_sandbox( + sandbox.object_id(), + sandbox.object_name(), + )) + .await + .unwrap(); - async fn delete_sandbox( - &self, - request: Request, - ) -> Result, Status> { - self.0.delete_sandbox(request).await - } + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.phase(), SandboxPhase::Stopped as i32); + } - async fn watch_sandboxes( - &self, - request: Request, - ) -> Result, Status> { - self.0.watch_sandboxes(request).await - } - } + #[tokio::test] + async fn repeated_stopped_snapshot_does_not_repeat_session_cleanup() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-stopping", "sandbox-stopping", SandboxPhase::Stopping); + runtime.store.put_message(&sandbox).await.unwrap(); + let transition_session = ssh_session_record("transition-session", sandbox.object_id()); + runtime + .store + .put_message(&transition_session) + .await + .unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); - let runtime = test_runtime(Arc::new(FailingDriver::default())).await; - let sandbox = sandbox_record("sb-fail", "sandbox-fail", SandboxPhase::Provisioning); + let mut stopped = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + stopped.status = Some(make_driver_status(make_driver_condition( + "ContainerExited", + "container stopped by request", + ))); + runtime.apply_sandbox_update(stopped.clone()).await.unwrap(); - let traced = test_exporter::install_traced(); - async { + assert!(!runtime.supervisor_sessions.has_session(sandbox.object_id())); + assert!( runtime - .create_sandbox(sandbox, None) + .store + .get_message::(transition_session.object_id()) .await - .expect_err("driver refuses the create"); - } - .instrument(tracing::info_span!("request")) - .await; + .unwrap() + .is_none(), + "the transition to stopped cleans up ephemeral SSH sessions" + ); - let driver_span = traced.span_with("driver.create_sandbox", "sandbox.id", "sb-fail"); + let later_session = ssh_session_record("later-session", sandbox.object_id()); + runtime.store.put_message(&later_session).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + runtime.apply_sandbox_update(stopped).await.unwrap(); + assert!(runtime.supervisor_sessions.has_session(sandbox.object_id())); assert!( - matches!( - driver_span.status, - opentelemetry::trace::Status::Error { .. } - ), - "the span carries error status so trace UIs flag it, got {:?}", - driver_span.status + runtime + .store + .get_message::(later_session.object_id()) + .await + .unwrap() + .is_some(), + "later stopped snapshots do not repeat session cleanup" ); + } + + #[tokio::test] + async fn stopped_container_snapshot_confirms_stopping_sandbox() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-stopping", "sandbox-stopping", SandboxPhase::Stopping); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut stopped = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + stopped.status = Some(make_driver_status(make_driver_condition( + "ContainerExited", + "container stopped by request", + ))); + + runtime.apply_sandbox_update(stopped).await.unwrap(); + + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.phase(), SandboxPhase::Stopped as i32); + } + + #[tokio::test] + async fn resumed_v1beta1_snapshot_with_stale_suspended_reaches_ready() { + // Reproduces issue #2932: on Agent Sandbox v1beta1 a resumed CR reports + // Ready=True (DependenciesReady) alongside a stale Suspended=True + // (PodTerminated). Starting from the Starting phase that `start` sets, the + // reconciled sandbox must advance to Ready rather than being pinned at + // Starting by the stale Suspended condition. + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-resumed", "sandbox-resumed", SandboxPhase::Starting); + runtime.store.put_message(&sandbox).await.unwrap(); + register_test_supervisor_session(&runtime, sandbox.object_id()); + + let mut resumed = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + resumed.status = Some(DriverSandboxStatus { + sandbox_name: sandbox.object_name().to_string(), + instance_id: format!("{}-pod", sandbox.object_name()), + conditions: vec![ + DriverCondition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: "DependenciesReady".to_string(), + message: "Sandbox is ready".to_string(), + last_transition_time: String::new(), + }, + DriverCondition { + r#type: "Suspended".to_string(), + status: "True".to_string(), + reason: "PodTerminated".to_string(), + message: "Pod terminated".to_string(), + last_transition_time: String::new(), + }, + ], + ..Default::default() + }); + + runtime.apply_sandbox_update(resumed).await.unwrap(); + + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); assert_eq!( - test_exporter::attribute(&driver_span, "grpc.code").as_deref(), - Some("14"), - "the gRPC code names the cause without reading the message" + current.phase(), + SandboxPhase::Ready as i32, + "a resumed, Ready sandbox must not stay Starting because of a stale Suspended condition" ); } + #[tokio::test] + async fn stopped_container_snapshot_cannot_error_stopped_sandbox() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-stopped", "sandbox-stopped", SandboxPhase::Stopped); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut stopped = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + stopped.status = Some(make_driver_status(make_driver_condition( + "ContainerExited", + "container is stopped", + ))); + + runtime.apply_sandbox_update(stopped).await.unwrap(); + + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.phase(), SandboxPhase::Stopped as i32); + } + #[tokio::test] async fn begin_sandbox_delete_retries_after_stale_snapshot_conflict() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; @@ -4597,7 +7758,7 @@ mod tests { let mut watch_rx = runtime.sandbox_watch_bus.subscribe("sb-1"); runtime - .supervisor_session_disconnected("sb-1") + .supervisor_session_disconnected("sb-1", false) .await .unwrap(); @@ -4658,7 +7819,7 @@ mod tests { ..Default::default() }); - runtime.create_sandbox(sandbox, None).await.unwrap(); + runtime.create_sandbox(sandbox, None, false).await.unwrap(); runtime .apply_sandbox_update(ready_driver_sandbox("sb-1", "sandbox-a")) .await @@ -4814,7 +7975,7 @@ mod tests { let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); runtime.store.put_message(&sandbox).await.unwrap(); - let delete_gate = runtime.delete_gates.gate_for(sandbox.object_id()); + let delete_gate = runtime.lifecycle_gates.gate_for(sandbox.object_id()); let first_runtime = runtime.clone(); let first = tokio::spawn(async move { first_runtime.delete_sandbox("default", "sandbox-a").await }); @@ -4878,7 +8039,7 @@ mod tests { let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); runtime.store.put_message(&sandbox).await.unwrap(); - let delete_gate = runtime.delete_gates.gate_for(sandbox.object_id()); + let delete_gate = runtime.lifecycle_gates.gate_for(sandbox.object_id()); let first_runtime = runtime.clone(); let first = tokio::spawn(async move { first_runtime.delete_sandbox("default", "sandbox-a").await }); @@ -4937,7 +8098,7 @@ mod tests { // Hold the original ID's gate so the request resolves the name and // then waits before it can revalidate the durable row. - let delete_gate = runtime.delete_gates.gate_for(original.object_id()); + let delete_gate = runtime.lifecycle_gates.gate_for(original.object_id()); let delete_guard = delete_gate.lock().await; let delete_runtime = runtime.clone(); let delete = @@ -5014,6 +8175,43 @@ mod tests { assert_eq!(driver.delete_calls(), 1); } + #[tokio::test] + async fn sandbox_ssh_session_cleanup_batches_across_list_pages() { + let runtime = test_runtime(ControlledDriver::new()).await; + for idx in 0..(LIFECYCLE_SWEEP_PAGE_SIZE + 5) { + let session = ssh_session_record(&format!("owned-{idx:04}"), "sb-owned"); + runtime.store.put_message(&session).await.unwrap(); + } + for idx in 0..7 { + let session = ssh_session_record(&format!("unrelated-{idx:04}"), "sb-unrelated"); + runtime.store.put_message(&session).await.unwrap(); + } + + runtime + .cleanup_sandbox_ssh_sessions("sb-owned", "default") + .await + .unwrap(); + + assert_eq!( + runtime + .store + .count_in_workspace(SshSession::object_type(), "default") + .await + .unwrap(), + 7 + ); + for idx in 0..7 { + assert!( + runtime + .store + .get_message::(&format!("unrelated-{idx:04}")) + .await + .unwrap() + .is_some() + ); + } + } + #[tokio::test] async fn already_absent_driver_resource_is_removed_synchronously() { let driver = ControlledDriver::new(); @@ -5117,72 +8315,214 @@ mod tests { .await .expect("delete did not reach the driver"); runtime - .store - .update_message_cas::("sb-1", 0, |sandbox| { - sandbox.set_phase(SandboxPhase::Ready as i32); - }) + .store + .update_message_cas::("sb-1", 0, |sandbox| { + sandbox.set_phase(SandboxPhase::Ready as i32); + }) + .await + .unwrap(); + + driver.release_delete(); + let error = delete.await.unwrap().unwrap_err(); + assert_eq!(error.code(), Code::Internal); + assert_eq!( + SandboxPhase::try_from( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap() + .phase() + ) + .unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn accepted_driver_delete_leaves_removal_to_watcher() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = seed_sandbox_owned_records(&runtime, &sandbox).await; + let mut watch_rx = runtime.sandbox_watch_bus.subscribe("sb-1"); + + assert!( + runtime + .delete_sandbox("default", "sandbox-a") + .await + .unwrap() + .deleted + ); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Deleting + ); + assert_sandbox_owned_records(&runtime, &sandbox, &session, true).await; + assert!(watch_rx.try_recv().is_ok()); + assert!(matches!( + watch_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + + runtime.apply_deleted("sb-1").await.unwrap(); + assert_sandbox_owned_records(&runtime, &sandbox, &session, false).await; + assert!(watch_rx.try_recv().is_ok()); + assert!(matches!( + watch_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Closed) + )); + } + + #[tokio::test] + async fn apply_deleted_releases_driver_resources_for_out_of_band_removal() { + // Regression test for #2352: a container removed without going + // through OpenShell's own DeleteSandbox (e.g. `podman rm -f`) must + // still release driver-owned secrets/volumes, not just the store + // record. This never calls `runtime.delete_sandbox`, matching the + // out-of-band removal the issue reports. + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let session = seed_sandbox_owned_records(&runtime, &sandbox).await; + + assert_eq!(driver.delete_calls(), 0); + + runtime.apply_deleted("sb-1").await.unwrap(); + + // The driver call is backgrounded (see `spawn_driver_sandbox_cleanup`) + // so it can't block the watch loop; wait for it to actually land + // before asserting on it. + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!( + driver.delete_requests(), + vec![("sb-1".to_string(), "sandbox-a".to_string())] + ); + assert_sandbox_owned_records(&runtime, &sandbox, &session, false).await; + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn apply_deleted_removes_store_record_even_when_driver_cleanup_fails() { + // The driver has already told us (via the watch/prune path that led + // here) that this sandbox is gone. A failure releasing its + // secrets/volumes must be logged, not block store cleanup — retrying + // forever on a driver hiccup would leave the store permanently out + // of sync with reality. + let driver = ControlledDriver::new(); + driver.set_delete_outcome(ControlledDeleteOutcome::Error("podman unreachable")); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.apply_deleted("sb-1").await.unwrap(); + + // Store cleanup happens synchronously in `apply_deleted_locked`, so + // this is already true even though the driver call is backgrounded. + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none() + ); + + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!(driver.delete_calls(), 1); + } + + #[tokio::test] + async fn prune_missing_sandbox_releases_driver_resources() { + // Regression test for #2352's second reproduction: a sandbox whose + // container never survived to exist at all (e.g. the gateway was + // killed mid-create) is discovered missing by the periodic sweep, + // not the watcher. That path must also release driver resources. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) .await .unwrap(); - driver.release_delete(); - let error = delete.await.unwrap().unwrap_err(); - assert_eq!(error.code(), Code::Internal); + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none() + ); + + // The driver call is backgrounded (see `spawn_driver_sandbox_cleanup`) + // so the prune sweep never awaits it while holding the gateway-wide + // sync_lock; wait for it to actually land before asserting on it. + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); assert_eq!( - SandboxPhase::try_from( - runtime - .store - .get_message::("sb-1") - .await - .unwrap() - .unwrap() - .phase() - ) - .unwrap(), - SandboxPhase::Ready + driver.delete_requests(), + vec![("sb-1".to_string(), "sandbox-a".to_string())] ); } #[tokio::test] - async fn accepted_driver_delete_leaves_removal_to_watcher() { + async fn prune_sweep_does_not_block_on_a_stuck_driver_delete_call() { + // Regression test: the prune sweep's driver cleanup must not be + // awaited while holding `sync_lock` (the gateway-wide state guard). + // Block the driver's delete call indefinitely and confirm the sweep + // itself still completes promptly and removes the store record. let driver = ControlledDriver::new(); - let runtime = test_runtime(driver).await; - let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + driver.block_delete(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); - let session = seed_sandbox_owned_records(&runtime, &sandbox).await; - let mut watch_rx = runtime.sandbox_watch_bus.subscribe("sb-1"); + + tokio::time::timeout( + Duration::from_secs(1), + runtime.reconcile_store_with_backend(Duration::ZERO), + ) + .await + .expect("prune sweep blocked on the stuck driver delete call") + .unwrap(); assert!( runtime - .delete_sandbox("default", "sandbox-a") + .store + .get_message::("sb-1") .await .unwrap() - .deleted + .is_none() ); - - let stored = runtime - .store - .get_message::("sb-1") + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) .await - .unwrap() - .unwrap(); - assert_eq!( - SandboxPhase::try_from(stored.phase()).unwrap(), - SandboxPhase::Deleting - ); - assert_sandbox_owned_records(&runtime, &sandbox, &session, true).await; - assert!(watch_rx.try_recv().is_ok()); - assert!(matches!( - watch_rx.try_recv(), - Err(tokio::sync::broadcast::error::TryRecvError::Empty) - )); - - runtime.apply_deleted("sb-1").await.unwrap(); - assert_sandbox_owned_records(&runtime, &sandbox, &session, false).await; - assert!(watch_rx.try_recv().is_ok()); - assert!(matches!( - watch_rx.try_recv(), - Err(tokio::sync::broadcast::error::TryRecvError::Closed) - )); + .expect("background driver cleanup did not run"); } #[tokio::test] @@ -5663,7 +9003,12 @@ mod tests { #[tokio::test] async fn non_deleting_container_exit_still_transitions_to_error() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; - let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + sandbox.status = Some(SandboxStatus { + sandbox_name: "sandbox-a".to_string(), + main_process_instance_id: "instance-1".to_string(), + ..Default::default() + }); runtime.store.put_message(&sandbox).await.unwrap(); let mut exited = ready_driver_sandbox("sb-1", "sandbox-a"); exited.status = Some(make_driver_status(make_driver_condition( @@ -5683,6 +9028,108 @@ mod tests { SandboxPhase::try_from(stored.phase()).unwrap(), SandboxPhase::Error ); + let status = stored.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert_eq!(status.exit_code, None); + } + + #[tokio::test] + async fn unexpected_term_runtime_restart_transitions_to_error() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-term-exit", "sandbox-term-exit", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Error as i32); + assert_eq!( + stored.status.unwrap().conditions[0].reason, + "ContainerRuntimeRestart" + ); + } + + #[tokio::test] + async fn late_term_runtime_restart_preserves_intentional_stop_status() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record( + "sb-term-stopped", + "sandbox-term-stopped", + SandboxPhase::Stopped, + ); + sandbox.status = Some(SandboxStatus { + sandbox_name: sandbox.object_name().to_string(), + phase: SandboxPhase::Stopped as i32, + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "Stopped".to_string(), + message: "Sandbox compute is stopped".to_string(), + last_transition_time: String::new(), + }], + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); + runtime_restart.status = Some(make_driver_status(make_driver_condition( + "ContainerRuntimeRestart", + "container exited with status 143", + ))); + + runtime.apply_sandbox_update(runtime_restart).await.unwrap(); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopped as i32); + let ready = &stored.status.unwrap().conditions[0]; + assert_eq!(ready.reason, "Stopped"); + assert_eq!(ready.message, "Sandbox compute is stopped"); + } + + #[tokio::test] + async fn late_driver_exit_preserves_completed_main_result() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Completed); + sandbox.status = Some(SandboxStatus { + sandbox_name: "sandbox-a".to_string(), + phase: SandboxPhase::Completed as i32, + main_process_instance_id: "instance-1".to_string(), + exit_code: Some(0), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + let mut exited = ready_driver_sandbox("sb-1", "sandbox-a"); + exited.status = Some(make_driver_status(make_driver_condition( + "ContainerExited", + "container exited after the canonical process completed", + ))); + + runtime.apply_sandbox_update(exited).await.unwrap(); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Completed as i32); + let status = stored.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert_eq!(status.exit_code, Some(0)); } #[tokio::test] @@ -5796,7 +9243,10 @@ mod tests { let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); - runtime.supervisor_session_connected("sb-1").await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "test-generation") + .await + .unwrap(); let stored = runtime .store @@ -5829,7 +9279,7 @@ mod tests { runtime.store.put_message(&sandbox).await.unwrap(); runtime - .supervisor_session_disconnected("sb-1") + .supervisor_session_disconnected("sb-1", false) .await .unwrap(); @@ -5974,7 +9424,7 @@ mod tests { #[tokio::test] async fn backend_not_ready_with_supervisor_becomes_ready() { - // VM path: supervisor connects before backend reports Ready. + // The supervisor may connect before the backend reports Ready. let runtime = test_runtime(Arc::new(TestDriver::default())).await; let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); @@ -6039,6 +9489,10 @@ mod tests { SandboxPhase::try_from(stored.phase()).unwrap(), SandboxPhase::Error ); + assert!( + stored.status.unwrap().main_process_instance_id.is_empty(), + "a provisioning failure must not fabricate a main-process exit" + ); } #[tokio::test] @@ -6051,7 +9505,10 @@ mod tests { // Promote to Ready via supervisor session connect. register_test_supervisor_session(&runtime, "sb-1"); - runtime.supervisor_session_connected("sb-1").await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "test-generation") + .await + .unwrap(); let stored = runtime .store .get_message::("sb-1") @@ -6066,7 +9523,7 @@ mod tests { // Session drops. runtime.supervisor_sessions.cleanup_sandbox("sb-1"); runtime - .supervisor_session_disconnected("sb-1") + .supervisor_session_disconnected("sb-1", false) .await .unwrap(); let stored = runtime @@ -6142,6 +9599,7 @@ mod tests { #[tokio::test] async fn reconcile_store_with_backend_applies_driver_snapshot() { let runtime = test_runtime(Arc::new(TestDriver { + workspace_rpcs_unimplemented: false, listed_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), name: "sandbox-a".to_string(), @@ -6223,6 +9681,7 @@ mod tests { /// Driver watch events arrive on a background stream, so the store writes /// they trigger land outside the request that caused them. #[tokio::test] + #[ignore = "flaky under concurrent test execution"] async fn driver_watch_events_are_roots_and_store_operations_have_parents() { use crate::otel_tracing::test_exporter; @@ -6268,6 +9727,7 @@ mod tests { /// The reconciler runs on a timer with no inbound request, so without a /// span of its own each store call becomes its own anonymous trace. #[tokio::test] + #[ignore = "flaky under concurrent test execution"] async fn reconcile_sweeps_are_roots_and_operations_have_parents() { use crate::otel_tracing::test_exporter; @@ -6296,7 +9756,7 @@ mod tests { .iter() .find(|root| { spans.iter().any(|span| { - span.name == "driver.list_sandboxes" + span.name == "openshell.compute.v1.ComputeDriver/ListSandboxes" && span.span_context.trace_id() == root.span_context.trace_id() }) && spans.iter().any(|span| { span.name.starts_with("store.") @@ -6309,7 +9769,7 @@ mod tests { let driver_span = spans .iter() .find(|span| { - span.name == "driver.list_sandboxes" + span.name == "openshell.compute.v1.ComputeDriver/ListSandboxes" && span.span_context.trace_id() == root.span_context.trace_id() }) .expect("the sweep records its driver call"); @@ -6327,6 +9787,7 @@ mod tests { #[tokio::test] async fn reconcile_store_with_backend_does_not_recreate_missing_record_from_snapshot() { let runtime = test_runtime(Arc::new(TestDriver { + workspace_rpcs_unimplemented: false, listed_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), name: "sandbox-a".to_string(), @@ -6529,68 +9990,269 @@ mod tests { )); } - #[derive(Default)] - struct RecordingResume { - calls: Mutex>, - results: Mutex>>, - } + #[tokio::test] + async fn shutdown_stops_running_intent_without_changing_persisted_phase() { + let driver = ControlledDriver::new(); + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; + + for (id, name, phase) in [ + ("sb-unspecified", "unspecified", SandboxPhase::Unspecified), + ("sb-prov", "prov", SandboxPhase::Provisioning), + ("sb-ready", "ready", SandboxPhase::Ready), + ("sb-starting", "starting", SandboxPhase::Starting), + ("sb-unknown", "unknown", SandboxPhase::Unknown), + ("sb-stopping", "stopping", SandboxPhase::Stopping), + ("sb-stopped", "stopped", SandboxPhase::Stopped), + ("sb-deleting", "deleting", SandboxPhase::Deleting), + ("sb-error", "error", SandboxPhase::Error), + ] { + runtime + .store + .put_message(&sandbox_record(id, name, phase)) + .await + .unwrap(); + } + + runtime + .stop_persisted_sandboxes_on_shutdown() + .await + .unwrap(); + + let mut called_ids = driver + .stop_requests() + .into_iter() + .map(|(id, _)| id) + .collect::>(); + called_ids.sort(); + assert_eq!( + called_ids, + vec![ + "sb-prov".to_string(), + "sb-ready".to_string(), + "sb-starting".to_string(), + "sb-unknown".to_string(), + "sb-unspecified".to_string(), + ] + ); + + let stored = runtime + .store + .get_message::("sb-ready") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Ready, + "gateway shutdown must retain logical running intent" + ); + } + + #[tokio::test] + async fn shutdown_stop_sweep_continues_after_driver_errors() { + let driver = ControlledDriver::new(); + driver.set_stop_outcome(ControlledLifecycleOutcome::Error("runtime angry")); + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; + for (id, name) in [("sb-1", "one"), ("sb-2", "two")] { + runtime + .store + .put_message(&sandbox_record(id, name, SandboxPhase::Ready)) + .await + .unwrap(); + } + + let err = runtime + .stop_persisted_sandboxes_on_shutdown() + .await + .unwrap_err(); + + assert!(err.contains("failed to stop 2 sandbox(es)")); + assert_eq!(driver.stop_calls(), 2); + } + + #[tokio::test] + async fn shutdown_stop_sweep_bounds_driver_concurrency() { + let driver = ControlledDriver::new(); + driver.block_stop(); + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; + for index in 0..=SHUTDOWN_STOP_CONCURRENCY { + runtime + .store + .put_message(&sandbox_record( + &format!("sb-{index}"), + &format!("sandbox-{index}"), + SandboxPhase::Ready, + )) + .await + .unwrap(); + } + + let sweep_runtime = runtime.clone(); + let sweep = + tokio::spawn(async move { sweep_runtime.stop_persisted_sandboxes_on_shutdown().await }); + tokio::time::timeout(Duration::from_secs(1), async { + while driver.stop_calls() < SHUTDOWN_STOP_CONCURRENCY { + tokio::task::yield_now().await; + } + }) + .await + .expect("shutdown sweep did not fill its concurrency window"); + assert_eq!(driver.stop_calls(), SHUTDOWN_STOP_CONCURRENCY); + + for _ in 0..=SHUTDOWN_STOP_CONCURRENCY { + driver.release_stop(); + } + tokio::time::timeout(Duration::from_secs(1), sweep) + .await + .expect("shutdown sweep did not finish") + .unwrap() + .unwrap(); + assert_eq!(driver.stop_calls(), SHUTDOWN_STOP_CONCURRENCY + 1); + } + + #[tokio::test] + async fn shutdown_stop_sweep_rechecks_intent_after_acquiring_gate() { + let driver = ControlledDriver::new(); + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; + runtime + .store + .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) + .await + .unwrap(); + + let gate = runtime.lifecycle_gates.gate_for("sb-1"); + let guard = gate.clone().lock_owned().await; + let sweep_runtime = runtime.clone(); + let sweep = + tokio::spawn(async move { sweep_runtime.stop_persisted_sandboxes_on_shutdown().await }); + tokio::time::timeout(Duration::from_secs(1), async { + while Arc::strong_count(&gate) < 3 { + tokio::task::yield_now().await; + } + }) + .await + .expect("shutdown sweep did not wait on the lifecycle gate"); + + runtime + .store + .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Stopped)) + .await + .unwrap(); + drop(guard); + tokio::time::timeout(Duration::from_secs(1), sweep) + .await + .expect("shutdown sweep did not finish") + .unwrap() + .unwrap(); + assert_eq!(driver.stop_calls(), 0); + } + + #[tokio::test] + async fn shutdown_stop_sweep_runs_for_any_capable_driver() { + for driver_name in ["arbitrary", "docker"] { + let driver = ControlledDriver::new(); + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), driver_name).await; + runtime + .store + .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) + .await + .unwrap(); - impl RecordingResume { - async fn set_result(&self, sandbox_id: &str, result: Result) { - self.results - .lock() + runtime + .stop_persisted_sandboxes_on_shutdown() .await - .insert(sandbox_id.to_string(), result); - } + .unwrap(); - async fn calls(&self) -> Vec<(String, String)> { - self.calls.lock().await.clone() + assert_eq!( + driver.stop_calls(), + 1, + "unexpected shutdown behavior for {driver_name}" + ); } } - #[tonic::async_trait] - impl StartupResume for RecordingResume { - async fn resume_sandbox( - &self, - sandbox_id: &str, - sandbox_name: &str, - ) -> Result { - self.calls - .lock() + #[tokio::test] + async fn shutdown_stop_sweep_skips_drivers_without_capability() { + for driver_name in ["docker", "kubernetes", "extension"] { + let driver = ControlledDriver::new(); + let runtime = test_runtime_for_driver(driver.clone(), driver_name).await; + runtime + .store + .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) .await - .push((sandbox_id.to_string(), sandbox_name.to_string())); - self.results - .lock() + .unwrap(); + + runtime + .stop_persisted_sandboxes_on_shutdown() .await - .get(sandbox_id) - .cloned() - .unwrap_or(Ok(true)) + .unwrap(); + + assert_eq!( + driver.stop_calls(), + 0, + "{driver_name} should retain operator-owned lifecycle" + ); } } #[tokio::test] - async fn resume_persisted_sandboxes_resumes_running_phases() { - let resume = Arc::new(RecordingResume::default()); + async fn start_persisted_sandboxes_starts_running_phases() { + let driver = ControlledDriver::new(); let runtime = - test_runtime_with_resume(Arc::new(TestDriver::default()), Some(resume.clone())).await; + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; for (id, name, phase) in [ ("sb-unspecified", "unspecified", SandboxPhase::Unspecified), ("sb-prov", "prov", SandboxPhase::Provisioning), ("sb-ready", "ready", SandboxPhase::Ready), ("sb-unknown", "unknown", SandboxPhase::Unknown), + ("sb-stopping", "stopping", SandboxPhase::Stopping), + ("sb-stopped", "stopped", SandboxPhase::Stopped), ("sb-deleting", "deleting", SandboxPhase::Deleting), - ("sb-error", "error", SandboxPhase::Error), ] { let sandbox = sandbox_record(id, name, phase); runtime.store.put_message(&sandbox).await.unwrap(); } + // Terminal errors are skipped: a backend-missing error and an ordinary + // container exit (crash) both stay in Error. Only a signal-kill from a + // machine/daemon restart is retried. + runtime + .store + .put_message(&error_sandbox_record( + "sb-error-perm", + "error-perm", + "BackendResourceMissing", + )) + .await + .unwrap(); + runtime + .store + .put_message(&error_sandbox_record( + "sb-error-exit", + "error-exit", + "ContainerExited", + )) + .await + .unwrap(); + runtime + .store + .put_message(&error_sandbox_record( + "sb-error-restart", + "error-restart", + "ContainerRuntimeRestart", + )) + .await + .unwrap(); - runtime.resume_persisted_sandboxes().await.unwrap(); + runtime.start_persisted_sandboxes().await.unwrap(); - let mut called_ids = resume - .calls() - .await + let mut called_ids = driver + .start_requests() .into_iter() .map(|(id, _)| id) .collect::>(); @@ -6598,6 +10260,7 @@ mod tests { assert_eq!( called_ids, vec![ + "sb-error-restart".to_string(), "sb-prov".to_string(), "sb-ready".to_string(), "sb-unknown".to_string(), @@ -6607,16 +10270,80 @@ mod tests { } #[tokio::test] - async fn resume_persisted_sandboxes_marks_missing_backend_as_error() { - let resume = Arc::new(RecordingResume::default()); - resume.set_result("sb-1", Ok(false)).await; + async fn startup_sweep_rechecks_intent_after_acquiring_gate() { + let driver = ControlledDriver::new(); + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; + runtime + .store + .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) + .await + .unwrap(); + + let gate = runtime.lifecycle_gates.gate_for("sb-1"); + let guard = gate.clone().lock_owned().await; + let sweep_runtime = runtime.clone(); + let sweep = tokio::spawn(async move { sweep_runtime.start_persisted_sandboxes().await }); + tokio::time::timeout(Duration::from_secs(1), async { + while Arc::strong_count(&gate) < 3 { + tokio::task::yield_now().await; + } + }) + .await + .expect("startup sweep did not wait on the lifecycle gate"); + + runtime + .store + .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Deleting)) + .await + .unwrap(); + drop(guard); + tokio::time::timeout(Duration::from_secs(1), sweep) + .await + .expect("startup sweep did not finish") + .unwrap() + .unwrap(); + assert_eq!(driver.start_calls(), 0); + } + + #[tokio::test] + async fn lifecycle_sweeps_page_through_all_persisted_sandboxes() { + let driver = ControlledDriver::new(); let runtime = - test_runtime_with_resume(Arc::new(TestDriver::default()), Some(resume.clone())).await; + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; + let sandbox_count = LIFECYCLE_SWEEP_PAGE_SIZE + 1; + for index in 0..sandbox_count { + runtime + .store + .put_message(&sandbox_record( + &format!("sb-{index:04}"), + &format!("sandbox-{index:04}"), + SandboxPhase::Ready, + )) + .await + .unwrap(); + } + + runtime.start_persisted_sandboxes().await.unwrap(); + assert_eq!(driver.start_calls(), sandbox_count as usize); + + runtime + .stop_persisted_sandboxes_on_shutdown() + .await + .unwrap(); + assert_eq!(driver.stop_calls(), sandbox_count as usize); + } + + #[tokio::test] + async fn start_persisted_sandboxes_marks_missing_backend_as_error() { + let driver = ControlledDriver::new(); + driver.set_start_outcome(ControlledLifecycleOutcome::NotFound); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver, "arbitrary").await; let sandbox = sandbox_record("sb-1", "missing", SandboxPhase::Ready); runtime.store.put_message(&sandbox).await.unwrap(); - runtime.resume_persisted_sandboxes().await.unwrap(); + runtime.start_persisted_sandboxes().await.unwrap(); let stored = runtime .store @@ -6634,21 +10361,19 @@ mod tests { .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) .expect("Ready condition present"); assert_eq!(ready.reason, "BackendResourceMissing"); + assert!(ready.message.contains("compute resource disappeared")); } #[tokio::test] - async fn resume_persisted_sandboxes_marks_failed_resume_as_error() { - let resume = Arc::new(RecordingResume::default()); - resume - .set_result("sb-1", Err("docker daemon angry".to_string())) - .await; - let runtime = - test_runtime_with_resume(Arc::new(TestDriver::default()), Some(resume.clone())).await; + async fn start_persisted_sandboxes_marks_failed_start_as_error() { + let driver = ControlledDriver::new(); + driver.set_start_outcome(ControlledLifecycleOutcome::Error("runtime angry")); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver, "arbitrary").await; let sandbox = sandbox_record("sb-1", "broken", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); - runtime.resume_persisted_sandboxes().await.unwrap(); + runtime.start_persisted_sandboxes().await.unwrap(); let stored = runtime .store @@ -6665,70 +10390,212 @@ mod tests { .as_ref() .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) .expect("Ready condition present"); - assert_eq!(ready.reason, "ResumeFailed"); - assert!(ready.message.contains("docker daemon angry")); + assert_eq!(ready.reason, "StartFailed"); + assert!(ready.message.contains("runtime angry")); } #[tokio::test] - async fn resume_persisted_sandboxes_is_noop_without_resume_hook() { - let runtime = test_runtime(Arc::new(TestDriver::default())).await; - let sandbox = sandbox_record("sb-1", "anywhere", SandboxPhase::Ready); + async fn start_persisted_sandboxes_runs_for_any_capable_driver() { + for driver_name in ["arbitrary", "docker"] { + let driver = ControlledDriver::new(); + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), driver_name).await; + let sandbox = sandbox_record("sb-1", "local", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!( + driver.start_requests(), + vec![("sb-1".to_string(), "local".to_string())], + "{driver_name} should reconcile persisted running intent" + ); + } + } + + #[tokio::test] + async fn start_persisted_sandboxes_skips_drivers_without_capability() { + for driver_name in ["docker", "kubernetes", "extension"] { + let driver = ControlledDriver::new(); + let runtime = test_runtime_for_driver(driver.clone(), driver_name).await; + let sandbox = sandbox_record("sb-1", "remote", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + assert!( + driver.start_requests().is_empty(), + "{driver_name} should not receive stable-running startup reconciliation" + ); + } + } + + #[tokio::test] + async fn start_persisted_sandboxes_recovers_error_phase_when_container_exists() { + let driver = ControlledDriver::new(); + // Default start outcome is Ok: the container is restarted successfully. + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-recover", "recover", "ContainerRuntimeRestart"); runtime.store.put_message(&sandbox).await.unwrap(); - runtime.resume_persisted_sandboxes().await.unwrap(); + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!(driver.start_calls(), 1); let stored = runtime .store - .get_message::("sb-1") + .get_message::("sb-err-recover") .await .unwrap() .unwrap(); assert_eq!( SandboxPhase::try_from(stored.phase()).unwrap(), - SandboxPhase::Ready + SandboxPhase::Provisioning ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "Resumed"); } - #[test] - fn build_platform_config_inverts_user_namespaces_to_host_users() { - use prost_types::value::Kind; + #[tokio::test] + async fn start_persisted_sandboxes_leaves_error_when_container_missing() { + let driver = ControlledDriver::new(); + driver.set_start_outcome(ControlledLifecycleOutcome::NotFound); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; - // user_namespaces: true → host_users: false - let mut template = SandboxTemplate { - user_namespaces: Some(true), - ..SandboxTemplate::default() - }; - let config = build_platform_config(&template).expect("config should be Some"); - let host_users = config - .fields - .get("host_users") - .expect("host_users must exist"); + let sandbox = error_sandbox_record("sb-err-gone", "gone", "ContainerRuntimeRestart"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + // Recovery was attempted, but the error state is preserved untouched. + assert_eq!(driver.start_calls(), 1); + + let stored = runtime + .store + .get_message::("sb-err-gone") + .await + .unwrap() + .unwrap(); assert_eq!( - host_users.kind, - Some(Kind::BoolValue(false)), - "user_namespaces: true must produce host_users: false" + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "ContainerRuntimeRestart"); + } - // user_namespaces: false → host_users: true - template.user_namespaces = Some(false); - let config = build_platform_config(&template).expect("config should be Some"); - let host_users = config - .fields - .get("host_users") - .expect("host_users must exist"); + #[tokio::test] + async fn start_persisted_sandboxes_leaves_error_when_start_fails() { + let driver = ControlledDriver::new(); + driver.set_start_outcome(ControlledLifecycleOutcome::Error("runtime angry")); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-fail", "fail", "ContainerStopped"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!(driver.start_calls(), 1); + + let stored = runtime + .store + .get_message::("sb-err-fail") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error + ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "ContainerStopped"); + } + + #[tokio::test] + async fn start_persisted_sandboxes_skips_non_recoverable_error() { + let driver = ControlledDriver::new(); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-perm", "perm", "BackendResourceMissing"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + // A non-container-exit error is never retried. + assert_eq!(driver.start_calls(), 0); + + let stored = runtime + .store + .get_message::("sb-err-perm") + .await + .unwrap() + .unwrap(); assert_eq!( - host_users.kind, - Some(Kind::BoolValue(true)), - "user_namespaces: false must produce host_users: true" + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error ); + } - // user_namespaces: None → host_users absent - template.user_namespaces = None; - let config = build_platform_config(&template); - assert!( - config.is_none() || !config.as_ref().unwrap().fields.contains_key("host_users"), - "unset user_namespaces must not produce host_users" + #[tokio::test] + async fn start_persisted_sandboxes_leaves_generic_container_exit_terminal() { + // A container that exited on its own — an ordinary application crash or + // non-zero exit — is stored as Error with `ContainerExited`. Startup + // recovery must NOT relaunch it: doing so would erase the failure + // signal and repeatedly revive a crash-prone workload. Only a + // signal-kill (`ContainerRuntimeRestart`) from a machine/daemon restart + // is recoverable. + let driver = ControlledDriver::new(); + let runtime = test_runtime_with_gateway_managed_lifecycle(driver.clone(), "podman").await; + + let sandbox = error_sandbox_record("sb-err-crash", "crash", "ContainerExited"); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime.start_persisted_sandboxes().await.unwrap(); + + assert_eq!(driver.start_calls(), 0); + + let stored = runtime + .store + .get_message::("sb-err-crash") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Error ); + let ready = stored + .status + .as_ref() + .and_then(|s| s.conditions.iter().find(|c| c.r#type == "Ready")) + .expect("Ready condition present"); + assert_eq!(ready.reason, "ContainerExited"); + } + + #[test] + fn driver_template_preserves_user_namespace_intent() { + let template = SandboxTemplate { + user_namespaces: Some(true), + ..SandboxTemplate::default() + }; + let driver_template = driver_sandbox_template_from_public(&template, "test") + .expect("template conversion should succeed"); + + assert_eq!(driver_template.user_namespaces, Some(true)); + assert!(driver_template.platform_config.is_none()); } #[tokio::test] @@ -6741,8 +10608,6 @@ mod tests { "test-driver".to_string(), Arc::new(TestDriver::default()), None, - None, - None, store, SandboxIndex::new(), SandboxWatchBus::new(), @@ -6903,13 +10768,14 @@ mod tests { let driver = FakeComputeDriver::new() .with_driver_name("fake-remote-driver") .with_default_image("openshell/sandbox:remote") + .with_gateway_manages_lifecycle() .with_gateway_listener_requirement( "172.19.0.1:17670", "external driver managed bridge", ); let _server = driver.serve_uds(&socket_path).unwrap(); - let endpoint = connect_remote_compute_driver("external-test", &socket_path) + let endpoint = connect_remote_compute_driver("docker", &socket_path) .await .unwrap(); let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); @@ -6927,7 +10793,7 @@ mod tests { runtime.gateway_listener_requirements(), &[GatewayListenerRequirement::Exact { address: "172.19.0.1:17670".parse().unwrap(), - driver_name: "external-test".to_string(), + driver_name: "docker".to_string(), reason: "external driver managed bridge".to_string(), }] ); @@ -6935,16 +10801,20 @@ mod tests { let mut sandbox = sandbox_record("sb-uds", "uds-sandbox", SandboxPhase::Provisioning); sandbox.spec = Some(SandboxSpec { log_level: "debug".to_string(), + policy: Some(openshell_core::proto::SandboxPolicy { + version: 42, + ..Default::default() + }), template: Some(SandboxTemplate { - image: "ghcr.io/nvidia/openshell/sandbox:test".to_string(), + image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), driver_config: Some(prost_types::Struct { fields: [ ( - "external-test".to_string(), + "docker".to_string(), struct_value([("pool", string_value("ci"))]), ), ( - "docker".to_string(), + "kubernetes".to_string(), struct_value([("network_mode", string_value("bridge"))]), ), ] @@ -6957,31 +10827,15 @@ mod tests { }); runtime.validate_sandbox_create(&sandbox).await.unwrap(); - runtime.create_sandbox(sandbox, None).await.unwrap(); - assert!( - runtime - .delete_sandbox("default", "uds-sandbox") - .await - .unwrap() - .deleted - ); - + runtime.create_sandbox(sandbox, None, false).await.unwrap(); let calls = driver.calls(); - assert_eq!(calls.len(), 5, "unexpected calls: {calls:?}"); - assert!(matches!(calls[0], FakeComputeDriverCall::GetCapabilities)); - assert!(matches!( - calls[1], - FakeComputeDriverCall::GetGatewayListenerRequirements - )); - + assert_eq!(calls.len(), 4, "unexpected calls: {calls:?}"); let validated = match &calls[2] { FakeComputeDriverCall::ValidateSandboxCreate { sandbox: Some(sandbox), } => sandbox, other => panic!("expected ValidateSandboxCreate call, got {other:?}"), }; - assert_eq!(validated.id, "sb-uds"); - assert_eq!(validated.name, "uds-sandbox"); let driver_config = validated .spec .as_ref() @@ -6990,17 +10844,51 @@ mod tests { .expect("selected driver_config should be forwarded"); assert!(driver_config.fields.contains_key("pool")); assert!(!driver_config.fields.contains_key("network_mode")); + assert_eq!( + validated + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version), + Some(42) + ); + assert!(matches!( + &calls[3], + FakeComputeDriverCall::CreateSandbox { sandbox: Some(sandbox) } + if sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()) + .is_some_and(|policy| policy.version == 42) + )); - let created = match &calls[3] { - FakeComputeDriverCall::CreateSandbox { - sandbox: Some(sandbox), - } => sandbox, - other => panic!("expected CreateSandbox call, got {other:?}"), - }; - assert_eq!(created.id, "sb-uds"); - assert_eq!(created.name, "uds-sandbox"); + driver.clear_calls(); + runtime + .stop_persisted_sandboxes_on_shutdown() + .await + .unwrap(); + assert!(matches!( + driver.calls().as_slice(), + [FakeComputeDriverCall::StopSandbox { sandbox_id, sandbox_name }] + if sandbox_id == "sb-uds" && sandbox_name == "uds-sandbox" + )); + + driver.clear_calls(); + runtime.start_persisted_sandboxes().await.unwrap(); + assert!(matches!( + driver.calls().as_slice(), + [FakeComputeDriverCall::StartSandbox { sandbox_id, sandbox_name }] + if sandbox_id == "sb-uds" && sandbox_name == "uds-sandbox" + )); + driver.clear_calls(); + assert!( + runtime + .delete_sandbox("default", "uds-sandbox") + .await + .unwrap() + .deleted + ); - match &calls[4] { + let calls = driver.calls(); + assert_eq!(calls.len(), 1, "unexpected calls: {calls:?}"); + match &calls[0] { FakeComputeDriverCall::DeleteSandbox { sandbox_id, sandbox_name, @@ -7066,7 +10954,7 @@ mod tests { deletion_timestamp_ms: 0, }); - let created = runtime.create_sandbox(sandbox, None).await.unwrap(); + let created = runtime.create_sandbox(sandbox, None, false).await.unwrap(); assert_eq!( created.metadata.as_ref().unwrap().resource_version, @@ -7101,7 +10989,7 @@ mod tests { .labels .insert("env".to_string(), "prod".to_string()); - runtime.create_sandbox(sandbox, None).await.unwrap(); + runtime.create_sandbox(sandbox, None, false).await.unwrap(); let matching = runtime .store @@ -7125,11 +11013,13 @@ mod tests { // Spawn two concurrent creation attempts for the same sandbox let runtime1 = runtime.clone(); let sandbox1 = sandbox.clone(); - let handle1 = tokio::spawn(async move { runtime1.create_sandbox(sandbox1, None).await }); + let handle1 = + tokio::spawn(async move { runtime1.create_sandbox(sandbox1, None, false).await }); let runtime2 = runtime.clone(); let sandbox2 = sandbox.clone(); - let handle2 = tokio::spawn(async move { runtime2.create_sandbox(sandbox2, None).await }); + let handle2 = + tokio::spawn(async move { runtime2.create_sandbox(sandbox2, None, false).await }); // Wait for both to complete let result1 = handle1.await.unwrap(); diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 1adad2b6b0..64ac953624 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -21,10 +21,11 @@ //! values. use std::collections::BTreeMap; +use std::io::Cursor; use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use openshell_core::config::ComputeDriverKind; +use base64::Engine as _; use openshell_core::proto::SupervisorMiddlewareService; use openshell_core::{ GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, @@ -68,6 +69,11 @@ pub struct OpenShellRoot { /// independently of this crate. #[serde(default)] pub drivers: BTreeMap, + + /// `[openshell.credential_drivers.]` tables — passed verbatim to + /// credential driver implementations after gateway-level selection. + #[serde(default)] + pub credential_drivers: BTreeMap, } /// `[openshell.gateway]` section. @@ -80,6 +86,11 @@ pub struct OpenShellRoot { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GatewayFileSection { + // ── Identity ───────────────────────────────────────────────────────── + /// Operator-assigned name for this gateway installation. + #[serde(default)] + pub name: Option, + // ── Listeners ──────────────────────────────────────────────────────── #[serde(default)] pub bind_address: Option, @@ -95,6 +106,12 @@ pub struct GatewayFileSection { // ── Drivers ────────────────────────────────────────────────────────── #[serde(default)] pub compute_drivers: Option>, + #[serde(default)] + pub credential_drivers: Option>, + #[serde(default)] + pub default_credential_driver: Option, + #[serde(default)] + pub credential_storage: Option, // ── Sandbox / SSH ──────────────────────────────────────────────────── #[serde(default)] @@ -209,24 +226,98 @@ pub struct SupervisorFileSection { pub struct MiddlewareServiceFileConfig { /// Operator-facing name used for diagnostics. pub name: String, - /// Plaintext gRPC endpoint reachable by the gateway and supervisors. + /// HTTP or HTTPS gRPC endpoint reachable by the gateway and supervisors. pub grpc_endpoint: String, - /// Operator-owned body limit for every binding exposed by this service. - pub max_body_bytes: u64, + /// Optional PEM trust-root bundle for an HTTPS endpoint. + #[serde(default)] + pub tls_ca_cert_path: Option, + /// Exact JWT audience for this service. Defaults to a kind-scoped value + /// derived from the registration name. + #[serde(default)] + pub audience: Option, + /// Opt out of extension authentication for this registration, permitting a + /// plaintext `http://` endpoint with no bearer credential. Development and + /// trusted-network deployments only. + #[serde(default)] + pub allow_insecure_transport: bool, + /// Operator-owned logical payload limit for every binding exposed by this + /// service, including HTTP bodies and complete WebSocket messages. + #[serde(alias = "max_body_bytes")] + pub max_payload_bytes: u64, /// Default RPC timeout using an integer with an `ms` or `s` suffix. #[serde(default)] pub timeout: Option, } -impl From<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { - fn from(config: &MiddlewareServiceFileConfig) -> Self { - Self { +impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { + type Error = ConfigFileError; + + fn try_from(config: &MiddlewareServiceFileConfig) -> Result { + let tls_ca_cert_pem = match &config.tls_ca_cert_path { + Some(path) => { + let pem = + std::fs::read(path).map_err(|source| ConfigFileError::MiddlewareTlsCaRead { + name: config.name.clone(), + path: path.clone(), + source, + })?; + sanitize_ca_cert_pem(&config.name, path, &pem)? + } + None => Vec::new(), + }; + + Ok(Self { name: config.name.clone(), grpc_endpoint: config.grpc_endpoint.clone(), - max_body_bytes: config.max_body_bytes, + max_payload_bytes: config.max_payload_bytes, timeout: config.timeout.clone().unwrap_or_default(), + tls_ca_cert_pem, + audience: config + .audience + .as_deref() + .filter(|audience| !audience.is_empty()) + .map_or_else( + || format!("urn:openshell:extension:middleware:{}", config.name), + ToString::to_string, + ), + allow_insecure_transport: config.allow_insecure_transport, + }) + } +} + +fn sanitize_ca_cert_pem(name: &str, path: &Path, pem: &[u8]) -> Result, ConfigFileError> { + let mut sanitized = Vec::new(); + let mut certificate_count = 0; + for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) { + let item = item.map_err(|source| ConfigFileError::MiddlewareTlsCaInvalid { + name: name.to_string(), + path: path.to_path_buf(), + message: source.to_string(), + })?; + let rustls_pemfile::Item::X509Certificate(certificate) = item else { + return Err(ConfigFileError::MiddlewareTlsCaInvalid { + name: name.to_string(), + path: path.to_path_buf(), + message: "PEM bundle contains a non-certificate block".to_string(), + }); + }; + certificate_count += 1; + sanitized.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); + let encoded = base64::engine::general_purpose::STANDARD.encode(certificate.as_ref()); + for line in encoded.as_bytes().chunks(64) { + sanitized.extend_from_slice(line); + sanitized.push(b'\n'); } + sanitized.extend_from_slice(b"-----END CERTIFICATE-----\n"); + } + if certificate_count == 0 { + return Err(ConfigFileError::MiddlewareTlsCaInvalid { + name: name.to_string(), + path: path.to_path_buf(), + message: "PEM bundle does not contain a certificate".to_string(), + }); } + Ok(sanitized) } #[derive(Debug, thiserror::Error)] @@ -255,12 +346,37 @@ pub enum ConfigFileError { env: &'static str, cli: &'static str, }, + #[error("invalid gateway config field `{field}`: {message}")] + InvalidValue { + field: &'static str, + message: &'static str, + }, + #[error( + "failed to read TLS CA certificate for supervisor middleware '{name}' from '{}': {source}", + path.display() + )] + MiddlewareTlsCaRead { + name: String, + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error( + "invalid TLS CA certificate for supervisor middleware '{name}' at '{}': {message}", + path.display() + )] + MiddlewareTlsCaInvalid { + name: String, + path: PathBuf, + message: String, + }, } /// Load and validate a TOML config file. /// /// Returns `Ok(ConfigFile::default())` for an empty file (the gateway then /// falls back entirely to CLI/env/built-in defaults). +#[cfg_attr(target_os = "windows", allow(clippy::result_large_err))] pub fn load(path: &Path) -> Result { let contents = std::fs::read_to_string(path).map_err(|source| ConfigFileError::Io { path: path.to_path_buf(), @@ -287,6 +403,18 @@ pub fn load(path: &Path) -> Result { cli: "--db-url", }); } + if file + .openshell + .gateway + .credential_drivers + .as_ref() + .is_some_and(Vec::is_empty) + { + return Err(ConfigFileError::InvalidValue { + field: "openshell.gateway.credential_drivers", + message: "omit the field to use default encrypted gateway credential storage, or specify exactly one external credential driver", + }); + } Ok(file) } @@ -303,13 +431,22 @@ pub fn driver_table( driver_name: &str, gateway: &GatewayFileSection, raw: Option<&toml::Value>, +) -> toml::Value { + driver_table_with_inherited_keys(driver_name, gateway, raw, &[]) +} + +pub(crate) fn driver_table_with_inherited_keys( + _driver_name: &str, + gateway: &GatewayFileSection, + raw: Option<&toml::Value>, + inheritable_keys: &[&str], ) -> toml::Value { let mut merged = match raw { Some(toml::Value::Table(table)) => table.clone(), _ => toml::Table::new(), }; - for key in inheritable_keys(driver_name) { + for key in inheritable_keys { if merged.contains_key(*key) { continue; } @@ -321,48 +458,6 @@ pub fn driver_table( toml::Value::Table(merged) } -/// Inheritance allowlist (the Q4 "high-overlap set"). Each driver opts in -/// to a specific subset so a gateway-wide default does not accidentally land -/// in a driver table that does not understand the field. -fn inheritable_keys(driver_name: &str) -> &'static [&'static str] { - match driver_name.parse::().ok() { - Some(ComputeDriverKind::Kubernetes) => &[ - "namespace", - "default_image", - "supervisor_image", - "client_tls_secret_name", - "service_account_name", - "host_gateway_ip", - "enable_user_namespaces", - "sa_token_ttl_secs", - ], - Some(ComputeDriverKind::Docker) => &[ - "sandbox_namespace", - "default_image", - "supervisor_image", - "host_gateway_ip", - "guest_tls_ca", - "guest_tls_cert", - "guest_tls_key", - ], - Some(ComputeDriverKind::Podman) => &[ - "default_image", - "supervisor_image", - "host_gateway_ip", - "guest_tls_ca", - "guest_tls_cert", - "guest_tls_key", - ], - Some(ComputeDriverKind::Vm) => &[ - "default_image", - "guest_tls_ca", - "guest_tls_cert", - "guest_tls_key", - ], - None => &[], - } -} - fn gateway_inherited_value(g: &GatewayFileSection, key: &str) -> Option { match key { "namespace" | "sandbox_namespace" => g.sandbox_namespace.as_deref().map(string_value), @@ -422,11 +517,12 @@ bind_address = "0.0.0.0:8080" health_bind_address = "0.0.0.0:8081" log_level = "info" compute_drivers = ["kubernetes"] +credential_drivers = ["kubernetes-secrets"] sandbox_namespace = "agents" grpc_rate_limit_requests = 120 grpc_rate_limit_window_seconds = 60 policy_validation_failure_mode = "retain_last_valid" -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" client_tls_secret_name = "openshell-sandbox-tls" service_account_name = "openshell-sandbox" @@ -443,6 +539,9 @@ audience = "openshell-cli" [openshell.drivers.kubernetes] namespace = "agents" grpc_endpoint = "https://openshell-gateway.agents.svc:8080" + +[openshell.credential_drivers.kubernetes-secrets] +namespace = "agents" "#; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("valid file parses"); @@ -450,7 +549,7 @@ grpc_endpoint = "https://openshell-gateway.agents.svc:8080" assert_eq!(gw.log_level.as_deref(), Some("info")); assert_eq!( gw.default_image.as_deref(), - Some("ghcr.io/nvidia/openshell/sandbox:latest") + Some("ghcr.io/nvidia/openshell-community/sandboxes/base:latest") ); assert_eq!(gw.grpc_rate_limit_requests, Some(120)); assert_eq!(gw.grpc_rate_limit_window_seconds, Some(60)); @@ -460,7 +559,32 @@ grpc_endpoint = "https://openshell-gateway.agents.svc:8080" ); assert!(gw.tls.is_some()); assert!(gw.oidc.is_some()); + assert_eq!( + gw.credential_drivers.as_deref(), + Some(&["kubernetes-secrets".to_string()][..]) + ); + assert!(gw.default_credential_driver.is_none()); assert!(file.openshell.drivers.contains_key("kubernetes")); + assert!( + file.openshell + .credential_drivers + .contains_key("kubernetes-secrets") + ); + } + + #[test] + fn rejects_explicit_empty_credential_drivers() { + let tmp = write_tmp( + r" +[openshell.gateway] +credential_drivers = [] +", + ); + + let err = load(tmp.path()).unwrap_err(); + + assert!(err.to_string().contains("credential_drivers")); + assert!(err.to_string().contains("omit the field")); } #[test] @@ -547,27 +671,176 @@ allow_unauthenticated_users = true #[test] fn parses_supervisor_middleware_registration() { + let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("test certificate"); + let mut ca = tempfile::Builder::new() + .suffix(".pem") + .tempfile() + .expect("CA tempfile"); + ca.write_all(certificate.cert.pem().as_bytes()) + .expect("write CA"); let toml = r#" [[openshell.supervisor.middleware]] name = "local-guard" -grpc_endpoint = "http://127.0.0.1:50051" -max_body_bytes = 262144 +grpc_endpoint = "https://127.0.0.1:50051" +tls_ca_cert_path = 'CA_PATH' +audience = "urn:openshell:middleware:local-guard" +max_payload_bytes = 262144 timeout = "2s" -"#; - let tmp = write_tmp(toml); +"# + .replace("CA_PATH", &ca.path().display().to_string()); + let tmp = write_tmp(&toml); let file = load(tmp.path()).expect("valid middleware registration parses"); assert_eq!( file.openshell.supervisor.middleware, vec![MiddlewareServiceFileConfig { name: "local-guard".into(), - grpc_endpoint: "http://127.0.0.1:50051".into(), - max_body_bytes: 262_144, + grpc_endpoint: "https://127.0.0.1:50051".into(), + tls_ca_cert_path: Some(ca.path().to_path_buf()), + audience: Some("urn:openshell:middleware:local-guard".into()), + allow_insecure_transport: false, + max_payload_bytes: 262_144, timeout: Some("2s".into()), }] ); let registration = - SupervisorMiddlewareService::from(&file.openshell.supervisor.middleware[0]); + SupervisorMiddlewareService::try_from(&file.openshell.supervisor.middleware[0]) + .expect("valid CA resolves"); assert_eq!(registration.timeout, "2s"); + let registered_pem = String::from_utf8(registration.tls_ca_cert_pem) + .expect("registered CA remains PEM text") + .replace("\r\n", "\n"); + let generated_pem = certificate.cert.pem().replace("\r\n", "\n"); + assert_eq!(registered_pem, generated_pem); + assert_eq!( + registration.audience, + "urn:openshell:middleware:local-guard" + ); + } + + #[test] + fn middleware_registration_defaults_audience_to_name() { + let mut config = MiddlewareServiceFileConfig { + name: "local-guard".into(), + grpc_endpoint: "https://guard.example:50051".into(), + tls_ca_cert_path: None, + audience: None, + allow_insecure_transport: false, + max_payload_bytes: 262_144, + timeout: None, + }; + + let registration = SupervisorMiddlewareService::try_from(&config).unwrap(); + assert_eq!( + registration.audience, + "urn:openshell:extension:middleware:local-guard" + ); + assert!(registration.tls_ca_cert_pem.is_empty()); + + config.audience = Some(String::new()); + let registration = SupervisorMiddlewareService::try_from(&config).unwrap(); + assert_eq!( + registration.audience, + "urn:openshell:extension:middleware:local-guard" + ); + } + + #[test] + fn middleware_registration_rejects_invalid_ca_pem() { + let mut ca = tempfile::Builder::new() + .suffix(".pem") + .tempfile() + .expect("CA tempfile"); + ca.write_all(b"not a certificate").expect("write CA"); + let config = MiddlewareServiceFileConfig { + name: "local-guard".into(), + grpc_endpoint: "https://guard.example:50051".into(), + tls_ca_cert_path: Some(ca.path().to_path_buf()), + audience: None, + allow_insecure_transport: false, + max_payload_bytes: 262_144, + timeout: None, + }; + + let error = SupervisorMiddlewareService::try_from(&config) + .expect_err("invalid CA must fail before service connection"); + assert!(matches!( + error, + ConfigFileError::MiddlewareTlsCaInvalid { .. } + )); + } + + #[test] + fn middleware_registration_rejects_ca_bundle_with_private_key() { + use std::io::Write as _; + + let certificate = + rcgen::generate_simple_self_signed(vec!["localhost".into()]).expect("test certificate"); + let mut ca = tempfile::Builder::new() + .suffix(".pem") + .tempfile() + .expect("CA tempfile"); + ca.write_all(certificate.cert.pem().as_bytes()) + .expect("write certificate"); + ca.write_all(certificate.key_pair.serialize_pem().as_bytes()) + .expect("write private key"); + let config = MiddlewareServiceFileConfig { + name: "local-guard".into(), + grpc_endpoint: "https://guard.example:50051".into(), + tls_ca_cert_path: Some(ca.path().to_path_buf()), + audience: None, + allow_insecure_transport: false, + max_payload_bytes: 262_144, + timeout: None, + }; + + let error = SupervisorMiddlewareService::try_from(&config) + .expect_err("private key material must never be distributed to a sandbox"); + assert!(matches!( + error, + ConfigFileError::MiddlewareTlsCaInvalid { .. } + )); + assert!(error.to_string().contains("non-certificate block")); + } + + #[test] + fn parses_gateway_interceptor_tls_and_audience() { + let tmp = write_tmp( + r#" +[[openshell.gateway.interceptors]] +name = "quota" +grpc_endpoint = "https://quota.example:50051" +tls_ca_cert_path = "/etc/openshell/quota-ca.pem" +audience = "urn:openshell:interceptor:quota" +"#, + ); + + let file = load(tmp.path()).expect("valid interceptor config parses"); + let interceptor = &file.openshell.gateway.interceptors[0]; + assert_eq!( + interceptor.tls_ca_cert_path.as_deref(), + Some(Path::new("/etc/openshell/quota-ca.pem")) + ); + assert_eq!( + interceptor.resolved_audience(), + "urn:openshell:interceptor:quota" + ); + } + + #[test] + fn parses_legacy_supervisor_middleware_payload_limit() { + let toml = r#" +[[openshell.supervisor.middleware]] +name = "local-guard" +grpc_endpoint = "http://127.0.0.1:50051" +max_body_bytes = 262144 +"#; + let tmp = write_tmp(toml); + let file = load(tmp.path()).expect("legacy middleware registration parses"); + assert_eq!( + file.openshell.supervisor.middleware[0].max_payload_bytes, + 262_144 + ); } #[test] @@ -670,17 +943,20 @@ version = 2 #[test] fn driver_table_inherits_gateway_defaults() { let gateway = GatewayFileSection { - default_image: Some("ghcr.io/nvidia/openshell/sandbox:0.9".to_string()), + default_image: Some( + "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + ), supervisor_image: Some("ghcr.io/nvidia/openshell/supervisor:0.9".to_string()), ..Default::default() }; let raw = toml::toml! { namespace = "agents" }; - let merged = driver_table( - ComputeDriverKind::Kubernetes.as_str(), + let merged = driver_table_with_inherited_keys( + "alpha", &gateway, Some(&toml::Value::Table(raw)), + &["default_image", "supervisor_image"], ); let table = merged.as_table().expect("table"); assert_eq!( @@ -689,7 +965,7 @@ version = 2 ); assert_eq!( table.get("default_image").and_then(|v| v.as_str()), - Some("ghcr.io/nvidia/openshell/sandbox:0.9") + Some("ghcr.io/nvidia/openshell-community/sandboxes/base:latest") ); assert_eq!( table.get("supervisor_image").and_then(|v| v.as_str()), @@ -698,14 +974,21 @@ version = 2 } #[test] - fn docker_driver_table_inherits_gateway_defaults() { + fn registered_driver_table_inherits_selected_gateway_defaults() { let gateway = GatewayFileSection { sandbox_namespace: Some("agents".to_string()), - default_image: Some("ghcr.io/nvidia/openshell/sandbox:0.9".to_string()), + default_image: Some( + "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + ), host_gateway_ip: Some("10.0.0.1".to_string()), ..Default::default() }; - let merged = driver_table(ComputeDriverKind::Docker.as_str(), &gateway, None); + let merged = driver_table_with_inherited_keys( + "alpha", + &gateway, + None, + &["sandbox_namespace", "default_image", "host_gateway_ip"], + ); let table = merged.as_table().expect("table"); assert_eq!( table.get("sandbox_namespace").and_then(|v| v.as_str()), @@ -713,7 +996,7 @@ version = 2 ); assert_eq!( table.get("default_image").and_then(|v| v.as_str()), - Some("ghcr.io/nvidia/openshell/sandbox:0.9") + Some("ghcr.io/nvidia/openshell-community/sandboxes/base:latest") ); assert_eq!( table.get("host_gateway_ip").and_then(|v| v.as_str()), @@ -722,17 +1005,24 @@ version = 2 } #[test] - fn podman_driver_table_inherits_gateway_host_gateway_ip() { + fn registered_driver_table_can_select_network_defaults() { let gateway = GatewayFileSection { - default_image: Some("ghcr.io/nvidia/openshell/sandbox:0.9".to_string()), + default_image: Some( + "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + ), host_gateway_ip: Some("192.168.127.254".to_string()), ..Default::default() }; - let merged = driver_table(ComputeDriverKind::Podman.as_str(), &gateway, None); + let merged = driver_table_with_inherited_keys( + "beta", + &gateway, + None, + &["default_image", "host_gateway_ip"], + ); let table = merged.as_table().expect("table"); assert_eq!( table.get("default_image").and_then(|v| v.as_str()), - Some("ghcr.io/nvidia/openshell/sandbox:0.9") + Some("ghcr.io/nvidia/openshell-community/sandboxes/base:latest") ); assert_eq!( table.get("host_gateway_ip").and_then(|v| v.as_str()), @@ -749,10 +1039,11 @@ version = 2 let raw = toml::toml! { default_image = "driver-specific" }; - let merged = driver_table( - ComputeDriverKind::Podman.as_str(), + let merged = driver_table_with_inherited_keys( + "alpha", &gateway, Some(&toml::Value::Table(raw)), + &["default_image"], ); assert_eq!( merged @@ -766,13 +1057,12 @@ version = 2 #[test] fn driver_table_does_not_leak_keys_outside_allowlist() { - // `client_tls_secret_name` is K8s-only; Docker must not receive it - // even when set at gateway scope. + // Fields not selected by the registration must remain gateway-only. let gateway = GatewayFileSection { client_tls_secret_name: Some("openshell-sandbox-tls".to_string()), ..Default::default() }; - let merged = driver_table(ComputeDriverKind::Docker.as_str(), &gateway, None); + let merged = driver_table_with_inherited_keys("alpha", &gateway, None, &["default_image"]); assert!( !merged .as_table() diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs new file mode 100644 index 0000000000..12d2835be3 --- /dev/null +++ b/crates/openshell-server/src/credentials.rs @@ -0,0 +1,2580 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway credential-driver runtime scaffolding. +//! +//! This module owns gateway-level credential-driver selection and resolution +//! dispatch. Concrete production backends and remote UDS transport plug in here +//! in later implementation slices. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +#[cfg(unix)] +use std::future::Future; +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::process::Stdio; +use std::sync::Arc; +#[cfg(unix)] +use std::time::{Duration, Instant}; +#[cfg(unix)] +use std::{ + io::ErrorKind, + os::unix::fs::{FileTypeExt, MetadataExt}, +}; + +use async_trait::async_trait; +#[cfg(unix)] +use hyper_util::rt::TokioIo; +use openshell_core::proto::credentials::v1::{ + DeleteCredentialRequest, GetCredentialDriverCapabilitiesRequest, + GetCredentialDriverCapabilitiesResponse, ResolveCredentialRequest, ResolveCredentialsRequest, + ResolvedCredential, StoreCredentialRequest, credential_driver_client::CredentialDriverClient, +}; +use openshell_core::proto::{CredentialHandle, Provider, StoredRefreshMaterialDeletion}; +use openshell_core::{Config, Error, Result as CoreResult}; +use openshell_driver_db_credstore::{ + CredentialObjectWrite, DbCredstoreCredentialDriver, DbCredstoreObjectStore, + DbCredstoreWriteCondition, StoredCredentialObject, +}; +use openshell_driver_kubernetes_secrets::KubernetesSecretsCredentialDriver; +use openshell_driver_vault::VaultCredentialDriver; +use sha2::{Digest, Sha256}; +#[cfg(unix)] +use tokio::net::UnixStream; +#[cfg(unix)] +use tokio::process::Command; +#[cfg(unix)] +use tonic::transport::{Channel, Endpoint}; +use tonic::{Request, Status}; +#[cfg(unix)] +use tower::service_fn; +use tracing::warn; + +use crate::persistence::{PersistenceError, Store, WriteCondition}; + +const DEFAULT_CREDENTIAL_DRIVER_STARTUP_TIMEOUT_SECS: u64 = 10; +const DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS: u64 = 30; +const REFRESH_MATERIAL_CREDENTIAL_KEY_DOMAIN: &[u8] = + b"openshell-refresh-material-credential-key-v1"; +const COMMON_CREDENTIAL_DRIVER_FIELDS: &[&str] = &[ + "transport", + "socket_path", + "command", + "args", + "startup_timeout_secs", +]; +#[cfg(unix)] +const CREDENTIAL_DRIVER_CONNECT_INTERVAL: Duration = Duration::from_millis(100); + +#[async_trait] +pub trait CredentialDriver: std::fmt::Debug + Send + Sync { + async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result; + + async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status>; + + async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status>; + + #[cfg(test)] + fn stored_credential_count(&self) -> Option { + None + } + + #[cfg(test)] + fn fail_next_store(&self) {} + + #[cfg(test)] + fn fail_next_delete(&self) {} + + #[cfg(test)] + fn gate_next_store( + &self, + ) -> Option<( + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender<()>, + )> { + None + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResolvedProviderCredentials { + pub values: HashMap, + pub expires_at_ms: HashMap, +} + +#[derive(Debug, Clone, Copy)] +pub struct RefreshMaterialScope<'a> { + pub provider_name: &'a str, + pub workspace: &'a str, + pub provider_id: &'a str, + pub credential_key: &'a str, +} + +#[derive(Debug, Clone)] +pub struct CredentialRuntime { + registry: CredentialDriverRegistry, + drivers: BTreeMap>, + _driver_processes: Vec>, +} + +impl CredentialRuntime { + pub fn from_config(config: &Config) -> CoreResult { + Self::from_config_with_optional_store(config, None) + } + + pub fn from_config_with_store(config: &Config, store: Arc) -> CoreResult { + Self::from_config_with_optional_store(config, Some(store)) + } + + fn from_config_with_optional_store( + config: &Config, + store: Option>, + ) -> CoreResult { + let registry = CredentialDriverRegistry::from_config(config)?; + let mut drivers = BTreeMap::new(); + connect_default_credential_store( + &mut drivers, + store.clone(), + &toml::Table::new(), + registry.requires_default_store(), + )?; + + for driver_name in registry.enabled_driver_names() { + if let Some(driver) = build_sync_builtin_driver(driver_name, store.clone()) { + drivers.insert(driver_name.clone(), driver); + } else if BuiltinCredentialDriverKind::from_name(driver_name).is_none() { + return Err(unknown_credential_driver_error(driver_name)); + } + } + + Ok(Self { + registry, + drivers, + _driver_processes: Vec::new(), + }) + } + + pub async fn from_config_file( + config: &Config, + config_file: Option<&crate::config_file::ConfigFile>, + ) -> CoreResult { + Self::from_config_file_with_optional_store(config, config_file, None).await + } + + pub async fn from_config_file_with_store( + config: &Config, + config_file: Option<&crate::config_file::ConfigFile>, + store: Arc, + ) -> CoreResult { + Self::from_config_file_with_optional_store(config, config_file, Some(store)).await + } + + async fn from_config_file_with_optional_store( + config: &Config, + config_file: Option<&crate::config_file::ConfigFile>, + store: Option>, + ) -> CoreResult { + let registry = CredentialDriverRegistry::from_config(config)?; + let mut drivers = BTreeMap::new(); + let mut driver_processes = Vec::new(); + let empty_config = toml::Table::new(); + let default_store_config = config_file + .and_then(|file| file.openshell.gateway.credential_storage.as_ref()) + .unwrap_or(&empty_config); + connect_default_credential_store( + &mut drivers, + store.clone(), + default_store_config, + registry.requires_default_store(), + )?; + + for driver_name in registry.enabled_driver_names() { + let driver_config = config_file + .and_then(|file| file.openshell.credential_drivers.get(driver_name)) + .map(|value| parse_driver_table(driver_name, value)) + .transpose()?; + + if let Some(driver_config) = driver_config { + let built = + build_configured_driver(driver_name, driver_config, store.clone()).await?; + drivers.insert(driver_name.clone(), built.driver); + if let Some(process) = built.process { + driver_processes.push(process); + } + } else { + let driver = build_default_in_tree_driver(driver_name, store.clone()).await?; + drivers.insert(driver_name.clone(), driver); + } + } + + Ok(Self { + registry, + drivers, + _driver_processes: driver_processes, + }) + } + + pub fn validate_provider_handles(&self, provider: &Provider) -> Result<(), Status> { + self.registry.validate_provider_handles(provider) + } + + pub fn stores_provider_credentials(&self) -> bool { + let driver_name = self.registry.storage_owner_name(); + self.drivers.contains_key(&driver_name) + } + + pub fn storage_owns_handle(&self, handle: &CredentialHandle) -> bool { + normalize_driver_name(&handle.driver) == self.registry.storage_owner_name() + } + + #[cfg(test)] + pub(crate) fn stored_credential_count(&self) -> Option { + self.drivers + .get(&self.registry.storage_owner_name()) + .and_then(|driver| driver.stored_credential_count()) + } + + #[cfg(test)] + pub(crate) fn fail_next_store(&self) { + if let Some(driver) = self.drivers.get(&self.registry.storage_owner_name()) { + driver.fail_next_store(); + } + } + + #[cfg(test)] + pub(crate) fn fail_next_delete(&self) { + if let Some(driver) = self.drivers.get(&self.registry.storage_owner_name()) { + driver.fail_next_delete(); + } + } + + #[cfg(test)] + pub(crate) fn gate_next_store( + &self, + ) -> ( + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender<()>, + ) { + self.drivers + .get(&self.registry.storage_owner_name()) + .and_then(|driver| driver.gate_next_store()) + .expect("test credential driver supports store gating") + } + + pub async fn store_provider_credentials( + &self, + provider_name: &str, + workspace: &str, + provider_id: &str, + credentials: &HashMap, + existing_handles: &HashMap, + ) -> Result, Status> { + self.store_provider_credentials_with_object_id( + provider_name, + workspace, + provider_id, + provider_id, + credentials, + existing_handles, + ) + .await + } + + pub async fn store_provider_credentials_with_object_id( + &self, + provider_name: &str, + workspace: &str, + provider_id: &str, + object_id: &str, + credentials: &HashMap, + existing_handles: &HashMap, + ) -> Result, Status> { + if credentials.is_empty() { + return Ok(HashMap::new()); + } + let driver_name = self.registry.storage_owner_name(); + let driver = self.connected_driver(&driver_name)?; + + let futures = credentials.iter().map(|(credential_key, value)| { + let driver_name = driver_name.clone(); + let driver = driver.clone(); + async move { + let existing_handle = existing_handles + .get(credential_key) + .filter(|handle| normalize_driver_name(&handle.driver) == driver_name) + .cloned(); + let replaced_handle = existing_handles + .get(credential_key) + .filter(|handle| normalize_driver_name(&handle.driver) != driver_name) + .cloned(); + let mut handle = driver + .store_credential(StoreCredentialRequest { + provider_name: provider_name.to_string(), + credential_key: credential_key.clone(), + value: value.clone(), + existing_handle, + workspace: workspace.to_string(), + provider_id: provider_id.to_string(), + object_id: object_id.to_string(), + }) + .await?; + handle.driver.clone_from(&driver_name); + if handle.handle.trim().is_empty() { + return Err(Status::internal(format!( + "credential driver '{driver_name}' returned an empty handle for provider credential '{credential_key}'" + ))); + } + if let Some(replaced_handle) = replaced_handle { + self.delete_provider_credential_handle( + provider_name, + workspace, + provider_id, + credential_key, + replaced_handle, + ) + .await?; + } + Ok::<_, Status>((credential_key.clone(), handle)) + } + }); + let results = futures::future::join_all(futures).await; + + let mut successes = HashMap::new(); + let mut first_error: Option = None; + for result in results { + match result { + Ok((key, handle)) => { + successes.insert(key, handle); + } + Err(err) if first_error.is_none() => { + first_error = Some(err); + } + Err(_) => {} + } + } + + if let Some(err) = first_error { + if !successes.is_empty() + && let Err(cleanup_err) = self + .delete_provider_credential_handles( + provider_name, + workspace, + provider_id, + &successes, + ) + .await + { + tracing::warn!( + provider_name = %provider_name, + error = %cleanup_err, + "failed to clean up partially stored credentials after error" + ); + } + return Err(err); + } + + Ok(successes) + } + + /// Store gateway-only refresh material through the active credential + /// driver. Material names never become backend keys directly; a + /// deterministic driver-safe key binds each slot to its injectable + /// credential without exposing caller-controlled names in backend paths. + pub async fn store_refresh_material_with_object_id( + &self, + scope: RefreshMaterialScope<'_>, + object_id: &str, + material: &HashMap, + existing_handles: &HashMap, + ) -> Result, Status> { + let mut values_by_storage_key = HashMap::with_capacity(material.len()); + let mut handles_by_storage_key = HashMap::with_capacity(existing_handles.len()); + let mut material_by_storage_key = HashMap::with_capacity(material.len()); + + for (material_key, value) in material { + let storage_key = refresh_material_storage_key(scope.credential_key, material_key); + material_by_storage_key.insert(storage_key.clone(), material_key.clone()); + values_by_storage_key.insert(storage_key, value.clone()); + } + for (material_key, handle) in existing_handles { + handles_by_storage_key.insert( + refresh_material_storage_key(scope.credential_key, material_key), + handle.clone(), + ); + } + + let stored = self + .store_provider_credentials_with_object_id( + scope.provider_name, + scope.workspace, + scope.provider_id, + object_id, + &values_by_storage_key, + &handles_by_storage_key, + ) + .await?; + stored + .into_iter() + .map(|(storage_key, handle)| { + material_by_storage_key + .remove(&storage_key) + .map(|material_key| (material_key, handle)) + .ok_or_else(|| { + Status::internal( + "credential driver returned an unknown refresh material key", + ) + }) + }) + .collect() + } + + pub async fn resolve_refresh_material( + &self, + scope: RefreshMaterialScope<'_>, + handles: &HashMap, + ) -> Result, Status> { + if handles.is_empty() { + return Ok(HashMap::new()); + } + let mut material_by_storage_key = HashMap::with_capacity(handles.len()); + let mut storage_handles = HashMap::with_capacity(handles.len()); + for (material_key, handle) in handles { + let storage_key = refresh_material_storage_key(scope.credential_key, material_key); + material_by_storage_key.insert(storage_key.clone(), material_key.clone()); + storage_handles.insert(storage_key, handle.clone()); + } + // Reuse the normal driver-batched resolver with a synthetic provider. + // Refresh inputs are gateway-only and have no provider-level expiry; + // passing zero below prevents an expired injectable credential from + // suppressing resolution of an otherwise valid refresh-material handle. + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: scope.provider_id.to_string(), + name: scope.provider_name.to_string(), + workspace: scope.workspace.to_string(), + ..Default::default() + }), + credential_handles: storage_handles, + ..Default::default() + }; + let resolved = self.resolve_provider_handles(&provider, 0).await?; + resolved + .values + .into_iter() + .map(|(storage_key, value)| { + material_by_storage_key + .remove(&storage_key) + .map(|material_key| (material_key, value)) + .ok_or_else(|| { + Status::internal( + "credential driver resolved an unknown refresh material key", + ) + }) + }) + .collect() + } + + pub async fn delete_refresh_material_handles( + &self, + scope: RefreshMaterialScope<'_>, + handles: &HashMap, + ) -> Result<(), Status> { + let storage_handles = handles + .iter() + .map(|(material_key, handle)| { + ( + refresh_material_storage_key(scope.credential_key, material_key), + handle.clone(), + ) + }) + .collect(); + self.delete_provider_credential_handles( + scope.provider_name, + scope.workspace, + scope.provider_id, + &storage_handles, + ) + .await + } + + pub async fn delete_refresh_material_deletions( + &self, + scope: RefreshMaterialScope<'_>, + deletions: &[StoredRefreshMaterialDeletion], + ) -> Result<(), Status> { + let futures = deletions.iter().map(|deletion| async move { + if deletion.material_key.is_empty() { + return Err(Status::failed_precondition( + "pending refresh-material deletion has no material key", + )); + } + let handle = deletion.handle.clone().ok_or_else(|| { + Status::failed_precondition( + "pending refresh-material deletion has no credential handle", + ) + })?; + let storage_key = + refresh_material_storage_key(scope.credential_key, &deletion.material_key); + self.delete_provider_credential_handle( + scope.provider_name, + scope.workspace, + scope.provider_id, + &storage_key, + handle, + ) + .await + }); + let results = futures::future::join_all(futures).await; + let mut first_error = None; + for result in results { + if let Err(err) = result + && first_error.is_none() + { + first_error = Some(err); + } + } + if let Some(err) = first_error { + return Err(err); + } + Ok(()) + } + + pub async fn delete_provider_credential_handles( + &self, + provider_name: &str, + workspace: &str, + provider_id: &str, + handles: &HashMap, + ) -> Result<(), Status> { + let futures = handles.iter().map(|(credential_key, handle)| { + self.delete_provider_credential_handle( + provider_name, + workspace, + provider_id, + credential_key, + handle.clone(), + ) + }); + let results = futures::future::join_all(futures).await; + let mut first_error: Option = None; + for result in results { + if let Err(err) = result + && first_error.is_none() + { + first_error = Some(err); + } + } + if let Some(err) = first_error { + return Err(err); + } + Ok(()) + } + + async fn delete_provider_credential_handle( + &self, + provider_name: &str, + workspace: &str, + provider_id: &str, + credential_key: &str, + handle: CredentialHandle, + ) -> Result<(), Status> { + let driver_name = self.registry.driver_for_handle(credential_key, &handle)?; + let driver = self.connected_driver(&driver_name)?; + driver + .delete_credential(DeleteCredentialRequest { + provider_name: provider_name.to_string(), + credential_key: credential_key.to_string(), + handle: Some(handle), + workspace: workspace.to_string(), + provider_id: provider_id.to_string(), + }) + .await + } + + pub async fn resolve_provider_handles( + &self, + provider: &Provider, + now_ms: i64, + ) -> Result { + self.registry.validate_provider_handles(provider)?; + if provider.credential_handles.is_empty() { + return Ok(ResolvedProviderCredentials::default()); + } + + let provider_name = provider + .metadata + .as_ref() + .map(|metadata| metadata.name.clone()) + .unwrap_or_default(); + let workspace = provider + .metadata + .as_ref() + .map(|metadata| metadata.workspace.clone()) + .unwrap_or_default(); + let provider_id = provider + .metadata + .as_ref() + .map(|metadata| metadata.id.clone()) + .unwrap_or_default(); + let mut request_keys = HashMap::new(); + let mut requests_by_driver: BTreeMap> = + BTreeMap::new(); + + for (credential_key, handle) in &provider.credential_handles { + let driver_name = self.registry.driver_for_handle(credential_key, handle)?; + let request_id = format!("credential-{}", request_keys.len()); + request_keys.insert(request_id.clone(), credential_key.clone()); + + let mut selected_handle = handle.clone(); + selected_handle.driver.clone_from(&driver_name); + requests_by_driver + .entry(driver_name) + .or_default() + .push(ResolveCredentialRequest { + request_id, + provider_name: provider_name.clone(), + credential_key: credential_key.clone(), + handle: Some(selected_handle), + workspace: workspace.clone(), + provider_id: provider_id.clone(), + }); + } + + let mut resolved = ResolvedProviderCredentials::default(); + let mut seen_responses = HashSet::new(); + + for (driver_name, requests) in requests_by_driver { + let expected_request_ids: HashSet<_> = requests + .iter() + .map(|request| request.request_id.clone()) + .collect(); + let driver = self.connected_driver(&driver_name)?; + + let responses = driver.resolve_credentials(requests).await?; + for response in responses { + if response.request_id.is_empty() { + return Err(Status::internal(format!( + "credential driver '{driver_name}' returned a response without request_id" + ))); + } + if !expected_request_ids.contains(&response.request_id) { + return Err(Status::internal(format!( + "credential driver '{driver_name}' returned unknown request_id '{}'", + response.request_id + ))); + } + if !seen_responses.insert(response.request_id.clone()) { + return Err(Status::internal(format!( + "credential driver '{driver_name}' returned duplicate request_id '{}'", + response.request_id + ))); + } + + let credential_key = request_keys + .get(&response.request_id) + .expect("validated response request_id") + .clone(); + + // Check provider-level expiration + let provider_expires_at_ms = provider + .credential_expires_at_ms + .get(&credential_key) + .copied() + .unwrap_or(0); + + // Compute effective expiration (earliest non-zero timestamp) + let effective_expires_at_ms = match (provider_expires_at_ms, response.expires_at_ms) + { + (0, driver) => driver, + (provider, 0) => provider, + (provider, driver) => provider.min(driver), + }; + + if effective_expires_at_ms > 0 && effective_expires_at_ms <= now_ms { + warn!( + provider_name = %provider_name, + credential_key = %credential_key, + provider_expires_at_ms, + driver_expires_at_ms = response.expires_at_ms, + effective_expires_at_ms, + "skipping expired handle-backed credential" + ); + continue; + } + if effective_expires_at_ms > 0 { + resolved + .expires_at_ms + .insert(credential_key.clone(), effective_expires_at_ms); + } + resolved.values.insert(credential_key, response.value); + } + + for request_id in expected_request_ids { + if !seen_responses.contains(&request_id) { + return Err(Status::internal(format!( + "credential driver '{driver_name}' did not return a response for request_id '{request_id}'" + ))); + } + } + } + + Ok(resolved) + } + + fn connected_driver(&self, driver_name: &str) -> Result<&Arc, Status> { + self.drivers.get(driver_name).ok_or_else(|| { + Status::failed_precondition(format!( + "credential driver '{driver_name}' is enabled but not connected" + )) + }) + } +} + +fn refresh_material_storage_key(credential_key: &str, material_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(REFRESH_MATERIAL_CREDENTIAL_KEY_DOMAIN); + hasher.update([0]); + hasher.update(credential_key.as_bytes()); + hasher.update([0]); + hasher.update(material_key.as_bytes()); + format!("openshell.refresh.{:x}", hasher.finalize()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BuiltinCredentialDriverKind { + KubernetesSecrets, + Vault, + #[cfg(any(test, feature = "test-support"))] + TestStatic, +} + +impl BuiltinCredentialDriverKind { + fn from_name(name: &str) -> Option { + match name { + KubernetesSecretsCredentialDriver::NAME => Some(Self::KubernetesSecrets), + VaultCredentialDriver::NAME => Some(Self::Vault), + #[cfg(any(test, feature = "test-support"))] + TestStaticCredentialDriver::NAME => Some(Self::TestStatic), + _ => None, + } + } +} + +#[cfg(any(test, feature = "test-support"))] +fn builtin_credential_driver_names() -> &'static [&'static str] { + &[ + KubernetesSecretsCredentialDriver::NAME, + VaultCredentialDriver::NAME, + TestStaticCredentialDriver::NAME, + ] +} + +#[cfg(not(any(test, feature = "test-support")))] +fn builtin_credential_driver_names() -> &'static [&'static str] { + &[ + KubernetesSecretsCredentialDriver::NAME, + VaultCredentialDriver::NAME, + ] +} + +fn unknown_credential_driver_error(driver_name: &str) -> Error { + Error::config(format!( + "credential driver '{driver_name}' is not a built-in credential driver and has no [openshell.credential_drivers.{driver_name}] table; configure an external driver with transport = 'uds' or choose one of: {}", + builtin_credential_driver_names().join(", ") + )) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialDriverRegistry { + enabled: BTreeSet, + default_driver: Option, +} + +impl CredentialDriverRegistry { + pub fn from_config(config: &Config) -> CoreResult { + let mut enabled = BTreeSet::new(); + for driver in &config.credential_drivers { + let driver = normalize_driver_name(driver); + if driver.is_empty() { + return Err(Error::config( + "credential_drivers entries must be non-empty strings", + )); + } + enabled.insert(driver); + } + + let default_driver = config + .default_credential_driver + .as_deref() + .map(normalize_driver_name) + .filter(|driver| !driver.is_empty()); + + if default_driver.is_some() && enabled.is_empty() { + return Err(Error::config( + "default_credential_driver requires credential_drivers to name an external credential driver", + )); + } + + if let Some(default_driver) = default_driver.as_deref() + && !enabled.contains(default_driver) + { + return Err(Error::config(format!( + "default_credential_driver '{default_driver}' is not listed in credential_drivers" + ))); + } + + if enabled.len() > 1 { + return Err(Error::config( + "credential_drivers supports at most one enabled credential driver", + )); + } + + Ok(Self { + enabled, + default_driver, + }) + } + + pub fn storage_owner_name(&self) -> String { + if self.enabled.is_empty() { + return DbCredstoreCredentialDriver::NAME.to_string(); + } + if let Some(default_driver) = self.default_driver.clone() { + return default_driver; + } + self.enabled + .iter() + .next() + .expect("enabled is non-empty") + .clone() + } + + fn requires_default_store(&self) -> bool { + self.enabled.is_empty() + } + + pub fn validate_provider_handles(&self, provider: &Provider) -> Result<(), Status> { + if provider.credential_handles.is_empty() { + return Ok(()); + } + for (credential_key, handle) in &provider.credential_handles { + self.driver_for_handle(credential_key, handle)?; + } + + Ok(()) + } + + fn enabled_driver_names(&self) -> impl Iterator { + self.enabled.iter() + } + + fn driver_for_handle( + &self, + credential_key: &str, + handle: &CredentialHandle, + ) -> Result { + let driver = normalize_driver_name(&handle.driver); + if driver.is_empty() { + return Err(Status::invalid_argument(format!( + "provider credential_handles['{credential_key}'] is missing driver" + ))); + } + if handle.handle.trim().is_empty() { + return Err(Status::invalid_argument(format!( + "provider credential_handles['{credential_key}'] is missing handle" + ))); + } + + if driver == DbCredstoreCredentialDriver::NAME { + return Ok(driver); + } + + if !self.enabled.contains(&driver) { + return Err(Status::invalid_argument(format!( + "provider credential_handles['{credential_key}'] references credential driver '{driver}' that is not enabled" + ))); + } + + Ok(driver) + } +} + +fn normalize_driver_name(driver: &str) -> String { + driver.trim().to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CredentialDriverTransport { + InTree, + Uds, +} + +#[derive(Debug, Clone, PartialEq)] +struct ConfiguredCredentialDriver { + transport: CredentialDriverTransport, + socket_path: Option, + command: Option, + args: Vec, + startup_timeout_secs: u64, + backend_config: toml::Table, +} + +fn parse_driver_table( + driver_name: &str, + value: &toml::Value, +) -> CoreResult { + let table = value.as_table().ok_or_else(|| { + Error::config(format!( + "[openshell.credential_drivers.{driver_name}] must be a TOML table" + )) + })?; + + let transport = table + .get("transport") + .map(|value| string_field(driver_name, "transport", value)) + .transpose()? + .unwrap_or_else(|| "in_tree".to_string()); + let transport = match transport.as_str() { + "in_tree" => CredentialDriverTransport::InTree, + "uds" => CredentialDriverTransport::Uds, + other => { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] transport must be 'in_tree' or 'uds', got '{other}'" + ))); + } + }; + + let socket_path = table + .get("socket_path") + .map(|value| string_field(driver_name, "socket_path", value)) + .transpose()? + .map(PathBuf::from); + let command = table + .get("command") + .map(|value| string_field(driver_name, "command", value)) + .transpose()? + .map(PathBuf::from); + let args = table + .get("args") + .map(|value| string_array_field(driver_name, "args", value)) + .transpose()? + .unwrap_or_default(); + let startup_timeout_secs = table + .get("startup_timeout_secs") + .map(|value| positive_integer_field(driver_name, "startup_timeout_secs", value)) + .transpose()? + .unwrap_or(DEFAULT_CREDENTIAL_DRIVER_STARTUP_TIMEOUT_SECS); + + if transport == CredentialDriverTransport::Uds { + let socket_path = socket_path.as_ref().ok_or_else(|| { + Error::config(format!( + "[openshell.credential_drivers.{driver_name}] socket_path is required when transport = 'uds'" + )) + })?; + if !socket_path.is_absolute() { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] socket_path must be absolute" + ))); + } + if let Some(command) = command.as_ref() + && !command.is_absolute() + { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] command must be absolute" + ))); + } + if command.is_none() && !args.is_empty() { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] args requires command" + ))); + } + if command.is_none() && table.contains_key("startup_timeout_secs") { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] startup_timeout_secs requires command" + ))); + } + } else if command.is_some() || !args.is_empty() || table.contains_key("startup_timeout_secs") { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] command, args, and startup_timeout_secs require transport = 'uds'" + ))); + } + + Ok(ConfiguredCredentialDriver { + transport, + socket_path, + command, + args, + startup_timeout_secs, + backend_config: backend_config_table(table), + }) +} + +fn backend_config_table(table: &toml::Table) -> toml::Table { + let mut backend_config = table.clone(); + for field in COMMON_CREDENTIAL_DRIVER_FIELDS { + backend_config.remove(*field); + } + backend_config +} + +fn string_field( + driver_name: &str, + field_name: &'static str, + value: &toml::Value, +) -> CoreResult { + let value = value.as_str().ok_or_else(|| { + Error::config(format!( + "[openshell.credential_drivers.{driver_name}] {field_name} must be a string" + )) + })?; + let value = value.trim(); + if value.is_empty() { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] {field_name} must not be empty" + ))); + } + Ok(value.to_string()) +} + +fn string_array_field( + driver_name: &str, + field_name: &'static str, + value: &toml::Value, +) -> CoreResult> { + let values = value.as_array().ok_or_else(|| { + Error::config(format!( + "[openshell.credential_drivers.{driver_name}] {field_name} must be an array of strings" + )) + })?; + + values + .iter() + .map(|value| string_field(driver_name, field_name, value)) + .collect() +} + +fn positive_integer_field( + driver_name: &str, + field_name: &'static str, + value: &toml::Value, +) -> CoreResult { + let value = value.as_integer().ok_or_else(|| { + Error::config(format!( + "[openshell.credential_drivers.{driver_name}] {field_name} must be a positive integer" + )) + })?; + if value <= 0 { + return Err(Error::config(format!( + "[openshell.credential_drivers.{driver_name}] {field_name} must be a positive integer" + ))); + } + u64::try_from(value).map_err(|_| { + Error::config(format!( + "[openshell.credential_drivers.{driver_name}] {field_name} is too large" + )) + }) +} + +fn connect_default_credential_store( + drivers: &mut BTreeMap>, + store: Option>, + config: &toml::Table, + required: bool, +) -> CoreResult<()> { + if !required && config.is_empty() { + return Ok(()); + } + + let Some(store) = store else { + if required { + return Err(Error::config( + "default encrypted credential storage requires the gateway object store", + )); + } + return Ok(()); + }; + + let object_store: Arc = + Arc::new(ServerDbCredstoreObjectStore::new(store)); + let storage: Arc = Arc::new(DbCredstoreCredentialDriver::from_config( + object_store, + config, + )?); + drivers.insert(DbCredstoreCredentialDriver::NAME.to_string(), storage); + Ok(()) +} + +#[derive(Debug)] +struct BuiltCredentialDriver { + driver: Arc, + process: Option>, +} + +async fn build_configured_driver( + driver_name: &str, + config: ConfiguredCredentialDriver, + store: Option>, +) -> CoreResult { + match config.transport { + CredentialDriverTransport::InTree => { + let driver = build_in_tree_driver(driver_name, Some(&config.backend_config), store) + .await? + .ok_or_else(|| { + Error::config(format!( + "credential driver '{driver_name}' is configured with transport = 'in_tree', but no in-tree implementation is available" + )) + })?; + Ok(BuiltCredentialDriver { + driver, + process: None, + }) + } + CredentialDriverTransport::Uds => { + let socket_path = config + .socket_path + .clone() + .expect("UDS transport requires socket_path during parsing"); + connect_uds_driver(driver_name, config, &socket_path).await + } + } +} + +async fn build_default_in_tree_driver( + driver_name: &str, + store: Option>, +) -> CoreResult> { + build_in_tree_driver(driver_name, None, store) + .await? + .ok_or_else(|| unknown_credential_driver_error(driver_name)) +} + +async fn build_in_tree_driver( + name: &str, + backend_config: Option<&toml::Table>, + _store: Option>, +) -> CoreResult>> { + let Some(kind) = BuiltinCredentialDriverKind::from_name(name) else { + return Ok(None); + }; + + let empty_config = toml::Table::new(); + let backend_config = backend_config.unwrap_or(&empty_config); + let driver: Arc = match kind { + BuiltinCredentialDriverKind::KubernetesSecrets => { + Arc::new(KubernetesSecretsCredentialDriver::from_config(backend_config).await?) + } + BuiltinCredentialDriverKind::Vault => { + Arc::new(VaultCredentialDriver::from_config(backend_config)?) + } + #[cfg(any(test, feature = "test-support"))] + BuiltinCredentialDriverKind::TestStatic => Arc::new(TestStaticCredentialDriver::new()), + }; + Ok(Some(driver)) +} + +fn build_sync_builtin_driver( + name: &str, + _store: Option>, +) -> Option> { + #[cfg(any(test, feature = "test-support"))] + if BuiltinCredentialDriverKind::from_name(name) == Some(BuiltinCredentialDriverKind::TestStatic) + { + let driver: Arc = Arc::new(TestStaticCredentialDriver::new()); + return Some(driver); + } + + let _ = name; + None +} + +#[derive(Debug, Clone)] +struct ServerDbCredstoreObjectStore { + store: Arc, +} + +impl ServerDbCredstoreObjectStore { + fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl DbCredstoreObjectStore for ServerDbCredstoreObjectStore { + async fn get_credential_object( + &self, + object_type: &str, + id: &str, + operation: &'static str, + ) -> Result, Status> { + self.store + .get(object_type, id) + .await + .map(|record| { + record.map(|record| StoredCredentialObject { + object_type: record.object_type, + id: record.id, + payload: record.payload, + resource_version: record.resource_version, + }) + }) + .map_err(|err| default_credential_store_persistence_error_to_status(err, operation)) + } + + async fn put_credential_object( + &self, + write: CredentialObjectWrite, + operation: &'static str, + ) -> Result<(), Status> { + let condition = match write.condition { + DbCredstoreWriteCondition::MustCreate => WriteCondition::MustCreate, + DbCredstoreWriteCondition::MatchResourceVersion(resource_version) => { + WriteCondition::MatchResourceVersion(resource_version) + } + }; + self.store + .put_if( + &write.object_type, + &write.id, + &write.name, + "", + &write.payload, + write.labels.as_deref(), + condition, + ) + .await + .map(|_| ()) + .map_err(|err| default_credential_store_persistence_error_to_status(err, operation)) + } + + async fn delete_credential_object( + &self, + object_type: &str, + id: &str, + expected_resource_version: u64, + operation: &'static str, + ) -> Result<(), Status> { + self.store + .delete_if(object_type, id, expected_resource_version) + .await + .map(|_| ()) + .map_err(|err| default_credential_store_persistence_error_to_status(err, operation)) + } +} + +fn default_credential_store_persistence_error_to_status( + err: PersistenceError, + operation: &str, +) -> Status { + match err { + PersistenceError::UniqueViolation { .. } => { + Status::already_exists(format!("default credential already exists: {err}")) + } + PersistenceError::Conflict { + current_resource_version, + } => Status::aborted(format!( + "default credential was modified concurrently during {operation} (current resource_version: {})", + current_resource_version.unwrap_or(0) + )), + PersistenceError::Decode(err) => Status::data_loss(format!( + "default credential decode failed during {operation}: {err}" + )), + PersistenceError::Encode(err) => Status::internal(format!( + "default credential encode failed during {operation}: {err}" + )), + other => Status::unavailable(format!("default credential {operation} failed: {other}")), + } +} + +#[async_trait] +impl CredentialDriver for KubernetesSecretsCredentialDriver { + async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + Self::store_credential(self, request).await + } + + async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + Self::delete_credential(self, request).await + } + + async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + Self::resolve_credentials(self, requests).await + } +} + +#[async_trait] +impl CredentialDriver for DbCredstoreCredentialDriver { + async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + Self::store_credential(self, request).await + } + + async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + Self::delete_credential(self, request).await + } + + async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + Self::resolve_credentials(self, requests).await + } +} + +#[async_trait] +impl CredentialDriver for VaultCredentialDriver { + async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + Self::store_credential(self, request).await + } + + async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + Self::delete_credential(self, request).await + } + + async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + Self::resolve_credentials(self, requests).await + } +} + +#[derive(Debug, Clone)] +#[cfg(unix)] +struct RemoteCredentialDriver { + channel: Channel, +} + +#[cfg(unix)] +impl RemoteCredentialDriver { + fn new(channel: Channel) -> Self { + Self { channel } + } + + fn client(&self) -> CredentialDriverClient { + CredentialDriverClient::new(self.channel.clone()) + } +} + +#[cfg(unix)] +#[async_trait] +impl CredentialDriver for RemoteCredentialDriver { + async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + let mut client = self.client(); + let mut grpc_request = Request::new(request); + grpc_request.set_timeout(Duration::from_secs( + DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS, + )); + + let timeout_duration = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS); + let response = + tokio::time::timeout(timeout_duration, client.store_credential(grpc_request)) + .await + .map_err(|_| { + Status::deadline_exceeded("credential driver StoreCredential timed out") + })??; + + response + .into_inner() + .handle + .ok_or_else(|| Status::internal("credential driver returned no stored handle")) + } + + async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + let mut client = self.client(); + let mut grpc_request = Request::new(request); + grpc_request.set_timeout(Duration::from_secs( + DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS, + )); + + let timeout_duration = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS); + tokio::time::timeout(timeout_duration, client.delete_credential(grpc_request)) + .await + .map_err(|_| { + Status::deadline_exceeded("credential driver DeleteCredential timed out") + })??; + Ok(()) + } + + async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + let mut client = self.client(); + let mut grpc_request = Request::new(ResolveCredentialsRequest { + credentials: requests, + }); + grpc_request.set_timeout(Duration::from_secs( + DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS, + )); + + let timeout_duration = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS); + let response = + tokio::time::timeout(timeout_duration, client.resolve_credentials(grpc_request)) + .await + .map_err(|_| { + Status::deadline_exceeded("credential driver ResolveCredentials timed out") + })??; + Ok(response.into_inner().credentials) + } +} + +#[derive(Debug)] +struct ManagedCredentialDriverProcess { + child: std::sync::Mutex>, + socket_path: PathBuf, +} + +#[cfg(unix)] +impl ManagedCredentialDriverProcess { + fn new(child: tokio::process::Child, socket_path: PathBuf) -> Self { + Self { + child: std::sync::Mutex::new(Some(child)), + socket_path, + } + } +} + +impl Drop for ManagedCredentialDriverProcess { + fn drop(&mut self) { + if let Ok(mut child) = self.child.lock() { + let _ = child.take(); + } + let _ = std::fs::remove_file(&self.socket_path); + } +} + +#[cfg(unix)] +async fn connect_uds_driver( + driver_name: &str, + config: ConfiguredCredentialDriver, + socket_path: &Path, +) -> CoreResult { + if config.command.is_some() { + spawn_uds_driver(driver_name, config, socket_path).await + } else { + let channel = connect_ready_credential_driver(driver_name, socket_path).await?; + Ok(BuiltCredentialDriver { + driver: Arc::new(RemoteCredentialDriver::new(channel)), + process: None, + }) + } +} + +#[cfg(not(unix))] +async fn connect_uds_driver( + driver_name: &str, + _config: ConfiguredCredentialDriver, + _socket_path: &Path, +) -> CoreResult { + Err(Error::config(format!( + "credential driver '{driver_name}' uses transport = 'uds', but this platform does not support Unix domain sockets" + ))) +} + +#[cfg(unix)] +async fn spawn_uds_driver( + driver_name: &str, + config: ConfiguredCredentialDriver, + socket_path: &Path, +) -> CoreResult { + let command_path = config + .command + .expect("UDS command exists when spawning credential driver"); + let parent = socket_path.parent().ok_or_else(|| { + Error::execution(format!( + "credential driver '{driver_name}' socket path '{}' has no parent directory", + socket_path.display() + )) + })?; + std::fs::create_dir_all(parent).map_err(|err| { + Error::execution(format!( + "failed to create credential driver '{driver_name}' socket dir '{}': {err}", + parent.display() + )) + })?; + remove_stale_launched_driver_socket(driver_name, socket_path)?; + + let mut command = Command::new(&command_path); + command.kill_on_drop(true); + command.stdin(Stdio::null()); + command.stdout(Stdio::inherit()); + command.stderr(Stdio::inherit()); + command.args(&config.args); + command.arg("--bind-socket").arg(socket_path); + + let mut child = command.spawn().map_err(|err| { + Error::execution(format!( + "failed to launch credential driver '{driver_name}' '{}': {err}", + command_path.display() + )) + })?; + let channel = wait_for_launched_credential_driver( + driver_name, + socket_path, + &mut child, + Duration::from_secs(config.startup_timeout_secs), + ) + .await?; + let process = Arc::new(ManagedCredentialDriverProcess::new( + child, + socket_path.to_path_buf(), + )); + Ok(BuiltCredentialDriver { + driver: Arc::new(RemoteCredentialDriver::new(channel)), + process: Some(process), + }) +} + +#[cfg(unix)] +fn remove_stale_launched_driver_socket(driver_name: &str, socket_path: &Path) -> CoreResult<()> { + let metadata = match std::fs::symlink_metadata(socket_path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(Error::execution(format!( + "failed to stat credential driver '{driver_name}' socket '{}': {err}", + socket_path.display() + ))); + } + }; + let file_type = metadata.file_type(); + if file_type.is_symlink() { + return Err(Error::execution(format!( + "credential driver '{driver_name}' socket '{}' is a symlink; refusing to remove it", + socket_path.display() + ))); + } + if !file_type.is_socket() { + return Err(Error::execution(format!( + "credential driver '{driver_name}' socket path '{}' exists but is not a Unix socket", + socket_path.display() + ))); + } + let expected_uid = rustix::process::geteuid().as_raw(); + if metadata.uid() != expected_uid { + return Err(Error::execution(format!( + "credential driver '{driver_name}' socket '{}' is owned by uid {} but current euid is {}", + socket_path.display(), + metadata.uid(), + expected_uid + ))); + } + std::fs::remove_file(socket_path).map_err(|err| { + Error::execution(format!( + "failed to remove stale credential driver '{driver_name}' socket '{}': {err}", + socket_path.display() + )) + }) +} + +#[cfg(unix)] +async fn wait_for_launched_credential_driver( + driver_name: &str, + socket_path: &Path, + child: &mut tokio::process::Child, + timeout: Duration, +) -> CoreResult { + let deadline = Instant::now() + timeout; + let mut last_error: Option = None; + + loop { + let try_wait_result = child.try_wait().map_err(|err| { + Error::execution(format!( + "failed to poll credential driver '{driver_name}' process: {err}" + )) + })?; + if let Some(status) = try_wait_result { + return Err(Error::execution(format!( + "credential driver '{driver_name}' exited before becoming ready with status {status}" + ))); + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(Error::execution(format!( + "timed out waiting for credential driver '{driver_name}' socket '{}': {}", + socket_path.display(), + last_error.unwrap_or_else(|| "unknown error".to_string()) + ))); + } + + match tokio::time::timeout( + remaining, + connect_ready_credential_driver(driver_name, socket_path), + ) + .await + { + Ok(Ok(channel)) => return Ok(channel), + Ok(Err(err)) => last_error = Some(err.to_string()), + Err(_) => { + return Err(Error::execution(format!( + "timed out waiting for credential driver '{driver_name}' to respond to GetCapabilities" + ))); + } + } + + if Instant::now() >= deadline { + return Err(Error::execution(format!( + "timed out waiting for credential driver '{driver_name}' socket '{}': {}", + socket_path.display(), + last_error.unwrap_or_else(|| "unknown error".to_string()) + ))); + } + + tokio::time::sleep(CREDENTIAL_DRIVER_CONNECT_INTERVAL).await; + } +} + +#[cfg(unix)] +async fn connect_ready_credential_driver( + driver_name: &str, + socket_path: &Path, +) -> CoreResult { + let channel = connect_credential_driver_socket(driver_name, socket_path).await?; + let mut client = CredentialDriverClient::new(channel.clone()); + let mut request = Request::new(GetCredentialDriverCapabilitiesRequest {}); + let timeout = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS); + request.set_timeout(timeout); + await_credential_driver_capabilities(driver_name, timeout, client.get_capabilities(request)) + .await?; + Ok(channel) +} + +#[cfg(unix)] +async fn await_credential_driver_capabilities( + driver_name: &str, + timeout: Duration, + response: impl Future< + Output = Result, Status>, + >, +) -> CoreResult<()> { + tokio::time::timeout(timeout, response) + .await + .map_err(|_| { + Error::config(format!( + "credential driver '{driver_name}' GetCapabilities timed out" + )) + })? + .map_err(|status| { + Error::config(format!( + "credential driver '{driver_name}' GetCapabilities failed: {status}" + )) + })?; + Ok(()) +} + +#[cfg(unix)] +async fn connect_credential_driver_socket( + driver_name: &str, + socket_path: &Path, +) -> CoreResult { + let socket_path = socket_path.to_path_buf(); + let display_path = socket_path.clone(); + Endpoint::from_static("http://[::]:50051") + .connect_with_connector(service_fn(move |_: tonic::transport::Uri| { + let socket_path = socket_path.clone(); + async move { UnixStream::connect(socket_path).await.map(TokioIo::new) } + })) + .await + .map_err(|err| { + Error::transport(format!( + "failed to connect to credential driver '{driver_name}' socket '{}': {err}", + display_path.display() + )) + }) +} + +#[cfg(any(test, feature = "test-support"))] +#[derive(Debug)] +struct TestStaticCredentialDriver { + values: std::sync::Mutex>, + fail_next_store: std::sync::atomic::AtomicBool, + fail_next_delete: std::sync::atomic::AtomicBool, + #[cfg(test)] + store_gate: std::sync::Mutex< + Option<( + tokio::sync::oneshot::Sender<()>, + tokio::sync::oneshot::Receiver<()>, + )>, + >, +} + +#[cfg(any(test, feature = "test-support"))] +impl TestStaticCredentialDriver { + const NAME: &'static str = "test-static"; + + fn new() -> Self { + Self { + values: std::sync::Mutex::new(HashMap::new()), + fail_next_store: std::sync::atomic::AtomicBool::new(false), + fail_next_delete: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + store_gate: std::sync::Mutex::new(None), + } + } + + fn handle_from_request( + request_id: &str, + handle: Option, + ) -> Result { + handle.ok_or_else(|| { + Status::invalid_argument(format!( + "test-static credential request '{request_id}' is missing handle" + )) + }) + } +} + +#[cfg(any(test, feature = "test-support"))] +#[async_trait] +impl CredentialDriver for TestStaticCredentialDriver { + async fn store_credential( + &self, + request: StoreCredentialRequest, + ) -> Result { + #[cfg(test)] + let gate = self.store_gate.lock().ok().and_then(|mut gate| gate.take()); + #[cfg(test)] + if let Some((hit, release)) = gate { + let _ = hit.send(()); + let _ = release.await; + } + if self + .fail_next_store + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(Status::unavailable("injected credential store failure")); + } + let handle = request + .existing_handle + .map(|handle| handle.handle) + .filter(|handle| !handle.trim().is_empty()) + .unwrap_or_else(|| { + format!( + "{}:{}:{}", + request.provider_name, request.credential_key, request.object_id + ) + }); + self.values + .lock() + .map_err(|_| Status::internal("test-static credential store lock poisoned"))? + .insert(handle.clone(), request.value); + Ok(CredentialHandle { + driver: Self::NAME.to_string(), + handle, + metadata: HashMap::new(), + }) + } + + async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { + if self + .fail_next_delete + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(Status::unavailable("injected credential delete failure")); + } + let handle = Self::handle_from_request("delete", request.handle)?; + self.values + .lock() + .map_err(|_| Status::internal("test-static credential store lock poisoned"))? + .remove(&handle.handle); + Ok(()) + } + + async fn resolve_credentials( + &self, + requests: Vec, + ) -> Result, Status> { + let mut responses = Vec::with_capacity(requests.len()); + for request in requests { + let handle = Self::handle_from_request(&request.request_id, request.handle)?; + let value = self + .values + .lock() + .map_err(|_| Status::internal("test-static credential store lock poisoned"))? + .get(&handle.handle) + .cloned() + .ok_or_else(|| Status::not_found("test-static credential handle not found"))?; + responses.push(ResolvedCredential { + request_id: request.request_id, + value, + expires_at_ms: 0, + }); + } + + Ok(responses) + } + + #[cfg(test)] + fn stored_credential_count(&self) -> Option { + self.values.lock().ok().map(|values| values.len()) + } + + #[cfg(test)] + fn fail_next_store(&self) { + self.fail_next_store + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[cfg(test)] + fn fail_next_delete(&self) { + self.fail_next_delete + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[cfg(test)] + fn gate_next_store( + &self, + ) -> Option<( + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender<()>, + )> { + let (hit_tx, hit_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *self.store_gate.lock().ok()? = Some((hit_tx, release_rx)); + Some((hit_rx, release_tx)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use openshell_core::proto::{CredentialHandle, Provider}; + use tonic::Code; + + use super::*; + + fn provider_with_handle(driver: &str, handle: &str) -> Provider { + Provider { + metadata: Some(openshell_core::proto::ObjectMeta { + name: "openai-local".to_string(), + ..Default::default() + }), + credential_handles: HashMap::from([( + "OPENAI_API_KEY".to_string(), + CredentialHandle { + driver: driver.to_string(), + handle: handle.to_string(), + metadata: HashMap::new(), + }, + )]), + ..Default::default() + } + } + + fn config_file(toml: &str) -> crate::config_file::ConfigFile { + toml::from_str(toml).expect("config file TOML") + } + + fn driver_table(toml: &str) -> toml::Value { + toml::from_str(toml).expect("driver table TOML") + } + + fn test_absolute_path(file_name: &str) -> PathBuf { + std::env::temp_dir().join(file_name) + } + + fn toml_path(path: &Path) -> String { + toml::Value::String(path.display().to_string()).to_string() + } + + #[test] + fn builtin_credential_driver_kind_resolves_known_names() { + assert_eq!( + BuiltinCredentialDriverKind::from_name("kubernetes-secrets"), + Some(BuiltinCredentialDriverKind::KubernetesSecrets) + ); + assert_eq!( + BuiltinCredentialDriverKind::from_name("openshell-gateway"), + None + ); + assert_eq!( + BuiltinCredentialDriverKind::from_name("vault"), + Some(BuiltinCredentialDriverKind::Vault) + ); + assert_eq!( + BuiltinCredentialDriverKind::from_name("enterprise-secrets"), + None + ); + } + + #[test] + fn registry_defaults_to_internal_credential_storage() { + let registry = CredentialDriverRegistry::from_config(&Config::new(None)).unwrap(); + + assert_eq!( + registry.storage_owner_name().as_str(), + DbCredstoreCredentialDriver::NAME + ); + } + + #[test] + fn registry_allows_legacy_inline_credentials_with_default_driver() { + let registry = CredentialDriverRegistry::from_config(&Config::new(None)).unwrap(); + + registry + .validate_provider_handles(&Provider::default()) + .expect("legacy inline provider should not require credential handles"); + } + + #[test] + fn registry_rejects_default_driver_without_external_driver() { + let config = Config::new(None).with_default_credential_driver(Some("vault")); + + let err = CredentialDriverRegistry::from_config(&config).unwrap_err(); + + assert!(err.to_string().contains("default_credential_driver")); + assert!(err.to_string().contains("requires credential_drivers")); + } + + #[test] + fn registry_rejects_empty_handle_driver() { + let config = Config::new(None).with_credential_drivers(["test-static"]); + let registry = CredentialDriverRegistry::from_config(&config).unwrap(); + + let err = registry + .validate_provider_handles(&provider_with_handle("", "openai/API_KEY")) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("missing driver")); + } + + #[test] + fn registry_rejects_empty_handle_value() { + let config = Config::new(None).with_credential_drivers(["test-static"]); + let registry = CredentialDriverRegistry::from_config(&config).unwrap(); + + let err = registry + .validate_provider_handles(&provider_with_handle("test-static", "")) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("missing handle")); + } + + #[test] + fn registry_rejects_unknown_handle_driver() { + let config = Config::new(None).with_credential_drivers(["test-static"]); + let registry = CredentialDriverRegistry::from_config(&config).unwrap(); + + let err = registry + .validate_provider_handles(&provider_with_handle("vault", "openai/API_KEY")) + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("not enabled")); + } + + #[test] + fn registry_rejects_default_driver_not_enabled() { + let config = Config::new(None) + .with_credential_drivers(["test-static"]) + .with_default_credential_driver(Some("vault")); + + let err = CredentialDriverRegistry::from_config(&config).unwrap_err(); + + assert!(err.to_string().contains("default_credential_driver")); + assert!(err.to_string().contains("not listed")); + } + + #[test] + fn registry_rejects_multiple_enabled_drivers() { + let config = Config::new(None).with_credential_drivers(["test-static", "vault"]); + + let err = CredentialDriverRegistry::from_config(&config).unwrap_err(); + + assert!(err.to_string().contains("at most one")); + } + + #[tokio::test] + async fn runtime_stores_and_resolves_test_static_handles() { + let config = Config::new(None) + .with_credential_drivers(["test-static"]) + .with_default_credential_driver(Some("test-static")); + let runtime = CredentialRuntime::from_config(&config).unwrap(); + let stored = runtime + .store_provider_credentials( + "openai-local", + "test-workspace", + "test-provider-id", + &HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + let mut provider = Provider { + metadata: Some(openshell_core::proto::ObjectMeta { + name: "openai-local".to_string(), + ..Default::default() + }), + ..Default::default() + }; + provider.credential_handles = stored; + + let resolved = runtime + .resolve_provider_handles(&provider, 1_000) + .await + .unwrap(); + + assert_eq!( + resolved.values.get("OPENAI_API_KEY").map(String::as_str), + Some("sk-test") + ); + } + + #[tokio::test] + async fn runtime_overwrites_existing_test_static_handle() { + let config = Config::new(None).with_credential_drivers(["test-static"]); + let runtime = CredentialRuntime::from_config(&config).unwrap(); + let first = runtime + .store_provider_credentials( + "openai-local", + "test-workspace", + "test-provider-id", + &HashMap::from([("OPENAI_API_KEY".to_string(), "sk-first".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + let second = runtime + .store_provider_credentials( + "openai-local", + "test-workspace", + "test-provider-id", + &HashMap::from([("OPENAI_API_KEY".to_string(), "sk-second".to_string())]), + &first, + ) + .await + .unwrap(); + assert_eq!( + first.get("OPENAI_API_KEY").unwrap().handle, + second.get("OPENAI_API_KEY").unwrap().handle + ); + + let provider = Provider { + metadata: Some(openshell_core::proto::ObjectMeta { + name: "openai-local".to_string(), + ..Default::default() + }), + credential_handles: second, + ..Default::default() + }; + + let resolved = runtime + .resolve_provider_handles(&provider, 1_000) + .await + .unwrap(); + + assert_eq!( + resolved.values.get("OPENAI_API_KEY").map(String::as_str), + Some("sk-second") + ); + } + + #[tokio::test] + async fn runtime_deletes_stored_test_static_handle() { + let config = Config::new(None).with_credential_drivers(["test-static"]); + let runtime = CredentialRuntime::from_config(&config).unwrap(); + let stored = runtime + .store_provider_credentials( + "openai-local", + "test-workspace", + "test-provider-id", + &HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + + runtime + .delete_provider_credential_handles( + "openai-local", + "test-workspace", + "test-provider-id", + &stored, + ) + .await + .unwrap(); + + let provider = Provider { + metadata: Some(openshell_core::proto::ObjectMeta { + name: "openai-local".to_string(), + ..Default::default() + }), + credential_handles: stored, + ..Default::default() + }; + let err = runtime + .resolve_provider_handles(&provider, 1_000) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::NotFound); + } + + #[tokio::test] + async fn runtime_uses_configured_in_tree_driver_table() { + let config = Config::new(None).with_credential_drivers(["test-static"]); + let file = config_file( + r#" +[openshell.credential_drivers.test-static] +transport = "in_tree" +backend_specific = "ignored-by-gateway" +"#, + ); + let runtime = CredentialRuntime::from_config_file(&config, Some(&file)) + .await + .unwrap(); + + let stored = runtime + .store_provider_credentials( + "openai-local", + "test-workspace", + "test-provider-id", + &HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + + assert_eq!( + stored + .get("OPENAI_API_KEY") + .map(|handle| handle.driver.as_str()), + Some("test-static") + ); + } + + #[tokio::test] + async fn runtime_uses_configured_vault_in_tree_driver_table() { + let token_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(token_file.path(), "dev-token").unwrap(); + let config = Config::new(None).with_credential_drivers(["vault"]); + let token_path = toml_path(token_file.path()); + let file = config_file(&format!( + r#" +[openshell.credential_drivers.vault] +transport = "in_tree" +address = "http://127.0.0.1:8200" +auth_method = "token_file" +token_path = {token_path} +"#, + )); + let runtime = CredentialRuntime::from_config_file(&config, Some(&file)) + .await + .unwrap(); + + assert!(runtime.stores_provider_credentials()); + } + + #[tokio::test] + async fn runtime_uses_configured_default_credential_storage() { + let storage = tempfile::tempdir().unwrap(); + let key_encryption_key_path = storage.path().join("key-encryption-key.bin"); + let store = Arc::new(crate::persistence::test_store().await); + let config = Config::new(None); + let key_encryption_key_path_toml = toml_path(&key_encryption_key_path); + let file = config_file(&format!( + r" +[openshell.gateway.credential_storage] +key_encryption_key_path = {key_encryption_key_path_toml} +", + )); + let runtime = CredentialRuntime::from_config_file_with_store( + &config, + Some(&file), + Arc::clone(&store), + ) + .await + .unwrap(); + + let stored = runtime + .store_provider_credentials( + "openai-local", + "test-workspace", + "test-provider-id", + &HashMap::from([("OPENAI_API_KEY".to_string(), "sk-test".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + + let handle_id = stored + .get("OPENAI_API_KEY") + .and_then(|handle| handle.handle.strip_prefix("v1:")) + .expect("stored db credstore handle id"); + let credential_record = store + .get(DbCredstoreCredentialDriver::OBJECT_TYPE, handle_id) + .await + .unwrap() + .expect("encrypted credential object"); + assert!( + !String::from_utf8_lossy(&credential_record.payload).contains("sk-test"), + "credential object payload must not contain plaintext credentials" + ); + + let provider = Provider { + metadata: Some(openshell_core::proto::ObjectMeta { + name: "openai-local".to_string(), + ..Default::default() + }), + credential_handles: stored, + ..Default::default() + }; + + let resolved = runtime + .resolve_provider_handles(&provider, 1_000) + .await + .unwrap(); + + assert_eq!( + resolved.values.get("OPENAI_API_KEY").map(String::as_str), + Some("sk-test") + ); + } + + #[tokio::test] + async fn runtime_rejects_in_tree_table_without_builtin_driver() { + let config = Config::new(None).with_credential_drivers(["enterprise-secrets"]); + let file = config_file( + r#" +[openshell.credential_drivers.enterprise-secrets] +transport = "in_tree" +"#, + ); + + let err = CredentialRuntime::from_config_file(&config, Some(&file)) + .await + .unwrap_err(); + + assert!(err.to_string().contains("no in-tree implementation")); + } + + #[tokio::test] + async fn runtime_rejects_unknown_driver_without_driver_table() { + let config = Config::new(None).with_credential_drivers(["enterprise-secrets"]); + + let err = CredentialRuntime::from_config_file(&config, None) + .await + .unwrap_err(); + + assert!(err.to_string().contains("not a built-in credential driver")); + assert!(err.to_string().contains("transport = 'uds'")); + } + + #[test] + fn runtime_from_config_rejects_unknown_driver_name() { + let config = Config::new(None).with_credential_drivers(["enterprise-secrets"]); + + let err = CredentialRuntime::from_config(&config).unwrap_err(); + + assert!(err.to_string().contains("not a built-in credential driver")); + } + + #[tokio::test] + async fn runtime_rejects_uds_table_without_socket_path() { + let config = Config::new(None).with_credential_drivers(["vault"]); + let file = config_file( + r#" +[openshell.credential_drivers.vault] +transport = "uds" +"#, + ); + + let err = CredentialRuntime::from_config_file(&config, Some(&file)) + .await + .unwrap_err(); + + assert!(err.to_string().contains("socket_path is required")); + } + + #[tokio::test] + async fn runtime_rejects_relative_uds_socket_path() { + let config = Config::new(None).with_credential_drivers(["vault"]); + let file = config_file( + r#" +[openshell.credential_drivers.vault] +transport = "uds" +socket_path = "vault.sock" +"#, + ); + + let err = CredentialRuntime::from_config_file(&config, Some(&file)) + .await + .unwrap_err(); + + assert!(err.to_string().contains("socket_path must be absolute")); + } + + #[tokio::test] + async fn runtime_rejects_unknown_transport() { + let config = Config::new(None).with_credential_drivers(["vault"]); + let file = config_file( + r#" +[openshell.credential_drivers.vault] +transport = "tcp" +"#, + ); + + let err = CredentialRuntime::from_config_file(&config, Some(&file)) + .await + .unwrap_err(); + + assert!(err.to_string().contains("transport must be")); + } + + #[test] + fn parse_uds_driver_launch_settings() { + let socket_path = test_absolute_path("openshell-enterprise-secrets.sock"); + let command = test_absolute_path("openshell-credential-driver-enterprise-secrets"); + let socket_path_toml = toml_path(&socket_path); + let command_toml = toml_path(&command); + let parsed = parse_driver_table( + "enterprise-secrets", + &driver_table(&format!( + r#" +transport = "uds" +socket_path = {socket_path_toml} +command = {command_toml} +args = ["--profile", "dev"] +startup_timeout_secs = 3 +"#, + )), + ) + .unwrap(); + + assert_eq!(parsed.transport, CredentialDriverTransport::Uds); + assert_eq!(parsed.socket_path.as_deref(), Some(socket_path.as_path())); + assert_eq!(parsed.command.as_deref(), Some(command.as_path())); + assert_eq!(parsed.args, ["--profile", "dev"]); + assert_eq!(parsed.startup_timeout_secs, 3); + } + + #[test] + fn parse_uds_driver_defaults_to_connect_only() { + let socket_path = test_absolute_path("openshell-enterprise-secrets.sock"); + let socket_path_toml = toml_path(&socket_path); + let parsed = parse_driver_table( + "enterprise-secrets", + &driver_table(&format!( + r#" +transport = "uds" +socket_path = {socket_path_toml} +"#, + )), + ) + .unwrap(); + + assert_eq!(parsed.transport, CredentialDriverTransport::Uds); + assert!(parsed.command.is_none()); + assert!(parsed.args.is_empty()); + assert_eq!( + parsed.startup_timeout_secs, + DEFAULT_CREDENTIAL_DRIVER_STARTUP_TIMEOUT_SECS + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn get_capabilities_has_a_local_timeout() { + let response = std::future::pending::< + Result, Status>, + >(); + + let err = await_credential_driver_capabilities( + "enterprise-secrets", + Duration::from_millis(10), + response, + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("GetCapabilities timed out")); + } + + #[test] + fn parse_driver_table_preserves_backend_config_without_transport_fields() { + let parsed = parse_driver_table( + "kubernetes-secrets", + &driver_table( + r#" +transport = "in_tree" +namespace = "openshell" +allow_reference_namespace = true +"#, + ), + ) + .unwrap(); + + assert_eq!( + parsed + .backend_config + .get("namespace") + .and_then(toml::Value::as_str), + Some("openshell") + ); + assert_eq!( + parsed + .backend_config + .get("allow_reference_namespace") + .and_then(toml::Value::as_bool), + Some(true) + ); + assert!(!parsed.backend_config.contains_key("transport")); + } + + #[test] + fn parse_uds_driver_rejects_relative_command() { + let socket_path_toml = toml_path(&test_absolute_path("openshell-enterprise-secrets.sock")); + let err = parse_driver_table( + "enterprise-secrets", + &driver_table(&format!( + r#" +transport = "uds" +socket_path = {socket_path_toml} +command = "openshell-credential-driver-enterprise-secrets" +"#, + )), + ) + .unwrap_err(); + + assert!(err.to_string().contains("command must be absolute")); + } + + #[test] + fn parse_uds_driver_rejects_args_without_command() { + let socket_path_toml = toml_path(&test_absolute_path("openshell-enterprise-secrets.sock")); + let err = parse_driver_table( + "enterprise-secrets", + &driver_table(&format!( + r#" +transport = "uds" +socket_path = {socket_path_toml} +args = ["--profile", "dev"] +"#, + )), + ) + .unwrap_err(); + + assert!(err.to_string().contains("args requires command")); + } + + #[test] + fn parse_uds_driver_rejects_timeout_without_command() { + let socket_path_toml = toml_path(&test_absolute_path("openshell-enterprise-secrets.sock")); + let err = parse_driver_table( + "enterprise-secrets", + &driver_table(&format!( + r#" +transport = "uds" +socket_path = {socket_path_toml} +startup_timeout_secs = 3 +"#, + )), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("startup_timeout_secs requires command") + ); + } + + #[test] + fn parse_in_tree_driver_rejects_launch_settings() { + let err = parse_driver_table( + "test-static", + &driver_table( + r#" +transport = "in_tree" +command = "/usr/local/libexec/openshell-credential-driver-test" +"#, + ), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("command, args, and startup_timeout_secs require transport = 'uds'") + ); + } + + #[cfg(unix)] + #[test] + fn remove_stale_launched_driver_socket_removes_socket() { + use std::os::unix::net::UnixListener as StdUnixListener; + + let dir = tempfile::tempdir().unwrap(); + let socket_path = dir.path().join("driver.sock"); + let listener = StdUnixListener::bind(&socket_path).unwrap(); + + remove_stale_launched_driver_socket("enterprise-secrets", &socket_path).unwrap(); + + drop(listener); + assert!(!socket_path.exists()); + } + + #[cfg(unix)] + #[test] + fn remove_stale_launched_driver_socket_rejects_regular_file() { + let dir = tempfile::tempdir().unwrap(); + let socket_path = dir.path().join("driver.sock"); + std::fs::write(&socket_path, "not a socket").unwrap(); + + let err = + remove_stale_launched_driver_socket("enterprise-secrets", &socket_path).unwrap_err(); + + assert!(err.to_string().contains("not a Unix socket")); + assert!(socket_path.exists()); + } + + #[cfg(unix)] + #[test] + fn remove_stale_launched_driver_socket_rejects_symlink() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target.sock"); + let socket_path = dir.path().join("driver.sock"); + std::os::unix::fs::symlink(&target, &socket_path).unwrap(); + + let err = + remove_stale_launched_driver_socket("enterprise-secrets", &socket_path).unwrap_err(); + + assert!(err.to_string().contains("is a symlink")); + assert!(std::fs::symlink_metadata(&socket_path).is_ok()); + } + + #[tokio::test] + async fn runtime_rejects_unconnected_enabled_driver_on_resolution() { + let config = Config::new(None).with_credential_drivers(["vault"]); + let runtime = CredentialRuntime::from_config(&config).unwrap(); + + let err = runtime + .resolve_provider_handles(&provider_with_handle("vault", "v1:providers/openai"), 1_000) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("not connected")); + } +} diff --git a/crates/openshell-server/src/gateway_listener.rs b/crates/openshell-server/src/gateway_listener.rs index 1db4c6cbca..b638fc33e4 100644 --- a/crates/openshell-server/src/gateway_listener.rs +++ b/crates/openshell-server/src/gateway_listener.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::compute::GatewayListenerRequirement; -use openshell_core::{ComputeDriverKind, Error, Result}; +use openshell_core::{Error, Result}; use socket2::{Domain, Protocol, Socket, Type}; use std::net::{IpAddr, SocketAddr}; use tokio::net::TcpListener; @@ -103,7 +103,7 @@ fn gateway_listener_specs_with_default_route_ip( continue; }; validate_gateway_listener_requirement(bind_address, requirement)?; - add_callback_listener_spec(&mut specs, *address, requirement)?; + add_callback_listener_spec(&mut specs, *address, requirement); } for requirement in requirements { @@ -127,7 +127,7 @@ fn gateway_listener_specs_with_default_route_ip( } let address = SocketAddr::new(ip, bind_address.port()); validate_resolved_gateway_listener(bind_address, address)?; - add_callback_listener_spec(&mut specs, address, requirement)?; + add_callback_listener_spec(&mut specs, address, requirement); } for requirement in requirements { @@ -137,7 +137,7 @@ fn gateway_listener_specs_with_default_route_ip( validate_gateway_listener_requirement(bind_address, requirement)?; let address = SocketAddr::from(([127, 0, 0, 1], bind_address.port())); validate_resolved_gateway_listener(bind_address, address)?; - add_callback_listener_spec(&mut specs, address, requirement)?; + add_callback_listener_spec(&mut specs, address, requirement); } Ok(specs) @@ -147,20 +147,17 @@ fn add_callback_listener_spec( specs: &mut Vec, address: SocketAddr, requirement: &GatewayListenerRequirement, -) -> Result<()> { +) { let scope = GatewayListenerScope::ComputeDriverCallback; if let Some(existing) = specs .iter_mut() .find(|existing| listener_covers(existing.address, address)) { + if existing.scope == GatewayListenerScope::Primary { + return; + } if existing.address == address { - if existing.scope == GatewayListenerScope::Primary { - return Err(Error::config(format!( - "compute driver '{}' requested gateway callback listener {address}, but it is the same address as the primary listener; callback-only authorization cannot be preserved", - requirement.driver_name() - ))); - } - return Ok(()); + return; } if !existing .covered_addresses @@ -171,10 +168,9 @@ fn add_callback_listener_spec( .covered_addresses .push(CoveredGatewayAddress { address, scope }); } - return Ok(()); + return; } specs.push(callback_listener_spec(address, requirement)); - Ok(()) } fn callback_listener_spec( @@ -197,25 +193,11 @@ fn validate_gateway_listener_requirement( requirement: &GatewayListenerRequirement, ) -> Result<()> { match requirement { - GatewayListenerRequirement::Exact { - address, - driver_name, - .. - } if driver_name == ComputeDriverKind::Docker.as_str() - || driver_name == ComputeDriverKind::Podman.as_str() => - { + GatewayListenerRequirement::Exact { address, .. } => { validate_resolved_gateway_listener(primary_listener, *address) } - GatewayListenerRequirement::DefaultRouteInterface { driver_name, .. } - | GatewayListenerRequirement::LoopbackInterface { driver_name, .. } - if driver_name == ComputeDriverKind::Podman.as_str() => - { - Ok(()) - } - _ => Err(Error::config(format!( - "compute driver '{}' is not authorized to request this gateway listener selector", - requirement.driver_name() - ))), + GatewayListenerRequirement::DefaultRouteInterface { .. } + | GatewayListenerRequirement::LoopbackInterface { .. } => Ok(()), } } @@ -392,8 +374,8 @@ fn listener_covers(existing: SocketAddr, requested: SocketAddr) -> bool { #[cfg(test)] mod tests { use super::{ - CoveredGatewayAddress, GatewayListenerProvenance, GatewayListenerScope, - GatewayListenerSpec, bind_gateway_listeners, gateway_listener_specs, + GatewayListenerProvenance, GatewayListenerScope, GatewayListenerSpec, + bind_gateway_listeners, gateway_listener_specs, gateway_listener_specs_with_default_route_ip, }; use crate::compute::GatewayListenerRequirement; @@ -402,41 +384,33 @@ mod tests { use tokio::net::TcpListener; #[test] - fn gateway_listener_specs_track_driver_address_covered_by_wildcard() { + fn gateway_listener_specs_reuse_primary_when_wildcard_covers_driver_address() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); - let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap(); + let callback: SocketAddr = "172.18.0.1:8080".parse().unwrap(); let requirements = [ - docker_listener_requirement(docker), - docker_listener_requirement(docker), + exact_listener_requirement(callback), + exact_listener_requirement(callback), ]; assert_eq!( gateway_listener_specs(primary, &requirements).unwrap(), - vec![GatewayListenerSpec { - address: primary, - scope: GatewayListenerScope::Primary, - covered_addresses: vec![CoveredGatewayAddress { - address: docker, - scope: GatewayListenerScope::ComputeDriverCallback, - }], - provenance: None, - }] + vec![primary_listener_spec(primary)] ); } #[test] - fn gateway_listener_scope_for_local_addr_uses_covered_address_scope() { + fn gateway_listener_scope_for_reused_primary_remains_primary() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); - let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap(); + let callback: SocketAddr = "172.18.0.1:8080".parse().unwrap(); let loopback: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let [spec] = gateway_listener_specs(primary, &[docker_listener_requirement(docker)]) + let [spec] = gateway_listener_specs(primary, &[exact_listener_requirement(callback)]) .unwrap() .try_into() .unwrap(); assert_eq!( - spec.scope_for_local_addr(docker), - GatewayListenerScope::ComputeDriverCallback, + spec.scope_for_local_addr(callback), + GatewayListenerScope::Primary, ); assert_eq!( spec.scope_for_local_addr(loopback), @@ -447,10 +421,10 @@ mod tests { #[test] fn gateway_listener_specs_preserve_driver_callback_scope() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap(); + let callback: SocketAddr = "172.18.0.1:8080".parse().unwrap(); let requirements = [ - docker_listener_requirement(docker), - docker_listener_requirement(docker), + exact_listener_requirement(callback), + exact_listener_requirement(callback), ]; assert_eq!( @@ -463,11 +437,11 @@ mod tests { provenance: None, }, GatewayListenerSpec { - address: docker, + address: callback, scope: GatewayListenerScope::ComputeDriverCallback, covered_addresses: Vec::new(), provenance: Some(GatewayListenerProvenance { - driver_name: "docker".to_string(), + driver_name: "alpha".to_string(), reason: "managed bridge".to_string(), }), }, @@ -476,7 +450,7 @@ mod tests { } #[test] - fn gateway_listener_specs_reject_unauthorized_external_driver() { + fn gateway_listener_specs_accept_safe_external_driver_requirement() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let requirement = GatewayListenerRequirement::Exact { address: "172.18.0.1:8080".parse().unwrap(), @@ -484,8 +458,10 @@ mod tests { reason: "external bridge".to_string(), }; - let err = gateway_listener_specs(primary, &[requirement]).unwrap_err(); - assert!(err.to_string().contains("not authorized")); + let specs = gateway_listener_specs(primary, &[requirement]).unwrap(); + assert_eq!(specs.len(), 2); + assert_eq!(specs[1].address, "172.18.0.1:8080".parse().unwrap()); + assert_eq!(specs[1].scope, GatewayListenerScope::ComputeDriverCallback); } #[test] @@ -497,7 +473,7 @@ mod tests { "172.18.0.1:0", "172.18.0.1:9090", ] { - let requirement = docker_listener_requirement(address.parse().unwrap()); + let requirement = exact_listener_requirement(address.parse().unwrap()); assert!( gateway_listener_specs(primary, &[requirement]).is_err(), "{address} should be rejected" @@ -506,41 +482,41 @@ mod tests { } #[test] - fn gateway_listener_specs_use_exact_podman_network_gateway() { + fn gateway_listener_specs_use_exact_network_gateway() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let podman_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); + let network_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_listener_requirement(podman_gateway)]) + gateway_listener_specs(primary, &[network_listener_requirement(network_gateway)]) .unwrap(), vec![ primary_listener_spec(primary), - callback_listener_spec(podman_gateway, "podman", "Podman managed bridge",), + callback_listener_spec(network_gateway, "beta", "managed bridge",), ] ); } #[test] - fn gateway_listener_specs_track_podman_exact_when_primary_covers_it() { + fn gateway_listener_specs_reuse_primary_when_it_covers_exact_requirement() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); - let podman_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); + let network_gateway: SocketAddr = "10.89.1.1:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_listener_requirement(podman_gateway)],) + gateway_listener_specs(primary, &[network_listener_requirement(network_gateway)],) .unwrap(), - vec![primary_listener_spec_with_covered(primary, podman_gateway,)] + vec![primary_listener_spec(primary)] ); } #[test] - fn gateway_listener_specs_resolve_podman_default_route_source() { + fn gateway_listener_specs_resolve_default_route_source() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let default_route_ip = "192.168.20.20".parse().unwrap(); assert_eq!( gateway_listener_specs_with_default_route_ip( primary, - &[podman_default_route_listener_requirement()], + &[default_route_listener_requirement()], Some(default_route_ip), ) .unwrap(), @@ -548,8 +524,8 @@ mod tests { primary_listener_spec(primary), callback_listener_spec( "192.168.20.20:8080".parse().unwrap(), - "podman", - "rootless pasta upstream interface", + "beta", + "default route interface", ), ] ); @@ -561,7 +537,7 @@ mod tests { let err = gateway_listener_specs_with_default_route_ip( primary, - &[podman_default_route_listener_requirement()], + &[default_route_listener_requirement()], Some("203.0.113.20".parse().unwrap()), ) .unwrap_err(); @@ -570,62 +546,51 @@ mod tests { } #[test] - fn gateway_listener_specs_track_default_route_when_primary_is_ipv4_wildcard() { + fn gateway_listener_specs_reuse_ipv4_wildcard_for_default_route() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); let default_route_ip = "192.168.20.20".parse().unwrap(); - let callback = "192.168.20.20:8080".parse().unwrap(); - assert_eq!( gateway_listener_specs_with_default_route_ip( primary, - &[podman_default_route_listener_requirement()], + &[default_route_listener_requirement()], Some(default_route_ip), ) .unwrap(), - vec![primary_listener_spec_with_covered(primary, callback)] + vec![primary_listener_spec(primary)] ); } #[test] - fn gateway_listener_specs_resolve_podman_loopback_separately() { + fn gateway_listener_specs_resolve_loopback_separately() { let primary: SocketAddr = "192.168.20.20:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap(), + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(), vec![ primary_listener_spec(primary), - callback_listener_spec( - "127.0.0.1:8080".parse().unwrap(), - "podman", - "Podman machine host forwarder", - ), + callback_listener_spec("127.0.0.1:8080".parse().unwrap(), "beta", "host forwarder",), ] ); } #[test] - fn gateway_listener_specs_track_podman_loopback_when_wildcard_primary_covers_it() { + fn gateway_listener_specs_reuse_wildcard_primary_for_loopback() { let primary = "0.0.0.0:8080".parse().unwrap(); - let loopback = "127.0.0.1:8080".parse().unwrap(); assert_eq!( - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap(), - vec![primary_listener_spec_with_covered(primary, loopback)] + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(), + vec![primary_listener_spec(primary)] ); } #[test] - fn gateway_listener_specs_reject_callback_matching_primary_address() { + fn gateway_listener_specs_reuse_matching_primary_address() { let primary = "127.0.0.1:8080".parse().unwrap(); - let err = - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap_err(); - - assert!( - err.to_string() - .contains("same address as the primary listener") + assert_eq!( + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(), + vec![primary_listener_spec(primary)] ); - assert!(err.to_string().contains("callback-only authorization")); } #[test] @@ -633,7 +598,7 @@ mod tests { for primary in ["[::1]:8080", "[::]:8080"] { let primary = primary.parse().unwrap(); let specs = - gateway_listener_specs(primary, &[podman_loopback_listener_requirement()]).unwrap(); + gateway_listener_specs(primary, &[loopback_listener_requirement()]).unwrap(); assert_eq!(specs.len(), 2); assert_eq!(specs[1].address, SocketAddr::from(([127, 0, 0, 1], 8080))); @@ -641,15 +606,18 @@ mod tests { } #[test] - fn gateway_listener_specs_reject_cross_driver_selector_authority() { - let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + fn gateway_listener_specs_validate_selector_independently_of_driver_name() { + let primary: SocketAddr = "192.168.20.20:8080".parse().unwrap(); let requirement = GatewayListenerRequirement::LoopbackInterface { - driver_name: "docker".to_string(), + driver_name: "alpha".to_string(), reason: "wrong selector".to_string(), }; - let err = gateway_listener_specs(primary, &[requirement]).unwrap_err(); - assert!(err.to_string().contains("not authorized")); + let specs = gateway_listener_specs(primary, &[requirement]).unwrap(); + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].address, primary); + assert_eq!(specs[1].address, "127.0.0.1:8080".parse().unwrap()); + assert_eq!(specs[1].scope, GatewayListenerScope::ComputeDriverCallback); } #[tokio::test] @@ -662,7 +630,7 @@ mod tests { let result: openshell_core::Result<()> = async { let _listeners = bind_gateway_listeners( primary_address, - &[docker_listener_requirement(occupied_address)], + &[exact_listener_requirement(occupied_address)], ) .await?; continuation_reached.store(true, Ordering::SeqCst); @@ -682,6 +650,7 @@ mod tests { #[tokio::test] #[cfg(target_os = "linux")] + #[ignore = "flaky under concurrent test execution"] async fn gateway_listeners_bind_ipv6_wildcard_and_ipv4_callback_on_same_port() { let probe = TcpListener::bind("[::1]:0") .await @@ -690,7 +659,7 @@ mod tests { drop(probe); let primary = format!("[::]:{port}").parse().unwrap(); - let listeners = bind_gateway_listeners(primary, &[podman_loopback_listener_requirement()]) + let listeners = bind_gateway_listeners(primary, &[loopback_listener_requirement()]) .await .expect("IPv6 wildcard and IPv4 callback listeners should both bind"); @@ -702,33 +671,33 @@ mod tests { ); } - fn docker_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { + fn exact_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { GatewayListenerRequirement::Exact { address, - driver_name: "docker".to_string(), + driver_name: "alpha".to_string(), reason: "managed bridge".to_string(), } } - fn podman_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { + fn network_listener_requirement(address: SocketAddr) -> GatewayListenerRequirement { GatewayListenerRequirement::Exact { address, - driver_name: "podman".to_string(), - reason: "Podman managed bridge".to_string(), + driver_name: "beta".to_string(), + reason: "managed bridge".to_string(), } } - fn podman_default_route_listener_requirement() -> GatewayListenerRequirement { + fn default_route_listener_requirement() -> GatewayListenerRequirement { GatewayListenerRequirement::DefaultRouteInterface { - driver_name: "podman".to_string(), - reason: "rootless pasta upstream interface".to_string(), + driver_name: "beta".to_string(), + reason: "default route interface".to_string(), } } - fn podman_loopback_listener_requirement() -> GatewayListenerRequirement { + fn loopback_listener_requirement() -> GatewayListenerRequirement { GatewayListenerRequirement::LoopbackInterface { - driver_name: "podman".to_string(), - reason: "Podman machine host forwarder".to_string(), + driver_name: "beta".to_string(), + reason: "host forwarder".to_string(), } } @@ -741,21 +710,6 @@ mod tests { } } - fn primary_listener_spec_with_covered( - address: SocketAddr, - covered_address: SocketAddr, - ) -> GatewayListenerSpec { - GatewayListenerSpec { - address, - scope: GatewayListenerScope::Primary, - covered_addresses: vec![CoveredGatewayAddress { - address: covered_address, - scope: GatewayListenerScope::ComputeDriverCallback, - }], - provenance: None, - } - } - fn callback_listener_spec( address: SocketAddr, driver_name: &str, diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index a7d74e73dd..104d639584 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -5,7 +5,7 @@ //! //! Hosts authenticated identity RPCs: //! - `GetCurrentUser` — report the gateway-validated caller identity -//! - `IssueSandboxToken` — bootstrap exchange (K8s SA token → gateway JWT) +//! - `IssueSandboxToken` — driver-native bootstrap exchange → gateway JWT //! - `RefreshSandboxToken` — renew a still-valid gateway JWT //! //! Both end in a fresh gateway-signed JWT minted by @@ -16,10 +16,14 @@ use crate::ServerState; use crate::auth::identity::IdentityProvider; use crate::auth::principal::{Principal, SandboxIdentitySource}; use openshell_core::proto::{ - GetCurrentUserRequest, GetCurrentUserResponse, IssueSandboxTokenRequest, - IssueSandboxTokenResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, Sandbox, + ExtensionServiceCredential, GetCurrentUserRequest, GetCurrentUserResponse, + GetSandboxConfigRequest, IssueSandboxTokenRequest, IssueSandboxTokenResponse, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, Sandbox, }; +use openshell_extension_core::{ExtensionAudience, ExtensionCallerKind, MAX_EXTENSION_TOKEN_TTL}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; @@ -67,13 +71,9 @@ pub async fn handle_issue_sandbox_token( )); }; - // Only the bootstrap K8s ServiceAccount path can mint a fresh gateway JWT - // via this RPC. Sandboxes already holding a gateway JWT use - // `RefreshSandboxToken` instead. - if !matches!( - sandbox.source, - SandboxIdentitySource::K8sServiceAccount { .. } - ) { + // Only a selected compute driver may establish the bootstrap sandbox + // identity. Sandboxes already holding a gateway JWT use refresh instead. + if !matches!(sandbox.source, SandboxIdentitySource::ComputeDriver { .. }) { debug!( sandbox_id = %sandbox.sandbox_id, "IssueSandboxToken rejected: non-bootstrap principal source" @@ -109,6 +109,7 @@ pub async fn handle_refresh_sandbox_token( state: &Arc, request: Request, ) -> Result, Status> { + let requested_extension_services = request.get_ref().extension_service_names.clone(); let principal = request .extensions() .get::() @@ -144,6 +145,41 @@ pub async fn handle_refresh_sandbox_token( ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; let minted = issuer.mint(&sandbox.sandbox_id)?; + let extension_credentials = if requested_extension_services.is_empty() { + Vec::new() + } else if !state + .extension_mint_limiter + .try_acquire(&sandbox.sandbox_id) + { + // Minting resolves the sandbox's effective policy, so an unbounded + // caller could impose real gateway cost from inside a sandbox. The + // supervisor keeps its last-known-good slots on error and retries at + // its normal cadence, so refusing is safe. + warn!( + sandbox_id = %sandbox.sandbox_id, + "extension credential minting rate limit exceeded" + ); + return Err(Status::resource_exhausted( + "extension credential minting rate limit exceeded for this sandbox", + )); + } else { + let mut config_request = Request::new(GetSandboxConfigRequest { + sandbox_id: sandbox.sandbox_id.clone(), + }); + config_request + .extensions_mut() + .insert(Principal::Sandbox(sandbox.clone())); + let available = super::policy::handle_get_sandbox_config(state, config_request) + .await? + .into_inner() + .supervisor_middleware_services; + mint_extension_credentials( + issuer, + &sandbox.sandbox_id, + &requested_extension_services, + &available, + )? + }; info!( sandbox_id = %sandbox.sandbox_id, "renewed gateway sandbox JWT" @@ -152,9 +188,81 @@ pub async fn handle_refresh_sandbox_token( Ok(Response::new(RefreshSandboxTokenResponse { token: minted.token, expires_at_ms: minted.expires_at_ms, + extension_credentials, })) } +const MAX_EXTENSION_CREDENTIALS_PER_REFRESH: usize = 64; +const DEFAULT_EXTENSION_TOKEN_TTL: Duration = Duration::from_secs(15 * 60); + +#[allow(clippy::result_large_err)] +fn mint_extension_credentials( + issuer: &crate::auth::sandbox_jwt::SandboxJwtIssuer, + sandbox_id: &str, + requested_names: &[String], + available_services: &[openshell_core::proto::SupervisorMiddlewareService], +) -> Result, Status> { + if requested_names.len() > MAX_EXTENSION_CREDENTIALS_PER_REFRESH { + return Err(Status::invalid_argument(format!( + "at most {MAX_EXTENSION_CREDENTIALS_PER_REFRESH} extension credentials may be requested" + ))); + } + let mut unique = HashSet::with_capacity(requested_names.len()); + for name in requested_names { + if name.is_empty() { + return Err(Status::invalid_argument( + "extension service names must not be empty", + )); + } + if !unique.insert(name.as_str()) { + return Err(Status::invalid_argument(format!( + "duplicate extension service name '{name}'" + ))); + } + } + + let available: HashMap<&str, &openshell_core::proto::SupervisorMiddlewareService> = + available_services + .iter() + .map(|service| (service.name.as_str(), service)) + .collect(); + let ttl = if issuer.ttl().is_zero() { + DEFAULT_EXTENSION_TOKEN_TTL + } else { + issuer.ttl().min(MAX_EXTENSION_TOKEN_TTL) + }; + + requested_names + .iter() + .map(|name| { + let service = available.get(name.as_str()).ok_or_else(|| { + Status::permission_denied(format!( + "extension service '{name}' is not selected by the sandbox policy" + )) + })?; + if service.allow_insecure_transport { + return Err(Status::failed_precondition(format!( + "extension service '{name}' opted out of extension authentication; \ + no credential is minted for it" + ))); + } + let audience = ExtensionAudience::new(service.audience.clone()) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + let minted = issuer.mint_extension_token( + &audience, + ExtensionCallerKind::Supervisor, + Some(sandbox_id), + ttl, + )?; + Ok(ExtensionServiceCredential { + service_name: name.clone(), + token: minted.token, + expires_at_ms: minted.expires_at_ms, + }) + }) + .collect() +} + async fn ensure_sandbox_exists(state: &Arc, sandbox_id: &str) -> Result<(), Status> { if sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); @@ -199,7 +307,9 @@ mod tests { ); let compute = new_test_runtime(store.clone()).await; let mut state = ServerState::new( - Config::new(None).with_database_url("sqlite::memory:?cache=shared"), + Config::new(None) + .with_database_url("sqlite::memory:?cache=shared") + .with_credential_drivers(["test-static"]), store, compute, SandboxIndex::new(), @@ -282,7 +392,9 @@ mod tests { #[tokio::test] async fn refresh_returns_new_token() { let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut().insert(sandbox_principal("sandbox-a")); let resp = handle_refresh_sandbox_token(&state, req) .await @@ -292,10 +404,99 @@ mod tests { assert!(resp.expires_at_ms > 0); } + #[tokio::test] + async fn extension_credentials_are_minted_only_for_selected_registration_names() { + let state = state_with_issuer().await; + let issuer = state.sandbox_jwt_issuer.as_deref().expect("issuer"); + let available = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "content-guard".to_string(), + audience: "urn:example:content-guard".to_string(), + ..Default::default() + }]; + + let credentials = mint_extension_credentials( + issuer, + "sandbox-a", + &["content-guard".to_string()], + &available, + ) + .expect("selected service credential"); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].service_name, "content-guard"); + assert!(!credentials[0].token.is_empty()); + assert!(credentials[0].expires_at_ms > 0); + + let error = mint_extension_credentials( + issuer, + "sandbox-a", + &["attacker-chosen-audience".to_string()], + &available, + ) + .expect_err("unselected name must be rejected"); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[tokio::test] + async fn refresh_refuses_extension_credentials_past_the_per_sandbox_bound() { + let state = state_with_issuer().await; + // The gateway credential path is unaffected; only requests that carry + // extension service names consume the bound. + for _ in 0..10 { + assert!(state.extension_mint_limiter.try_acquire("sandbox-a")); + } + assert!(!state.extension_mint_limiter.try_acquire("sandbox-a")); + assert!(state.extension_mint_limiter.try_acquire("sandbox-b")); + } + + #[tokio::test] + async fn opted_out_registrations_never_receive_a_minted_credential() { + let state = state_with_issuer().await; + let issuer = state.sandbox_jwt_issuer.as_deref().expect("issuer"); + let available = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "legacy-guard".to_string(), + audience: "urn:example:legacy-guard".to_string(), + allow_insecure_transport: true, + ..Default::default() + }]; + + // The registration is policy-selected, so authorization passes; the + // opt-out is what withholds the credential. A supervisor must not be + // able to obtain a bearer token it would then send over plaintext. + let error = mint_extension_credentials( + issuer, + "sandbox-a", + &["legacy-guard".to_string()], + &available, + ) + .expect_err("opted-out registration must not mint a credential"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + } + + #[tokio::test] + async fn extension_credential_request_rejects_duplicate_names_atomically() { + let state = state_with_issuer().await; + let issuer = state.sandbox_jwt_issuer.as_deref().expect("issuer"); + let available = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "content-guard".to_string(), + audience: "urn:example:content-guard".to_string(), + ..Default::default() + }]; + let error = mint_extension_credentials( + issuer, + "sandbox-a", + &["content-guard".to_string(), "content-guard".to_string()], + &available, + ) + .expect_err("duplicates must be rejected"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } + #[tokio::test] async fn refresh_rejects_missing_sandbox() { let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut() .insert(sandbox_principal("sandbox-deleted")); let err = handle_refresh_sandbox_token(&state, req) @@ -313,9 +514,8 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: "pod-a".to_string(), - pod_uid: "uid-a".to_string(), + source: SandboxIdentitySource::ComputeDriver { + driver_name: "kubernetes".to_string(), }, trust_domain: Some("openshell".to_string()), })); @@ -336,9 +536,8 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-deleted".to_string(), - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: "pod-a".to_string(), - pod_uid: "uid-a".to_string(), + source: SandboxIdentitySource::ComputeDriver { + driver_name: "kubernetes".to_string(), }, trust_domain: Some("openshell".to_string()), })); @@ -352,7 +551,9 @@ mod tests { async fn refresh_rejects_user_principal() { use crate::auth::identity::{Identity, IdentityProvider}; let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut().insert(Principal::User(UserPrincipal { identity: Identity { subject: "alice".to_string(), @@ -369,19 +570,20 @@ mod tests { } #[tokio::test] - async fn refresh_rejects_k8s_sa_principal() { - // K8s SA-bootstrap principals must use IssueSandboxToken, not + async fn refresh_rejects_compute_driver_principal() { + // Driver-bootstrap principals must use IssueSandboxToken, not // RefreshSandboxToken — the refresh path assumes a still-valid // gateway-minted JWT exists. use crate::auth::principal::SandboxIdentitySource; let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::K8sServiceAccount { - pod_name: "pod-a".to_string(), - pod_uid: "uid-a".to_string(), + source: SandboxIdentitySource::ComputeDriver { + driver_name: "kubernetes".to_string(), }, trust_domain: Some("openshell".to_string()), })); @@ -402,7 +604,9 @@ mod tests { ); let compute = new_test_runtime(store.clone()).await; let state = Arc::new(ServerState::new( - Config::new(None).with_database_url("sqlite::memory:?cache=shared"), + Config::new(None) + .with_database_url("sqlite::memory:?cache=shared") + .with_credential_drivers(["test-static"]), store, compute, SandboxIndex::new(), @@ -412,7 +616,9 @@ mod tests { None, )); insert_sandbox(&state, "sandbox-a").await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut().insert(sandbox_principal("sandbox-a")); let err = handle_refresh_sandbox_token(&state, req) .await diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 6d5b2b35c3..8143e6058e 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -17,14 +17,17 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, ClearDraftChunksRequest, ClearDraftChunksResponse, ComputeDriverCapabilities, ComputeDriverInfo, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, - CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, - CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteProviderProfileRequest, - DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, - DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DeleteServiceRequest, DeleteServiceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, - EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, - ExposeServiceRequest, GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, + CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, + CreateSshSessionResponse, CreateWorkspaceRequest, CreateWorkspaceResponse, + DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, + DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + DeleteSandboxRequest, DeleteSandboxResponse, DeleteSandboxTemplateRequest, + DeleteSandboxTemplateResponse, DeleteServiceRequest, DeleteServiceResponse, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, DetachSandboxProviderRequest, + DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, FinalizeMainProcessExitRequest, + FinalizeMainProcessExitResponse, GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetGatewayInfoRequest, GetGatewayInfoResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, @@ -32,22 +35,25 @@ use openshell_core::proto::{ GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, HealthRequest, HealthResponse, - ImportProviderProfilesRequest, ImportProviderProfilesResponse, IssueSandboxTokenRequest, - IssueSandboxTokenResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, - ListProviderProfilesRequest, ListProviderProfilesResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, - ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, + GetSandboxTemplateRequest, GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, + HealthRequest, HealthResponse, ImportProviderProfilesRequest, ImportProviderProfilesResponse, + IssueSandboxTokenRequest, IssueSandboxTokenResponse, LintProviderProfilesRequest, + LintProviderProfilesResponse, ListProviderProfilesRequest, ListProviderProfilesResponse, + ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, + ListSandboxPoliciesResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, + ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, ListServicesResponse, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, - RemoveWorkspaceMemberResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, - RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, - RotateProviderCredentialResponse, SandboxResponse, ServiceEndpointResponse, ServiceStatus, - SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, - UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, + RemoveWorkspaceMemberResponse, ReportMainProcessExitRequest, ReportMainProcessExitResponse, + ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, + SandboxResponse, SandboxTemplateResponse, ServiceEndpointResponse, ServiceStatus, + StartSandboxRequest, StopSandboxRequest, SubmitPolicyAnalysisRequest, + SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, WatchSandboxRequest, open_shell_server::OpenShell, }; @@ -295,6 +301,34 @@ impl OpenShell for OpenShellService { sandbox::handle_list_sandboxes(&self.state, request).await } + async fn create_sandbox_template( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_create_sandbox_template(&self.state, request).await + } + + async fn get_sandbox_template( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_get_sandbox_template(&self.state, request).await + } + + async fn list_sandbox_templates( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_list_sandbox_templates(&self.state, request).await + } + + async fn delete_sandbox_template( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_delete_sandbox_template(&self.state, request).await + } + async fn list_sandbox_providers( &self, request: Request, @@ -323,6 +357,20 @@ impl OpenShell for OpenShellService { sandbox::handle_delete_sandbox(&self.state, request).await } + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_stop_sandbox(&self.state, request).await + } + + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + sandbox::handle_start_sandbox(&self.state, request).await + } + // --- Exec --- type ExecSandboxStream = ReceiverStream>; @@ -527,6 +575,13 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_provider_environment(&self.state, request).await } + async fn exchange_provider_subject_token( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_exchange_provider_subject_token(&self.state, request).await + } + async fn update_config( &self, request: Request, @@ -664,6 +719,20 @@ impl OpenShell for OpenShellService { crate::supervisor_session::handle_connect_supervisor(&self.state, request).await } + async fn report_main_process_exit( + &self, + request: Request, + ) -> Result, Status> { + crate::supervisor_session::handle_report_main_process_exit(&self.state, request).await + } + + async fn finalize_main_process_exit( + &self, + request: Request, + ) -> Result, Status> { + crate::supervisor_session::handle_finalize_main_process_exit(&self.state, request).await + } + type RelayStreamStream = Pin> + Send + 'static>>; @@ -738,7 +807,9 @@ pub mod test_support { use crate::ServerState; use crate::auth::identity::{Identity, IdentityProvider}; use crate::auth::principal::{Principal, UserPrincipal}; - use crate::compute::{new_test_runtime, new_test_runtime_for_driver}; + use crate::compute::{ + NoopTestDriver, new_test_runtime, new_test_runtime_for_driver, new_test_runtime_with_driver, + }; use crate::persistence::Store; use crate::sandbox_index::SandboxIndex; use crate::sandbox_watch::SandboxWatchBus; @@ -786,7 +857,36 @@ pub mod test_support { new_test_runtime_for_driver(store.clone(), driver_name).await }; Arc::new(ServerState::new( - Config::new(None).with_database_url("sqlite::memory:?cache=shared"), + Config::new(None) + .with_database_url("sqlite::memory:?cache=shared") + .with_credential_drivers(["test-static"]), + store, + compute, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + )) + } + + /// Build a test state whose compute driver fails the requested number of + /// workspace cleanup calls before succeeding. + pub async fn test_server_state_with_workspace_cleanup_failures( + failures: usize, + ) -> Arc { + let store = Arc::new( + Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(), + ); + crate::ensure_default_workspace(&store).await.unwrap(); + let driver = Arc::new(NoopTestDriver::failing_workspace_deletes(failures)); + let compute = new_test_runtime_with_driver(store.clone(), "test", driver).await; + Arc::new(ServerState::new( + Config::new(None) + .with_database_url("sqlite::memory:?cache=shared") + .with_credential_drivers(["test-static"]), store, compute, SandboxIndex::new(), diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 5c5faae8a6..0e4c74af95 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -44,21 +44,23 @@ use openshell_core::proto::{ }; use openshell_core::proto::{ L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox, - SandboxPolicy as ProtoSandboxPolicy, + SandboxPolicy as ProtoSandboxPolicy, StaticCredentialEndpointBinding, }; use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, PolicyDecisionOperation, TelemetryOutcome, }; use openshell_core::{ VERSION, + endpoint_path::EndpointPathPattern, + host_pattern::{host_matches, host_patterns_overlap}, settings::{self, SettingValueKind}, }; use openshell_ocsf::{ ConfigStateChangeBuilder, OCSF_TARGET, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, }; use openshell_policy::{ - PolicyMergeOp, ProviderPolicyLayer, compose_effective_policy, merge_policy, - serialize_sandbox_policy, + PolicyMergeOp, ProviderPolicyLayer, canonicalize_advisor_add_rule, compose_effective_policy, + merge_policy, policy_covers_rule, serialize_sandbox_policy, strip_provider_rule_names, }; use openshell_prover::{ credentials::{Credential, CredentialSet}, @@ -69,7 +71,6 @@ use openshell_prover::{ registry::load_embedded_binary_registry, report::finding_shorthand, }; -use openshell_providers::normalize_provider_type; use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -79,9 +80,8 @@ use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; use super::validation::{ - level_matches, normalize_process_identity_for_driver, source_matches, validate_annotations, - validate_no_reserved_provider_policy_keys, validate_policy_safety, - validate_static_fields_unchanged, + level_matches, source_matches, validate_annotations, validate_no_reserved_provider_policy_keys, + validate_policy_safety, validate_static_fields_unchanged, }; use super::{MAX_PAGE_SIZE, StoredSettingValue, StoredSettings, clamp_limit}; use crate::persistence::current_time_ms; @@ -343,6 +343,9 @@ fn summarize_endpoint(endpoint: &NetworkEndpoint) -> String { if endpoint.request_body_credential_rewrite { parts.push("request_body_credential_rewrite=true".to_string()); } + if endpoint.allow_uninspected_credentials { + parts.push("allow_uninspected_credentials=true".to_string()); + } if !endpoint.allowed_ips.is_empty() { parts.push(format!("allowed_ips={}", endpoint.allowed_ips.len())); } @@ -444,6 +447,7 @@ fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result, + validation_result: String, + application_error: String, + review_token: String, +} + +impl ProposalEvaluation { + fn current_hash(&self) -> String { + deterministic_policy_hash(&self.current_effective_policy) + } + + fn candidate_hash(&self) -> String { + self.candidate_effective_policy + .as_ref() + .map(deterministic_policy_hash) + .unwrap_or_default() + } +} + +fn proposal_prover_result( + current_effective_policy: &ProtoSandboxPolicy, + candidate_effective_policy: &ProtoSandboxPolicy, + credentials: &CredentialSet, +) -> String { + let candidate_findings = match run_prover_findings(candidate_effective_policy, credentials) { + Ok(findings) => findings, + Err(error) => { + warn!(error = %error, "prover validation unavailable for candidate policy"); + return "validation unavailable".to_string(); + } + }; + let base_findings = match run_prover_findings(current_effective_policy, credentials) { + Ok(findings) => findings, + Err(error) => { + warn!(error = %error, "prover baseline run failed; treating baseline as empty"); + Vec::new() + } + }; + let new_findings = finding_delta(&base_findings, &candidate_findings); + if new_findings.is_empty() { + return "prover: no new findings".to_string(); + } + let mut out = format!( + "prover: {} new finding{}", + new_findings.len(), + if new_findings.len() == 1 { "" } else { "s" } + ); + for finding in &new_findings { + out.push_str("\n "); + out.push_str(&finding_shorthand(finding)); + } + out +} + +fn compute_proposal_review_token( + rule_name: &str, + rule: &NetworkPolicyRule, + current_effective_policy: &ProtoSandboxPolicy, + candidate_effective_policy: &ProtoSandboxPolicy, + credentials: &CredentialSet, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"openshell-proposal-evaluator-v2\0"); + hasher.update(rule_name.as_bytes()); + hasher.update(canonical_rule_bytes(rule)); + hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes()); + hasher.update(deterministic_policy_hash(candidate_effective_policy).as_bytes()); + + let mut credential_fingerprints = credentials + .credentials + .iter() + .map(|credential| { + let mut scopes = credential.scopes.clone(); + scopes.sort(); + let mut hosts = credential.target_hosts.clone(); + hosts.sort(); + format!( + "{}\0{}\0{}\0{}\0{}", + credential.name, + credential.cred_type, + credential.injected_via, + scopes.join("\0"), + hosts.join("\0") + ) + }) + .collect::>(); + credential_fingerprints.sort(); + for fingerprint in credential_fingerprints { + hasher.update(fingerprint.as_bytes()); + } + hex::encode(hasher.finalize()) +} + +fn compute_failed_proposal_evaluation_hash( + rule_name: &str, + rule: &NetworkPolicyRule, + current_effective_policy: &ProtoSandboxPolicy, + application_error: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"openshell-proposal-evaluator-v2-failed\0"); + hasher.update(rule_name.as_bytes()); + hasher.update(canonical_rule_bytes(rule)); + hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes()); + hasher.update(application_error.as_bytes()); + hex::encode(hasher.finalize()) +} + +#[allow(clippy::too_many_arguments)] +fn evaluate_proposal_candidate( + base_policy: &ProtoSandboxPolicy, + current_effective_policy: &ProtoSandboxPolicy, + requested_rule_name: &str, + proposed_rule: &NetworkPolicyRule, + analysis_mode: &str, + credentials: &CredentialSet, + validation_context: PolicyMergeValidationContext<'_>, + reuse_validation_result: Option<&str>, +) -> ProposalEvaluation { + let canonical = if analysis_mode == "mechanistic" { + canonicalize_advisor_add_rule( + base_policy, + current_effective_policy, + requested_rule_name, + proposed_rule, + ) + } else { + Ok((requested_rule_name.to_string(), proposed_rule.clone())) + }; + let (rule_name, rule) = match canonical { + Ok(value) => value, + Err(error) => { + let application_error = format!("candidate canonicalization failed: {error}"); + return ProposalEvaluation { + rule_name: requested_rule_name.to_string(), + rule: proposed_rule.clone(), + current_effective_policy: current_effective_policy.clone(), + candidate_effective_policy: None, + validation_result: String::new(), + review_token: compute_failed_proposal_evaluation_hash( + requested_rule_name, + proposed_rule, + current_effective_policy, + &application_error, + ), + application_error, + }; + } + }; + + let operations = [PolicyMergeOp::AddRule { + rule_name: rule_name.clone(), + rule: rule.clone(), + }]; + let candidate_base = match merge_policy(base_policy.clone(), &operations) { + Ok(result) => result.policy, + Err(error) => { + let application_error = format!("merge failed: {}", one_line(&error.to_string())); + let review_token = compute_failed_proposal_evaluation_hash( + &rule_name, + &rule, + current_effective_policy, + &application_error, + ); + return ProposalEvaluation { + rule_name, + rule, + current_effective_policy: current_effective_policy.clone(), + candidate_effective_policy: None, + validation_result: String::new(), + review_token, + application_error, + }; + } + }; + + let validation = (|| -> Result { + validate_policy_safety(&candidate_base)?; + validate_candidate_effective_policy(&candidate_base, validation_context.provider_layers)?; + let mut effective = if validation_context.provider_layers.is_empty() { + candidate_base.clone() + } else { + compose_effective_policy(&candidate_base, validation_context.provider_layers) + }; + let bindings = policy_static_credential_endpoint_bindings(Some(&effective))?; + if let Some(context) = validation_context.credential_binding { + validate_operator_merged_credential_policy(&mut effective, &bindings, context)?; + } + Ok(effective) + })(); + + let candidate_effective_policy = match validation { + Ok(policy) => policy, + Err(error) => { + let application_error = format!("candidate invalid: {}", one_line(error.message())); + let review_token = compute_failed_proposal_evaluation_hash( + &rule_name, + &rule, + current_effective_policy, + &application_error, + ); + return ProposalEvaluation { + rule_name, + rule, + current_effective_policy: current_effective_policy.clone(), + candidate_effective_policy: None, + validation_result: String::new(), + review_token, + application_error, + }; + } + }; + let validation_result = reuse_validation_result.map_or_else( + || { + proposal_prover_result( + current_effective_policy, + &candidate_effective_policy, + credentials, + ) + }, + ToString::to_string, + ); + let application_error = if validation_result == "validation unavailable" { + "prover validation unavailable; proposal cannot be reviewed".to_string() + } else { + String::new() + }; + let review_token = if application_error.is_empty() { + compute_proposal_review_token( + &rule_name, + &rule, + current_effective_policy, + &candidate_effective_policy, + credentials, + ) + } else { + compute_failed_proposal_evaluation_hash( + &rule_name, + &rule, + current_effective_policy, + &application_error, + ) + }; + ProposalEvaluation { + rule_name, + rule, + current_effective_policy: current_effective_policy.clone(), + candidate_effective_policy: Some(candidate_effective_policy), + validation_result, + application_error, + review_token, + } +} + /// Run the prover end-to-end against a single policy with the given /// credential set. Returns the raw finding list, or a short error string /// identifying which infrastructure step failed. @@ -545,10 +808,9 @@ async fn build_credential_set_for_sandbox_with_catalog( }; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + provider_type, &provider.profile_workspace, ) else { warn!( @@ -678,6 +940,68 @@ fn one_line(s: &str) -> String { .join("; ") } +async fn reconcile_pending_chunks_covered_by_policy( + state: &Arc, + sandbox_id: &str, + effective_policy: &ProtoSandboxPolicy, + policy_version: i64, +) -> Result { + let pending = state + .store + .list_draft_chunks(sandbox_id, Some("pending")) + .await + .map_err(|error| Status::internal(format!("list pending chunks failed: {error}")))?; + let mut reconciled = 0; + for chunk in pending { + let Some(rule) = decode_draft_chunk_rule(&chunk)? else { + continue; + }; + if !policy_covers_rule(effective_policy, &rule) { + continue; + } + let reason = format!("covered by active policy revision {policy_version}"); + if state + .store + .conditionally_reject_draft_chunk(&chunk.id, current_time_ms(), &reason) + .await + .map_err(|error| Status::internal(format!("reconcile covered chunk failed: {error}")))? + { + reconciled += 1; + } + } + if reconciled > 0 { + state.sandbox_watch_bus.notify(sandbox_id); + } + Ok(reconciled) +} + +async fn reconcile_pending_chunks_after_policy_change( + state: &Arc, + workspace: &str, + sandbox: &Sandbox, +) -> Result { + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let effective = current_effective_policy_for_sandbox( + state, + &catalog, + workspace, + sandbox, + sandbox.object_id(), + ) + .await?; + let version = state + .store + .get_latest_policy(sandbox.object_id()) + .await + .map_err(|error| Status::internal(format!("fetch latest policy failed: {error}")))? + .map_or(0, |record| record.version); + reconcile_pending_chunks_covered_by_policy(state, sandbox.object_id(), &effective, version) + .await +} + /// Auto-reject any pending chunks for the same sandbox that share the /// `(host, port, binary)` of the newly-submitted chunk. Mode-agnostic: the /// rule is "the latest submission for this endpoint wins; older pending @@ -910,13 +1234,198 @@ async fn resolve_proposal_approval_mode( Ok((false, "default")) } +fn apply_evaluation_to_chunk(chunk: &mut DraftChunkRecord, evaluation: &ProposalEvaluation) { + chunk.rule_name.clone_from(&evaluation.rule_name); + chunk.proposed_rule = evaluation.rule.encode_to_vec(); + chunk + .validation_result + .clone_from(&evaluation.validation_result); + chunk + .application_error + .clone_from(&evaluation.application_error); + chunk.review_token.clone_from(&evaluation.review_token); + chunk.current_effective_policy_hash = evaluation.current_hash(); + chunk.candidate_effective_policy_hash = evaluation.candidate_hash(); + chunk.current_effective_policy = Some(evaluation.current_effective_policy.clone()); + chunk + .candidate_effective_policy + .clone_from(&evaluation.candidate_effective_policy); + chunk.last_seen_ms = current_time_ms(); +} + +async fn evaluate_stored_chunk_against_live_inputs( + state: &Arc, + workspace: &str, + sandbox: &Sandbox, + chunk: &DraftChunkRecord, + reuse_validation_result: Option<&str>, +) -> Result { + let rule = decode_draft_chunk_rule(chunk)? + .ok_or_else(|| Status::failed_precondition("draft chunk has no proposed rule"))?; + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.clone()) + .unwrap_or_default(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let current_effective = current_effective_policy_for_sandbox( + state, + &catalog, + workspace, + sandbox, + sandbox.object_id(), + ) + .await?; + let current_base = current_base_policy_for_sandbox(state.store.as_ref(), sandbox).await?; + let credentials = build_credential_set_for_sandbox_with_catalog( + state.store.as_ref(), + &catalog, + workspace, + &provider_names, + ) + .await?; + let merge_validation = sandbox_policy_merge_validation_data_with_catalog( + state, + workspace, + sandbox, + &provider_names, + &catalog, + ) + .await?; + let credential_binding_context = merge_validation.credential_binding_context(); + Ok(evaluate_proposal_candidate( + ¤t_base, + ¤t_effective, + &chunk.rule_name, + &rule, + "stored", + &credentials, + PolicyMergeValidationContext { + provider_layers: &merge_validation.provider_layers, + credential_binding: Some(&credential_binding_context), + }, + reuse_validation_result, + )) +} + +async fn persist_refreshed_evaluation( + state: &Arc, + chunk: &DraftChunkRecord, + evaluation: &ProposalEvaluation, +) -> Result { + let mut refreshed = chunk.clone(); + apply_evaluation_to_chunk(&mut refreshed, evaluation); + let updated = state + .store + .update_draft_chunk_evaluation(&refreshed) + .await + .map_err(|error| { + Status::internal(format!("persist proposal evaluation failed: {error}")) + })?; + if !updated { + return Err(Status::failed_precondition( + "proposal is no longer pending; refetch before deciding", + )); + } + state.sandbox_watch_bus.notify(&chunk.sandbox_id); + Ok(refreshed) +} + +async fn persist_pending_application_error( + state: &Arc, + chunk_id: &str, + error: &Status, +) { + let Ok(Some(mut chunk)) = state.store.get_draft_chunk(chunk_id).await else { + return; + }; + if chunk.status != "pending" { + return; + } + chunk.application_error = one_line(error.message()); + chunk.last_seen_ms = current_time_ms(); + if let Err(persist_error) = state.store.update_draft_chunk_evaluation(&chunk).await { + warn!( + chunk_id, + error = %persist_error, + "failed to persist proposal application error" + ); + return; + } + state.sandbox_watch_bus.notify(&chunk.sandbox_id); +} + +async fn clear_pending_application_error(state: &Arc, chunk_id: &str) { + let Ok(Some(mut chunk)) = state.store.get_draft_chunk(chunk_id).await else { + return; + }; + if chunk.status != "pending" || chunk.application_error.is_empty() { + return; + } + chunk.application_error.clear(); + chunk.last_seen_ms = current_time_ms(); + if let Err(error) = state.store.update_draft_chunk_evaluation(&chunk).await { + warn!(chunk_id, error = %error, "failed to clear proposal application error"); + } +} + +async fn require_current_proposal_evaluation( + state: &Arc, + workspace: &str, + sandbox: &Sandbox, + chunk: &DraftChunkRecord, + supplied_review_token: Option<&str>, +) -> Result { + if !chunk.review_token.is_empty() + && supplied_review_token.is_some_and(|token| token != chunk.review_token) + { + return Err(Status::failed_precondition( + "review token does not match the fetched proposal; refetch and review again", + )); + } + + let reuse = (!chunk.review_token.is_empty()).then_some(chunk.validation_result.as_str()); + let live = + evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, reuse).await?; + if !live.application_error.is_empty() { + persist_refreshed_evaluation(state, chunk, &live).await?; + return Err(Status::failed_precondition(format!( + "proposal is not applicable: {}", + live.application_error + ))); + } + + if chunk.review_token.is_empty() { + // Compatibility path for proposals stored before review tokens were + // introduced. Evaluate once, persist the token, and allow the legacy + // approval request to proceed against that exact live candidate. + let evaluated = + evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, None) + .await?; + persist_refreshed_evaluation(state, chunk, &evaluated).await?; + return Ok(evaluated); + } + + if live.review_token != chunk.review_token { + let refreshed = + evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, None) + .await?; + persist_refreshed_evaluation(state, chunk, &refreshed).await?; + return Err(Status::failed_precondition( + "proposal inputs changed; evaluation refreshed, refetch and review again", + )); + } + Ok(live) +} + struct AutoApproveChunkContext<'a> { sandbox: &'a Sandbox, workspace: &'a str, source: &'a str, resolved_from: &'a str, - current_policy: &'a ProtoSandboxPolicy, - credential_set: &'a CredentialSet, } async fn auto_approve_chunk( @@ -949,17 +1458,15 @@ async fn auto_approve_chunk( return Ok(()); } - // Mechanistic dedup may return an existing row whose proposed rule was - // edited after its original validation. Re-run the prover against that - // stored rule instead of trusting the incoming proposal's verdict. - let rule = decode_draft_chunk_rule(&chunk)? - .ok_or_else(|| Status::failed_precondition("draft chunk has no proposed rule"))?; - let validation_result = validation_result_for_agent_proposal( - context.current_policy.clone(), - &chunk.rule_name, - &rule, - context.credential_set, - ); + let live_evaluation = require_current_proposal_evaluation( + state, + context.workspace, + context.sandbox, + &chunk, + None, + ) + .await?; + let validation_result = live_evaluation.validation_result; if validation_result != "prover: no new findings" { info!( sandbox_id = %sandbox_id, @@ -993,24 +1500,36 @@ async fn auto_approve_chunk( .as_ref() .map(|spec| spec.providers.as_slice()) .unwrap_or_default(); - let provider_layers = provider_policy_layers_for_sandbox( + let merge_validation = sandbox_policy_merge_validation_data( state, context.workspace, context.sandbox, provider_names, ) .await?; - let (version, hash) = merge_chunk_into_policy( + let credential_binding_context = merge_validation.credential_binding_context(); + let merge_result = merge_chunk_into_policy_with_validation( state.store.as_ref(), sandbox_id, context.workspace, &chunk, - &provider_layers, + PolicyMergeValidationContext { + provider_layers: &merge_validation.provider_layers, + credential_binding: Some(&credential_binding_context), + }, ) - .await?; + .await; + let (version, hash) = match merge_result { + Ok(result) => result, + Err(status) => { + persist_pending_application_error(state, chunk_id, &status).await; + return Err(status); + } + }; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); + clear_pending_application_error(state, chunk_id).await; state .store .update_draft_chunk_status(chunk_id, "approved", Some(now_ms), None) @@ -1018,6 +1537,16 @@ async fn auto_approve_chunk( .map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?; state.sandbox_watch_bus.notify(sandbox_id); + if let Err(error) = + reconcile_pending_chunks_after_policy_change(state, context.workspace, context.sandbox) + .await + { + warn!( + sandbox_id, + error = %error, + "failed to reconcile pending policy proposals after auto-approval" + ); + } let source_label = if context.source.is_empty() { "unspecified" @@ -1051,8 +1580,8 @@ async fn auto_approve_chunk( } // TODO: share effective-policy lookup with `load_sandbox_policy` / -// `GetSandboxConfig`. They re-implement very similar global-settings + -// providers_v2 + compose logic; consolidating them is out of scope for the +// `GetSandboxConfig`. They re-implement very similar global-settings and +// profile-composition logic; consolidating them is out of scope for the // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, @@ -1061,7 +1590,12 @@ async fn current_effective_policy_for_sandbox( sandbox: &Sandbox, sandbox_id: &str, ) -> Result { - let mut policy = if let Some(record) = state + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.clone()) + .unwrap_or_default(); + let policy = if let Some(record) = state .store .get_latest_policy(sandbox_id) .await @@ -1077,34 +1611,43 @@ async fn current_effective_policy_for_sandbox( .unwrap_or_default() }; - let global_settings = load_global_settings(state.store.as_ref()).await?; - let policy_source = decode_policy_from_global_settings(&global_settings)?.map_or( - PolicySource::Sandbox, - |global_policy| { - policy = global_policy; + effective_policy_for_source(state, catalog, workspace, &provider_names, policy).await +} + +async fn effective_policy_for_source( + state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, + workspace: &str, + provider_names: &[String], + mut policy: ProtoSandboxPolicy, +) -> Result { + let global_settings = load_global_settings(state.store.as_ref()).await?; + let policy_source = decode_policy_from_global_settings(&global_settings)?.map_or( + PolicySource::Sandbox, + |global_policy| { + policy = global_policy; PolicySource::Global }, ); - let providers_v2_enabled = - bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; - if providers_v2_enabled && !matches!(policy_source, PolicySource::Global) { - let provider_names = sandbox - .spec - .as_ref() - .map(|spec| spec.providers.clone()) - .unwrap_or_default(); - let provider_layers = profile_provider_policy_layers_with_catalog( - state.store.as_ref(), - catalog, - workspace, - &provider_names, - ) - .await?; - if !provider_layers.is_empty() { - policy = compose_effective_policy(&policy, &provider_layers); - } - } + clear_provider_credentialed_markers(&mut policy); + let mut provider_context = provider_policy_context_with_catalog( + state.store.as_ref(), + catalog, + workspace, + provider_names, + ) + .await?; + if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() { + policy = compose_effective_policy(&policy, &provider_context.layers); + } + let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&policy))?; + extend_credentialed_scopes_from_policy_bindings( + &mut provider_context.credentialed_scopes, + &policy_credential_bindings, + &provider_context.endpointless_provider_names, + ); + stamp_provider_credentialed_endpoints(&mut policy, &provider_context.credentialed_scopes); Ok(policy) } @@ -1136,6 +1679,275 @@ pub(super) fn validate_candidate_effective_policy( validate_endpoint_ambiguities(&effective_policy) } +fn policy_static_credential_endpoint_bindings( + policy: Option<&ProtoSandboxPolicy>, +) -> Result>, Status> { + let mut bindings = HashMap::>::new(); + let Some(policy) = policy else { + return Ok(bindings); + }; + + for rule in policy.network_policies.values() { + for endpoint in &rule.endpoints { + let Some(binding) = endpoint.credential_binding.as_ref() else { + continue; + }; + let provider = binding.provider.trim(); + if provider.is_empty() { + return Err(Status::invalid_argument(format!( + "credential_binding.provider is required for endpoint '{}'", + endpoint.host + ))); + } + if provider != binding.provider { + return Err(Status::invalid_argument(format!( + "credential_binding.provider '{}' must not contain leading or trailing whitespace", + binding.provider + ))); + } + if endpoint.host.trim().is_empty() { + return Err(Status::invalid_argument(format!( + "credential-bound endpoint for provider '{provider}' must define a host" + ))); + } + let ports = if endpoint.ports.is_empty() { + vec![endpoint.port] + } else { + endpoint.ports.clone() + }; + if ports + .iter() + .any(|port| *port == 0 || *port > u32::from(u16::MAX)) + { + return Err(Status::invalid_argument(format!( + "credential-bound endpoint '{}' for provider '{provider}' must define ports in range 1..=65535", + endpoint.host + ))); + } + let provider_bindings = bindings.entry(provider.to_string()).or_default(); + for port in ports { + let candidate = StaticCredentialEndpointBinding { + host: endpoint.host.clone(), + port, + path: endpoint.path.clone(), + }; + if !provider_bindings.contains(&candidate) { + provider_bindings.push(candidate); + } + } + } + } + + for endpoints in bindings.values_mut() { + endpoints.sort_by(|left, right| { + (&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path)) + }); + } + Ok(bindings) +} + +pub(super) fn policy_has_credential_binding_for_provider( + policy: &ProtoSandboxPolicy, + provider_name: &str, +) -> bool { + policy.network_policies.values().any(|rule| { + rule.endpoints.iter().any(|endpoint| { + endpoint + .credential_binding + .as_ref() + .is_some_and(|binding| binding.provider == provider_name) + }) + }) +} + +fn validate_policy_credential_binding_context( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], + policy: &ProtoSandboxPolicy, + bindings: &HashMap>, +) -> Result<(), Status> { + for provider_name in bindings.keys() { + let record = records + .iter() + .find(|record| record.name == *provider_name) + .ok_or_else(|| { + Status::failed_precondition(format!( + "credential_binding references provider '{provider_name}', but that provider is not attached to the sandbox" + )) + })?; + let profile = super::provider::get_provider_type_profile_for_scope( + catalog, + &record.provider.r#type, + &record.provider.profile_workspace, + ) + .ok_or_else(|| { + Status::failed_precondition(format!( + "credential_binding provider '{provider_name}' has no provider profile" + )) + })?; + if !profile.to_proto().endpoints.is_empty() { + return Err(Status::failed_precondition(format!( + "credential_binding provider '{provider_name}' profile already defines endpoints; \ + profile endpoints remain the credential boundary" + ))); + } + } + + validate_policy_signing_credential_sources(catalog, records, policy)?; + Ok(()) +} + +const SIGV4_REQUIRED_CREDENTIAL_KEYS: [&str; 2] = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]; + +fn validate_policy_signing_credential_sources( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], + policy: &ProtoSandboxPolicy, +) -> Result<(), Status> { + for rule in policy.network_policies.values() { + for endpoint in &rule.endpoints { + if endpoint.credential_signing.is_empty() { + continue; + } + + let source = endpoint.credential_binding.as_ref().map_or_else( + || { + records.iter().find_map(|record| { + signing_profile_for_record(catalog, record).filter(|profile| { + profile_declares_sigv4_credentials(profile) + && !profile.endpoints.is_empty() + && signed_endpoint_is_covered( + endpoint, + &profile.to_proto().endpoints, + ) + }) + }) + }, + |binding| { + records + .iter() + .find(|record| record.name == binding.provider) + .and_then(|record| signing_profile_for_record(catalog, record)) + .filter(|profile| { + profile_declares_sigv4_credentials(profile) + && profile.endpoints.is_empty() + }) + }, + ); + + if source.is_none() { + let selector = format_endpoint_selector(endpoint); + return Err(Status::failed_precondition(format!( + "credential_signing endpoint '{selector}' has no resolvable AWS credential source; attach an endpoint-bearing provider profile that declares AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and covers this endpoint, or set credential_binding.provider to an attached endpointless profile that declares those credentials" + ))); + } + } + } + Ok(()) +} + +fn signing_profile_for_record( + catalog: &EffectiveProviderProfileCatalog, + record: &super::provider::ProviderEnvironmentRecord, +) -> Option { + super::provider::get_provider_type_profile_for_scope( + catalog, + &record.provider.r#type, + &record.provider.profile_workspace, + ) +} + +fn profile_declares_sigv4_credentials(profile: &openshell_providers::ProviderTypeProfile) -> bool { + let env_vars = profile.credential_env_vars(); + SIGV4_REQUIRED_CREDENTIAL_KEYS + .iter() + .all(|required| env_vars.contains(required)) +} + +fn signed_endpoint_is_covered( + signed: &NetworkEndpoint, + profile_endpoints: &[NetworkEndpoint], +) -> bool { + endpoint_ports_for_validation(signed) + .into_iter() + .all(|port| { + profile_endpoints.iter().any(|profile| { + endpoint_ports_for_validation(profile).contains(&port) + && host_pattern_covers(&profile.host, &signed.host) + && path_pattern_covers(&profile.path, &signed.path) + }) + }) +} + +fn endpoint_ports_for_validation(endpoint: &NetworkEndpoint) -> Vec { + if endpoint.ports.is_empty() { + vec![endpoint.port] + } else { + endpoint.ports.clone() + } +} + +fn host_pattern_covers(binding_pattern: &str, policy_pattern: &str) -> bool { + if binding_pattern.eq_ignore_ascii_case(policy_pattern) { + return true; + } + if contains_glob_syntax(policy_pattern) { + return false; + } + host_matches(binding_pattern, policy_pattern).unwrap_or(false) +} + +fn path_pattern_covers(binding_pattern: &str, policy_pattern: &str) -> bool { + if binding_pattern == policy_pattern || matches!(binding_pattern, "" | "**" | "/**") { + return true; + } + if contains_glob_syntax(policy_pattern) { + return false; + } + EndpointPathPattern::new(binding_pattern).matches(policy_pattern) +} + +fn contains_glob_syntax(value: &str) -> bool { + value + .chars() + .any(|character| matches!(character, '*' | '?' | '[')) +} + +fn format_endpoint_selector(endpoint: &NetworkEndpoint) -> String { + let ports = endpoint_ports_for_validation(endpoint) + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + format!("{}:{ports}{}", endpoint.host, endpoint.path) +} + +async fn validate_policy_credential_bindings_for_sandbox( + state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, + workspace: &str, + provider_names: &[String], + policy: &ProtoSandboxPolicy, +) -> Result>, Status> { + let bindings = policy_static_credential_endpoint_bindings(Some(policy))?; + let has_signing = policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| !endpoint.credential_signing.is_empty()) + }); + if bindings.is_empty() && !has_signing { + return Ok(bindings); + } + let records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + provider_names, + ) + .await?; + validate_policy_credential_binding_context(catalog, &records, policy, &bindings)?; + Ok(bindings) +} + async fn provider_policy_layers_for_sandbox( state: &ServerState, workspace: &str, @@ -1143,22 +1955,21 @@ async fn provider_policy_layers_for_sandbox( provider_names: &[String], ) -> Result, Status> { let global_settings = load_global_settings(state.store.as_ref()).await?; - if decode_policy_from_global_settings(&global_settings)?.is_some() - || !bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)? - { + if decode_policy_from_global_settings(&global_settings)?.is_some() { return Ok(Vec::new()); } let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), workspace) .await?; - let layers = profile_provider_policy_layers_with_catalog( + let layers = provider_policy_context_with_catalog( state.store.as_ref(), &catalog, workspace, provider_names, ) - .await?; + .await? + .layers; debug!( sandbox_id = %sandbox.object_id(), provider_layer_count = layers.len(), @@ -1195,7 +2006,25 @@ pub(super) async fn validate_candidate_provider_attachments( let base_policy = current_base_policy_for_sandbox(state.store.as_ref(), sandbox).await?; let provider_layers = provider_policy_layers_for_sandbox(state, workspace, sandbox, provider_names).await?; - validate_candidate_effective_policy(&base_policy, &provider_layers) + validate_candidate_effective_policy(&base_policy, &provider_layers)?; + let effective_policy = if provider_layers.is_empty() { + base_policy + } else { + compose_effective_policy(&base_policy, &provider_layers) + }; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + validate_policy_credential_bindings_for_sandbox( + state, + &catalog, + workspace, + provider_names, + &effective_policy, + ) + .await?; + Ok(()) } pub(super) async fn provider_policy_composition_enabled(store: &Store) -> Result { @@ -1204,8 +2033,7 @@ pub(super) async fn provider_policy_composition_enabled(store: &Store) -> Result } fn provider_policy_composition_enabled_in(settings: &StoredSettings) -> Result { - Ok(decode_policy_from_global_settings(settings)?.is_none() - && bool_setting_enabled(settings, settings::PROVIDERS_V2_ENABLED_KEY)?) + Ok(decode_policy_from_global_settings(settings)?.is_none()) } async fn validate_provider_composition_for_existing_sandboxes( @@ -1245,13 +2073,14 @@ async fn validate_provider_composition_for_existing_sandboxes( .expect("catalog was inserted for sandbox workspace"); let base_policy = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?; - let provider_layers = profile_provider_policy_layers_with_catalog( + let provider_layers = provider_policy_context_with_catalog( state.store.as_ref(), catalog, &workspace, provider_names, ) - .await?; + .await? + .layers; validate_candidate_effective_policy(&base_policy, &provider_layers).map_err(|error| { Status::failed_precondition(format!( "cannot activate provider policy composition: sandbox '{}/{}' has an invalid effective policy: {}", @@ -1271,6 +2100,36 @@ async fn validate_provider_composition_for_existing_sandboxes( Ok(()) } +pub async fn validate_provider_composition_startup_preflight( + state: &ServerState, +) -> Result<(), Status> { + if provider_policy_composition_enabled(state.store.as_ref()).await? { + validate_provider_composition_for_existing_sandboxes(state).await?; + } + Ok(()) +} + +pub(super) async fn validate_candidate_sandbox_credential_policy( + state: &ServerState, + workspace: &str, + provider_names: &[String], + policy: Option<&ProtoSandboxPolicy>, +) -> Result<(), Status> { + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let effective = effective_policy_for_source( + state, + &catalog, + workspace, + provider_names, + policy.cloned().unwrap_or_default(), + ) + .await?; + validate_uninspected_credentialed_endpoints(&effective) +} + fn truncate_for_log(input: &str, max_chars: usize) -> String { let mut chars = input.chars(); let truncated: String = chars.by_ref().take(max_chars).collect(); @@ -1544,8 +2403,13 @@ pub(super) async fn handle_get_sandbox_config( let global_settings = load_global_settings(state.store.as_ref()).await?; let sandbox_settings = load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; - let providers_v2_enabled = - bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; + let mut provider_policy_context = provider_policy_context_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &workspace, + &sandbox_provider_names, + ) + .await?; let mut global_policy_version: u32 = 0; @@ -1565,28 +2429,41 @@ pub(super) async fn handle_get_sandbox_config( } } - if providers_v2_enabled - && !matches!(policy_source, PolicySource::Global) + if let Some(source_policy) = policy.as_mut() { + // Never trust provenance supplied by a persisted/user-authored policy. + // The gateway derives it from the attached provider catalog below. + clear_provider_credentialed_markers(source_policy); + } + + if !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() + && !provider_policy_context.layers.is_empty() { - let provider_layers = profile_provider_policy_layers_with_catalog( - state.store.as_ref(), - &provider_profile_catalog, - &workspace, - &sandbox_provider_names, - ) - .await?; - if !provider_layers.is_empty() { - let effective_policy = compose_effective_policy(source_policy, &provider_layers); - validate_policy_safety(&effective_policy).map_err(|error| { - Status::failed_precondition(format!( - "provider composition produced an invalid effective policy: {}", - error.message() - )) - })?; - policy_hash = deterministic_policy_hash(&effective_policy); - policy = Some(effective_policy); - } + let effective_policy = + compose_effective_policy(source_policy, &provider_policy_context.layers); + validate_policy_safety(&effective_policy).map_err(|error| { + Status::failed_precondition(format!( + "provider composition produced an invalid effective policy: {}", + error.message() + )) + })?; + policy_hash = deterministic_policy_hash(&effective_policy); + policy = Some(effective_policy); + } + + let policy_credential_bindings = policy_static_credential_endpoint_bindings(policy.as_ref())?; + extend_credentialed_scopes_from_policy_bindings( + &mut provider_policy_context.credentialed_scopes, + &policy_credential_bindings, + &provider_policy_context.endpointless_provider_names, + ); + if let Some(effective_policy) = policy.as_mut() { + stamp_provider_credentialed_endpoints( + effective_policy, + &provider_policy_context.credentialed_scopes, + ); + report_uninspected_credentialed_endpoints(effective_policy, &sandbox_id); + policy_hash = deterministic_policy_hash(effective_policy); } if let Some(policy) = policy.as_ref() { @@ -1609,12 +2486,24 @@ pub(super) async fn handle_get_sandbox_config( policy_source, &supervisor_middleware_services, state.config.policy_validation_failure_mode, + state.sandbox_jwt_issuer.is_some(), ); - let provider_env_revision = compute_provider_env_revision_with_catalog( + if let Some(policy) = policy.as_ref() { + validate_policy_credential_bindings_for_sandbox( + state.as_ref(), + &provider_profile_catalog, + &workspace, + &sandbox_provider_names, + policy, + ) + .await?; + } + let provider_env_revision = compute_provider_env_revision_with_catalog_and_policy_bindings( state.store.as_ref(), &provider_profile_catalog, &workspace, &sandbox_provider_names, + &policy_credential_bindings, ) .await?; @@ -1634,6 +2523,7 @@ pub(super) async fn handle_get_sandbox_config( .policy_validation_failure_mode .as_str() .to_string(), + extension_authentication_enabled: state.sandbox_jwt_issuer.is_some(), })) } @@ -1649,14 +2539,32 @@ async fn compute_provider_env_revision( compute_provider_env_revision_with_catalog(store, &catalog, workspace, provider_names).await } +#[cfg(test)] pub(super) async fn compute_provider_env_revision_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], +) -> Result { + compute_provider_env_revision_with_catalog_and_policy_bindings( + store, + catalog, + workspace, + provider_names, + &HashMap::new(), + ) + .await +} + +async fn compute_provider_env_revision_with_catalog_and_policy_bindings( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + workspace: &str, + provider_names: &[String], + policy_bindings: &HashMap>, ) -> Result { let mut hasher = Sha256::new(); - hasher.update(b"openshell-provider-env-revision-v1"); + hasher.update(b"openshell-provider-env-revision-v4"); for provider_name in provider_names { hasher.update(provider_name.as_bytes()); @@ -1668,13 +2576,22 @@ pub(super) async fn compute_provider_env_revision_with_catalog( })? { Some(record) => { hasher.update(record.id.as_bytes()); - hasher.update(record.updated_at_ms.to_le_bytes()); + hasher.update(record.resource_version.to_le_bytes()); let provider = Provider::decode(record.payload.as_slice()).map_err(|e| { Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; + let refresh_states = + crate::provider_refresh::list_refresh_states_for_provider(store, &record.id) + .await?; + hash_provider_refresh_states(&refresh_states, &mut hasher)?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(catalog, &provider.r#type, &mut hasher); + hash_provider_profile_revision( + catalog, + &provider.r#type, + &provider.profile_workspace, + &mut hasher, + ); let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1694,40 +2611,181 @@ pub(super) async fn compute_provider_env_revision_with_catalog( } } + hash_policy_credential_bindings(policy_bindings, &mut hasher); + let digest = hasher.finalize(); Ok(u64::from_le_bytes(digest[..8].try_into().map_err( |_| Status::internal("provider env revision digest too short"), )?)) } -fn hash_provider_profile_revision( +#[cfg(test)] +fn compute_provider_env_revision_from_records( catalog: &EffectiveProviderProfileCatalog, - provider_type: &str, - hasher: &mut Sha256, -) { - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - catalog.hash_profile_revision(profile_id, hasher); + records: &[super::provider::ProviderEnvironmentRecord], +) -> Result { + compute_provider_env_revision_from_records_and_policy_bindings( + catalog, + records, + &HashMap::new(), + ) } -#[cfg(test)] -async fn profile_provider_policy_layers( - store: &Store, - workspace: &str, - provider_names: &[String], -) -> Result, Status> { - let catalog = ProviderProfileSources::with_default_sources() - .snapshot_catalog(store, workspace) +fn compute_provider_env_revision_from_records_and_policy_bindings( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], + policy_bindings: &HashMap>, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"openshell-provider-env-revision-v4"); + + for record in records { + hasher.update(record.name.as_bytes()); + hasher.update(record.object_id.as_bytes()); + hasher.update(record.resource_version.to_le_bytes()); + + let provider = &record.provider; + hash_provider_refresh_states(&record.refresh_states, &mut hasher)?; + hasher.update(provider.r#type.as_bytes()); + hash_provider_profile_revision( + catalog, + &provider.r#type, + &provider.profile_workspace, + &mut hasher, + ); + + let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); + credential_keys.sort(); + for key in credential_keys { + hasher.update(key.as_bytes()); + } + let mut expiry_keys: Vec<_> = provider.credential_expires_at_ms.keys().collect(); + expiry_keys.sort(); + for key in expiry_keys { + hasher.update(key.as_bytes()); + hasher.update(provider.credential_expires_at_ms[key].to_le_bytes()); + } + } + + hash_policy_credential_bindings(policy_bindings, &mut hasher); + + let digest = hasher.finalize(); + Ok(u64::from_le_bytes(digest[..8].try_into().map_err( + |_| Status::internal("provider env revision digest too short"), + )?)) +} + +fn hash_provider_refresh_states( + states: &[openshell_core::proto::StoredProviderCredentialRefreshState], + hasher: &mut Sha256, +) -> Result<(), Status> { + let mut states = states.iter().collect::>(); + states.sort_by(|left, right| { + left.credential_key + .cmp(&right.credential_key) + .then_with(|| { + left.metadata + .as_ref() + .map(|metadata| metadata.id.as_str()) + .cmp(&right.metadata.as_ref().map(|metadata| metadata.id.as_str())) + }) + }); + for state in states { + hasher.update(b"provider-refresh-state"); + hasher.update(state.credential_key.as_bytes()); + hasher.update(state.strategy.to_le_bytes()); + hasher.update(crate::provider_refresh::effective_authorization_epoch(state)?.as_bytes()); + if let Some(metadata) = &state.metadata { + hasher.update(metadata.id.as_bytes()); + hasher.update(metadata.resource_version.to_le_bytes()); + } + let mut outputs = state.additional_output_keys.iter().collect::>(); + outputs.sort(); + for (output, key) in outputs { + hasher.update(output.as_bytes()); + hasher.update(key.as_bytes()); + } + } + Ok(()) +} + +fn hash_policy_credential_bindings( + bindings: &HashMap>, + hasher: &mut Sha256, +) { + let mut provider_names: Vec<_> = bindings.keys().collect(); + provider_names.sort(); + for provider_name in provider_names { + hasher.update(provider_name.as_bytes()); + let mut endpoints = bindings[provider_name].clone(); + endpoints.sort_by(|left, right| { + (&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path)) + }); + for endpoint in endpoints { + hasher.update(endpoint.host.as_bytes()); + hasher.update(endpoint.port.to_le_bytes()); + hasher.update(endpoint.path.as_bytes()); + } + } +} + +fn hash_provider_profile_revision( + catalog: &EffectiveProviderProfileCatalog, + provider_type: &str, + profile_workspace: &str, + hasher: &mut Sha256, +) { + catalog.hash_type_profile_revision_for_scope(provider_type, profile_workspace, hasher); +} + +#[cfg(test)] +async fn profile_provider_policy_layers( + store: &Store, + workspace: &str, + provider_names: &[String], +) -> Result, Status> { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store, workspace) .await?; profile_provider_policy_layers_with_catalog(store, &catalog, workspace, provider_names).await } +#[cfg(test)] async fn profile_provider_policy_layers_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], ) -> Result, Status> { + Ok( + provider_policy_context_with_catalog(store, catalog, workspace, provider_names) + .await? + .layers, + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CredentialedEndpointScope { + host: String, + ports: Vec, +} + +#[derive(Debug, Default)] +struct ProviderPolicyContext { + layers: Vec, + credentialed_scopes: Vec, + endpointless_provider_names: HashSet, +} + +async fn provider_policy_context_with_catalog( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + workspace: &str, + provider_names: &[String], +) -> Result { let mut layers = Vec::new(); + let mut credentialed_scopes = Vec::new(); + let mut endpointless_provider_names = HashSet::new(); for name in provider_names { let provider = store @@ -1737,10 +2795,9 @@ async fn profile_provider_policy_layers_with_catalog( .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + provider_type, &provider.profile_workspace, ) else { warn!( @@ -1751,23 +2808,195 @@ async fn profile_provider_policy_layers_with_catalog( continue; }; + if !super::provider::provider_profile_endpoints_are_active(&profile, &provider) { + endpointless_provider_names.insert(name.clone()); + continue; + } + let rule_name = openshell_policy::provider_rule_name(provider.object_name()); + let mut rule = profile.network_policy_rule(&rule_name); + if rule.endpoints.is_empty() { + endpointless_provider_names.insert(name.clone()); + } + if profile.has_credentialed_endpoints() { + for endpoint in &mut rule.endpoints { + endpoint.provider_credentialed = true; + let scope = CredentialedEndpointScope { + host: endpoint.host.to_ascii_lowercase(), + ports: endpoint_ports(endpoint), + }; + if !credentialed_scopes.contains(&scope) { + credentialed_scopes.push(scope); + } + } + } layers.push(ProviderPolicyLayer { rule_name: rule_name.clone(), - rule: profile.network_policy_rule(&rule_name), + rule, }); } - Ok(layers) + Ok(ProviderPolicyContext { + layers, + credentialed_scopes, + endpointless_provider_names, + }) +} + +fn endpoint_ports(endpoint: &NetworkEndpoint) -> Vec { + if endpoint.ports.is_empty() { + (endpoint.port > 0) + .then_some(endpoint.port) + .into_iter() + .collect() + } else { + endpoint.ports.clone() + } +} + +fn extend_credentialed_scopes_from_policy_bindings( + scopes: &mut Vec, + bindings: &HashMap>, + endpointless_provider_names: &HashSet, +) { + for (provider_name, provider_bindings) in bindings { + if !endpointless_provider_names.contains(provider_name) { + continue; + } + for binding in provider_bindings { + let scope = CredentialedEndpointScope { + host: binding.host.to_ascii_lowercase(), + ports: vec![binding.port], + }; + if !scopes.contains(&scope) { + scopes.push(scope); + } + } + } +} + +fn endpoint_matches_credentialed_scope( + endpoint: &NetworkEndpoint, + scope: &CredentialedEndpointScope, +) -> bool { + if !host_patterns_overlap(&endpoint.host, &scope.host).unwrap_or(false) { + return false; + } + let endpoint_ports = endpoint_ports(endpoint); + endpoint_ports.is_empty() + || scope.ports.is_empty() + || endpoint_ports.iter().any(|port| scope.ports.contains(port)) +} + +pub(super) fn clear_provider_credentialed_markers(policy: &mut ProtoSandboxPolicy) { + for rule in policy.network_policies.values_mut() { + for endpoint in &mut rule.endpoints { + endpoint.provider_credentialed = false; + } + } +} + +fn stamp_provider_credentialed_endpoints( + policy: &mut ProtoSandboxPolicy, + scopes: &[CredentialedEndpointScope], +) { + for rule in policy.network_policies.values_mut() { + for endpoint in &mut rule.endpoints { + endpoint.provider_credentialed = scopes + .iter() + .any(|scope| endpoint_matches_credentialed_scope(endpoint, scope)); + } + } +} + +/// A credentialed endpoint whose configured mode disables the L7 inspection a +/// reviewer would expect, without an explicit `allow_uninspected_credentials`. +struct UninspectedCredentialedEndpoint { + rule_name: String, + host: String, + port: u32, + mode: &'static str, +} + +/// Scan the effective policy for credentialed endpoints on uninspected modes. +/// +/// Explicit opt-ins are logged and skipped. Never logs credential names, +/// placeholders, or secret values. +fn find_uninspected_credentialed_endpoint( + policy: &ProtoSandboxPolicy, +) -> Option { + for (rule_name, rule) in &policy.network_policies { + for endpoint in &rule.endpoints { + if !endpoint.provider_credentialed { + continue; + } + + let mode = if endpoint.protocol.trim().is_empty() { + "L4-only" + } else if endpoint.tls.trim().eq_ignore_ascii_case("skip") { + "tls: skip" + } else { + continue; + }; + + if endpoint.allow_uninspected_credentials { + warn!( + rule_name, + host = %endpoint.host, + ports = ?endpoint_ports(endpoint), + mode, + "credentialed endpoint explicitly allows uninspected traffic" + ); + continue; + } + + return Some(UninspectedCredentialedEndpoint { + rule_name: rule_name.clone(), + host: endpoint.host.clone(), + port: endpoint_ports(endpoint) + .first() + .copied() + .unwrap_or(endpoint.port), + mode, + }); + } + } + None } -pub(super) fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result { - match settings.settings.get(key) { - None => Ok(false), - Some(StoredSettingValue::Bool(value)) => Ok(*value), - Some(_) => Err(Status::internal(format!( - "setting '{key}' has invalid value type; expected bool" - ))), +/// Admission gate for policy-authoring paths (create, attach, operator config +/// update). Rejects credentialed endpoints that would lose L7 inspection. +fn validate_uninspected_credentialed_endpoints(policy: &ProtoSandboxPolicy) -> Result<(), Status> { + let Some(violation) = find_uninspected_credentialed_endpoint(policy) else { + return Ok(()); + }; + + warn!( + rule_name = %violation.rule_name, + host = %violation.host, + port = violation.port, + mode = violation.mode, + "rejecting uninspected credentialed endpoint" + ); + Err(Status::failed_precondition(format!( + "credentialed endpoint '{}:{}' in rule '{}' uses {}; configure L7 inspection or explicitly set allow_uninspected_credentials: true", + violation.host, violation.port, violation.rule_name, violation.mode + ))) +} + +/// Delivery-path reporting for an already-persisted policy. Sandbox config +/// delivery must not fail closed here: refusing the config would crash-loop a +/// running supervisor. The runtime backstop denies the traffic instead. +fn report_uninspected_credentialed_endpoints(policy: &ProtoSandboxPolicy, sandbox_id: &str) { + if let Some(violation) = find_uninspected_credentialed_endpoint(policy) { + warn!( + sandbox_id, + rule_name = %violation.rule_name, + host = %violation.host, + port = violation.port, + mode = violation.mode, + "delivering credentialed endpoint without L7 inspection; the sandbox proxy will deny this traffic unless allow_uninspected_credentials is set" + ); } } @@ -1788,6 +3017,7 @@ pub(super) async fn handle_get_sandbox_provider_environment( request: Request, ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); + let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); @@ -1801,27 +3031,80 @@ pub(super) async fn handle_get_sandbox_provider_environment( let spec = sandbox .spec + .as_ref() .ok_or_else(|| Status::internal("sandbox has no spec"))?; - let provider_names = spec.providers; + let provider_names = spec.providers.clone(); let provider_profile_catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - let provider_env_revision = compute_provider_env_revision_with_catalog( + let provider_records = super::provider::load_provider_environment_records( state.store.as_ref(), - &provider_profile_catalog, &workspace, &provider_names, ) .await?; - let provider_environment = super::provider::resolve_provider_environment_with_catalog( - state.store.as_ref(), + let effective_policy = current_effective_policy_for_sandbox( + state.as_ref(), &provider_profile_catalog, &workspace, - &provider_names, + &sandbox, + &sandbox_id, ) .await?; + let policy_credential_bindings = + policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + validate_policy_credential_binding_context( + &provider_profile_catalog, + &provider_records, + &effective_policy, + &policy_credential_bindings, + )?; + let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + )?; + let mut provider_environment = + super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + &state.credentials, + Some(&sandbox_id), + ) + .await?; + + if supports_static_credential_bindings { + let unbound_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + !provider_environment + .static_credential_bindings + .contains_key(*key) + }) + .cloned() + .collect::>(); + for key in unbound_static_keys { + warn!( + sandbox_id = %sandbox_id, + key = %key, + "withholding unbound static provider credential from binding-capable supervisor" + ); + provider_environment.environment.remove(&key); + provider_environment.credential_expires_at_ms.remove(&key); + provider_environment.static_credential_keys.remove(&key); + } + } else { + for key in &provider_environment.static_credential_keys { + provider_environment.environment.remove(key); + provider_environment.credential_expires_at_ms.remove(key); + } + provider_environment.static_credential_bindings.clear(); + } info!( sandbox_id = %sandbox_id, @@ -1831,11 +3114,20 @@ pub(super) async fn handle_get_sandbox_provider_environment( "GetSandboxProviderEnvironment request completed successfully" ); + let non_secret_environment_keys = provider_environment + .environment + .keys() + .filter(|key| !provider_environment.static_credential_keys.contains(*key)) + .cloned() + .collect(); + Ok(Response::new(GetSandboxProviderEnvironmentResponse { environment: provider_environment.environment, provider_env_revision, credential_expires_at_ms: provider_environment.credential_expires_at_ms, dynamic_credentials: provider_environment.dynamic_credentials, + static_credential_bindings: provider_environment.static_credential_bindings, + non_secret_environment_keys, })) } @@ -1940,12 +3232,17 @@ async fn handle_update_config_inner( let mut new_policy = req.policy.ok_or_else(|| { Status::invalid_argument("policy is required for global policy update") })?; - normalize_process_identity_for_driver(&mut new_policy, state.compute.driver_kind()); + clear_provider_credentialed_markers(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) .await?; validate_candidate_effective_policy(&new_policy, &[])?; + if !policy_static_credential_endpoint_bindings(Some(&new_policy))?.is_empty() { + return Err(Status::failed_precondition( + "credential_binding is sandbox-scoped and cannot be used in a global policy", + )); + } let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); @@ -2220,25 +3517,27 @@ async fn handle_update_config_inner( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let merge_ops = parse_merge_operations(&req.merge_operations)?; validate_merge_operations_for_server(&merge_ops)?; - let provider_layers = - provider_policy_layers_for_sandbox(state, &workspace, &sandbox, &spec.providers) + let merge_validation = + sandbox_policy_merge_validation_data(state, &workspace, &sandbox, &spec.providers) .await?; + let credential_binding_context = merge_validation.credential_binding_context(); let atomic_context = AtomicPolicyWriteContext { expected_resource_version: req.expected_resource_version, provenance: &req.annotations, annotations: &req.annotations, }; - let mut baseline_policy = spec.policy.clone(); - if let Some(policy) = baseline_policy.as_mut() { - normalize_process_identity_for_driver(policy, state.compute.driver_kind()); - } + let baseline_policy = spec.policy.clone(); let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, &workspace, baseline_policy.as_ref(), &merge_ops, - &provider_layers, + PolicyMergeValidationContext { + provider_layers: &merge_validation.provider_layers, + credential_binding: Some(&credential_binding_context), + }, + None, Some(&atomic_context), ) .await?; @@ -2303,8 +3602,7 @@ async fn handle_update_config_inner( let mut new_policy = req .policy .ok_or_else(|| Status::invalid_argument("policy is required"))?; - normalize_process_identity_for_driver(&mut new_policy, state.compute.driver_kind()); - + clear_provider_credentialed_markers(&mut new_policy); let global_settings = load_global_settings(state.store.as_ref()).await?; if global_settings.settings.contains_key(POLICY_SETTING_KEY) { return Err(Status::failed_precondition( @@ -2318,7 +3616,7 @@ async fn handle_update_config_inner( .ok_or_else(|| Status::internal("sandbox has no spec"))?; if sandbox_caller { - if openshell_policy::strip_provider_rule_names(&mut new_policy) { + if strip_provider_rule_names(&mut new_policy) { debug!( sandbox_id = %sandbox_id, "UpdateConfig: stripped provider-derived policy entries from sandbox sync" @@ -2329,11 +3627,7 @@ async fn handle_update_config_inner( } let backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { - let mut comparable_baseline = baseline_policy.clone(); - normalize_process_identity_for_driver( - &mut comparable_baseline, - state.compute.driver_kind(), - ); + let comparable_baseline = baseline_policy.clone(); validate_static_fields_unchanged(&comparable_baseline, &new_policy)?; None } else { @@ -2345,6 +3639,35 @@ async fn handle_update_config_inner( let provider_layers = provider_policy_layers_for_sandbox(state, &workspace, &sandbox, &spec.providers).await?; validate_candidate_effective_policy(&new_policy, &provider_layers)?; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let effective_policy = if provider_layers.is_empty() { + new_policy.clone() + } else { + compose_effective_policy(&new_policy, &provider_layers) + }; + validate_policy_credential_bindings_for_sandbox( + state.as_ref(), + &provider_profile_catalog, + &workspace, + &spec.providers, + &effective_policy, + ) + .await?; + // Sandbox-authored syncs replay a policy the supervisor already discovered + // on disk. Rejecting it here would crash-loop the sandbox instead of + // surfacing an operator decision, so only operator-authored updates gate. + if !sandbox_caller { + validate_candidate_sandbox_credential_policy( + state, + &workspace, + &spec.providers, + Some(&new_policy), + ) + .await?; + } let _sandbox_sync_guard = if backfill_policy.is_some() { Some(state.compute.sandbox_sync_guard().await) @@ -2888,6 +4211,21 @@ pub(super) async fn handle_submit_policy_analysis( &sandbox_id, ) .await?; + let active_policy_version = state + .store + .get_latest_policy(&sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch latest policy failed: {error}")))? + .map_or(0, |record| record.version); + reconcile_pending_chunks_covered_by_policy( + state, + &sandbox_id, + ¤t_policy, + active_policy_version, + ) + .await?; + let current_base_policy = + current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?; // Auto-approval is an opt-in behavior, sourced from the settings model // (sandbox or gateway scope) so it can be flipped on a running sandbox @@ -2914,6 +4252,19 @@ pub(super) async fn handle_submit_policy_analysis( &provider_names_for_creds, ) .await?; + let merge_validation = sandbox_policy_merge_validation_data_with_catalog( + state, + &workspace, + &sandbox, + &provider_names_for_creds, + &provider_profile_catalog, + ) + .await?; + let credential_binding_context = merge_validation.credential_binding_context(); + let proposal_validation_context = PolicyMergeValidationContext { + provider_layers: &merge_validation.provider_layers, + credential_binding: Some(&credential_binding_context), + }; let current_version = state .store @@ -2954,14 +4305,98 @@ pub(super) async fn handle_submit_policy_analysis( continue; } - let now_ms = current_time_ms(); - let proposed_rule_bytes = chunk - .proposed_rule + let rule_ref = chunk.proposed_rule.as_ref().expect("checked above"); + let incoming_observation_key = rule_ref.endpoints.first().and_then(|endpoint| { + rule_ref.binaries.first().map(|binary| { + ( + endpoint.host.to_lowercase(), + endpoint.port as i32, + binary.path.clone(), + ) + }) + }); + let existing_mechanistic = if req.analysis_mode == "mechanistic" { + let chunks = state + .store + .list_draft_chunks(&sandbox_id, None) + .await + .map_err(|error| { + Status::internal(format!("list draft chunks for dedup failed: {error}")) + })?; + incoming_observation_key + .as_ref() + .and_then(|(host, port, binary)| { + chunks.into_iter().find(|existing| { + existing.host == *host + && existing.port == *port + && existing.binary == *binary + }) + }) + } else { + None + }; + // A duplicate observation updates the existing pending decision; it + // must not replace an edited rule with the new mapper payload. Build + // and hash the candidate from the stored rule, while retaining the + // incoming observation key for the persistence-layer dedup lookup. + let existing_pending_rule = existing_mechanistic .as_ref() - .map(Message::encode_to_vec) - .unwrap_or_default(); + .filter(|existing| existing.status == "pending") + .map(decode_draft_chunk_rule) + .transpose()? + .flatten(); + let evaluation_rule = existing_pending_rule.as_ref().unwrap_or(rule_ref); + let evaluation_rule_name = existing_mechanistic + .as_ref() + .filter(|existing| existing.status == "pending") + .map_or(chunk.rule_name.as_str(), |existing| { + existing.rule_name.as_str() + }); + let evaluation_mode = if existing_pending_rule.is_some() { + "stored" + } else { + req.analysis_mode.as_str() + }; + let reusable_validation = existing_mechanistic + .as_ref() + .filter(|existing| existing.status == "pending" && !existing.review_token.is_empty()) + .map(|existing| existing.validation_result.as_str()); + let mut evaluation = evaluate_proposal_candidate( + ¤t_base_policy, + ¤t_policy, + evaluation_rule_name, + evaluation_rule, + evaluation_mode, + &credential_set, + proposal_validation_context, + reusable_validation, + ); + if let Some(existing) = &existing_mechanistic + && !existing.review_token.is_empty() + && evaluation.review_token != existing.review_token + { + evaluation = evaluate_proposal_candidate( + ¤t_base_policy, + ¤t_policy, + evaluation_rule_name, + evaluation_rule, + evaluation_mode, + &credential_set, + proposal_validation_context, + None, + ); + } + if req.analysis_mode != "mechanistic" && !evaluation.application_error.is_empty() { + rejected += 1; + rejection_reasons.push(format!( + "chunk '{}': {}", + chunk.rule_name, evaluation.application_error + )); + continue; + } - let rule_ref = chunk.proposed_rule.as_ref().expect("checked above"); + let now_ms = current_time_ms(); + let proposed_rule_bytes = evaluation.rule.encode_to_vec(); let (ep_host, ep_port) = rule_ref .endpoints .first() @@ -2973,17 +4408,6 @@ pub(super) async fn handle_submit_policy_analysis( .map(|b| b.path.clone()) .unwrap_or_default(); - // The prover runs on every proposal regardless of `analysis_mode`. - // Source provenance (mechanistic vs agent_authored) is preserved in - // OCSF audit fields, but the safety decision is grounded in the - // merged-policy consequence, not the author — proposer-agnostic. - let validation_result = validation_result_for_agent_proposal( - current_policy.clone(), - &chunk.rule_name, - rule_ref, - &credential_set, - ); - let record = DraftChunkRecord { // The handler proposes an id; the store may swap it for an // existing row's id on dedup. Always trust `effective_id` for @@ -2992,10 +4416,10 @@ pub(super) async fn handle_submit_policy_analysis( sandbox_id: sandbox_id.clone(), draft_version, status: "pending".to_string(), - rule_name: chunk.rule_name.clone(), + rule_name: evaluation.rule_name.clone(), proposed_rule: proposed_rule_bytes, rationale: chunk.rationale.clone(), - security_notes: generate_security_notes(rule_ref), + security_notes: generate_security_notes(&evaluation.rule), confidence: f64::from(chunk.confidence.clamp(0.0, 1.0)), created_at_ms: now_ms, decided_at_ms: None, @@ -3013,8 +4437,14 @@ pub(super) async fn handle_submit_policy_analysis( } else { now_ms }, - validation_result: validation_result.clone(), + validation_result: evaluation.validation_result.clone(), rejection_reason: String::new(), + application_error: evaluation.application_error.clone(), + review_token: evaluation.review_token.clone(), + current_effective_policy_hash: evaluation.current_hash(), + candidate_effective_policy_hash: evaluation.candidate_hash(), + current_effective_policy: Some(evaluation.current_effective_policy.clone()), + candidate_effective_policy: evaluation.candidate_effective_policy.clone(), }; // Mechanistic mode dedups N denials targeting the same endpoint // into one chunk. All other modes (agent-authored proposals, future @@ -3028,6 +4458,14 @@ pub(super) async fn handle_submit_policy_analysis( .put_draft_chunk(&record, dedup_key.as_deref(), &workspace) .await .map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?; + if effective_id != record.id + && existing_mechanistic.as_ref().is_some_and(|existing| { + existing.status == "pending" && existing.review_token != evaluation.review_token + }) + && let Some(existing) = existing_mechanistic.as_ref() + { + persist_refreshed_evaluation(state, existing, &evaluation).await?; + } accepted += 1; // Implicit supersede: any other pending chunk for the same @@ -3083,12 +4521,11 @@ pub(super) async fn handle_submit_policy_analysis( workspace: &workspace, source: &req.analysis_mode, resolved_from, - current_policy: ¤t_policy, - credential_set: &credential_set, }, ) .await { + persist_pending_application_error(state, &effective_id, &err).await; warn!( chunk_id = %effective_id, sandbox_id = %sandbox_id, @@ -3253,6 +4690,15 @@ async fn handle_approve_draft_chunk_inner( ))); } + require_current_proposal_evaluation( + state, + &workspace, + &sandbox, + &chunk, + Some(&req.review_token), + ) + .await?; + info!( sandbox_id = %sandbox_id, chunk_id = %req.chunk_id, @@ -3269,19 +4715,31 @@ async fn handle_approve_draft_chunk_inner( .as_ref() .map(|spec| spec.providers.as_slice()) .unwrap_or_default(); - let provider_layers = - provider_policy_layers_for_sandbox(state, &workspace, &sandbox, provider_names).await?; - let (version, hash) = merge_chunk_into_policy( + let merge_validation = + sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?; + let credential_binding_context = merge_validation.credential_binding_context(); + let merge_result = merge_chunk_into_policy_with_validation( state.store.as_ref(), &sandbox_id, &workspace, &chunk, - &provider_layers, + PolicyMergeValidationContext { + provider_layers: &merge_validation.provider_layers, + credential_binding: Some(&credential_binding_context), + }, ) - .await?; + .await; + let (version, hash) = match merge_result { + Ok(result) => result, + Err(status) => { + persist_pending_application_error(state, &req.chunk_id, &status).await; + return Err(status); + } + }; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); + clear_pending_application_error(state, &req.chunk_id).await; state .store .update_draft_chunk_status(&req.chunk_id, "approved", Some(now_ms), None) @@ -3289,6 +4747,15 @@ async fn handle_approve_draft_chunk_inner( .map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?; state.sandbox_watch_bus.notify(&sandbox_id); + if let Err(error) = + reconcile_pending_chunks_after_policy_change(state, &workspace, &sandbox).await + { + warn!( + sandbox_id, + error = %error, + "failed to reconcile pending policy proposals after approval" + ); + } emit_gateway_policy_audit_log( &sandbox_id, sandbox.object_name(), @@ -3480,6 +4947,31 @@ async fn handle_approve_all_draft_chunks_inner( return Err(Status::failed_precondition("no pending chunks to approve")); } + let chunks_to_approve = if req.approvals.is_empty() { + pending_chunks.clone() + } else { + let by_id = pending_chunks + .iter() + .map(|chunk| (chunk.id.as_str(), chunk)) + .collect::>(); + let mut selected = Vec::with_capacity(req.approvals.len()); + for approval in &req.approvals { + let chunk = by_id.get(approval.chunk_id.as_str()).ok_or_else(|| { + Status::failed_precondition(format!( + "chunk '{}' is not pending; refetch before bulk approval", + approval.chunk_id + )) + })?; + selected.push((*chunk).clone()); + } + selected + }; + let review_tokens = req + .approvals + .iter() + .map(|approval| (approval.chunk_id.as_str(), approval.review_token.as_str())) + .collect::>(); + info!( sandbox_id = %sandbox_id, pending_count = pending_chunks.len(), @@ -3487,39 +4979,23 @@ async fn handle_approve_all_draft_chunks_inner( "ApproveAllDraftChunks: starting bulk approval" ); - let mut chunks_approved: u32 = 0; let mut chunks_skipped: u32 = 0; - let mut last_version: i64 = 0; - let mut last_hash = String::new(); let provider_names = sandbox .spec .as_ref() .map(|spec| spec.providers.as_slice()) .unwrap_or_default(); - let provider_layers = - provider_policy_layers_for_sandbox(state, &workspace, &sandbox, provider_names).await?; - let mut bulk_candidate = - current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?; - for chunk in &pending_chunks { - let security_notes = current_draft_chunk_security_notes(chunk)?; - if !req.include_security_flagged && !security_notes.is_empty() { - continue; - } - let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) - .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; - let operations = [PolicyMergeOp::AddRule { - rule_name: chunk.rule_name.clone(), - rule, - }]; - validate_merge_operations_for_server(&operations)?; - bulk_candidate = merge_policy(bulk_candidate, &operations) - .map_err(map_policy_merge_error)? - .policy; - } - validate_policy_safety(&bulk_candidate)?; - validate_candidate_effective_policy(&bulk_candidate, &provider_layers)?; - - for chunk in &pending_chunks { + let merge_validation = + sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?; + let credential_binding_context = merge_validation.credential_binding_context(); + let merge_validation_context = PolicyMergeValidationContext { + provider_layers: &merge_validation.provider_layers, + credential_binding: Some(&credential_binding_context), + }; + let mut staged_policy = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?; + let mut expected_effective_hash: Option = None; + let mut accepted = Vec::<(DraftChunkRecord, PolicyMergeOp, String)>::new(); + for chunk in &chunks_to_approve { let security_notes = current_draft_chunk_security_notes(chunk)?; if !req.include_security_flagged && !security_notes.is_empty() { info!( @@ -3533,28 +5009,151 @@ async fn handle_approve_all_draft_chunks_inner( continue; } + let supplied_token = review_tokens.get(chunk.id.as_str()).copied().unwrap_or(""); + let evaluation = match require_current_proposal_evaluation( + state, + &workspace, + &sandbox, + chunk, + Some(supplied_token), + ) + .await + { + Ok(evaluation) => evaluation, + Err(status) if status.code() == tonic::Code::FailedPrecondition => { + info!( + sandbox_id = %sandbox_id, + chunk_id = %chunk.id, + reason = %status.message(), + "ApproveAllDraftChunks: skipping stale or invalid candidate" + ); + chunks_skipped += 1; + continue; + } + Err(status) => return Err(status), + }; + + let current_hash = evaluation.current_hash(); + if let Some(expected_hash) = expected_effective_hash.as_deref() + && current_hash != expected_hash + { + return Err(Status::failed_precondition( + "proposal inputs changed during bulk review; refetch and review again", + )); + } + info!( sandbox_id = %sandbox_id, chunk_id = %chunk.id, rule_name = %chunk.rule_name, host = %chunk.host, port = chunk.port, - "ApproveAllDraftChunks: merging chunk" + "ApproveAllDraftChunks: staging chunk" ); - let (version, hash) = merge_chunk_into_policy( + let operation = PolicyMergeOp::AddRule { + rule_name: evaluation.rule_name, + rule: evaluation.rule, + }; + let candidate = match stage_validated_merge_operation( + &staged_policy, + &operation, + merge_validation_context, + ) { + Ok(candidate) => candidate, + Err(status) => { + persist_pending_application_error(state, &chunk.id, &status).await; + info!( + sandbox_id = %sandbox_id, + chunk_id = %chunk.id, + reason = %status.message(), + "ApproveAllDraftChunks: skipping chunk that conflicts with the staged batch" + ); + chunks_skipped += 1; + continue; + } + }; + let chunk_summary = summarize_draft_chunk_rule(chunk)?; + if expected_effective_hash.is_none() { + expected_effective_hash = Some(current_hash); + } + staged_policy = candidate; + accepted.push((chunk.clone(), operation, chunk_summary)); + } + + let (last_version, last_hash) = if accepted.is_empty() { + (0, String::new()) + } else { + // Rebuild the staged candidate from fresh live inputs immediately before + // persistence. This reuses each unchanged chunk's cached prover result; + // it does not silently bind the request to a newly refreshed token. + for (chunk, _, _) in &accepted { + let supplied_token = review_tokens.get(chunk.id.as_str()).copied().unwrap_or(""); + require_current_proposal_evaluation( + state, + &workspace, + &sandbox, + chunk, + Some(supplied_token), + ) + .await?; + } + + let final_merge_validation = + sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names) + .await?; + let final_credential_binding_context = final_merge_validation.credential_binding_context(); + let final_validation_context = PolicyMergeValidationContext { + provider_layers: &final_merge_validation.provider_layers, + credential_binding: Some(&final_credential_binding_context), + }; + let final_base = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?; + let mut rebuilt_policy = final_base.clone(); + for (_, operation, _) in &accepted { + rebuilt_policy = stage_validated_merge_operation( + &rebuilt_policy, + operation, + final_validation_context, + )?; + } + if deterministic_policy_hash(&rebuilt_policy) != deterministic_policy_hash(&staged_policy) { + return Err(Status::failed_precondition( + "proposal inputs changed during bulk review; refetch and review again", + )); + } + + let operations = accepted + .iter() + .map(|(_, operation, _)| operation.clone()) + .collect::>(); + let expected_hash = expected_effective_hash.as_deref().ok_or_else(|| { + Status::failed_precondition("bulk approval has no reviewed policy snapshot") + })?; + match apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, &workspace, - chunk, - &provider_layers, + Some(&final_base), + &operations, + final_validation_context, + Some(expected_hash), + None, ) - .await?; - last_version = version; - last_hash = hash; - let chunk_summary = summarize_draft_chunk_rule(chunk)?; + .await + { + Ok((version, hash, _)) => (version, hash), + Err(status) => { + for (chunk, _, _) in &accepted { + persist_pending_application_error(state, &chunk.id, &status).await; + } + return Err(status); + } + } + }; + for (chunk, _, chunk_summary) in &accepted { let now_ms = current_time_ms(); + clear_pending_application_error(state, &chunk.id).await; state .store .update_draft_chunk_status(&chunk.id, "approved", Some(now_ms), None) @@ -3566,14 +5165,23 @@ async fn handle_approve_all_draft_chunks_inner( sandbox.object_name(), "approved", format!("gateway approved draft chunk {}: {chunk_summary}", chunk.id), - version, + last_version, &last_hash, ); - chunks_approved += 1; emit_sandbox_policy_update_success(); } + let chunks_approved = u32::try_from(accepted.len()).unwrap_or(u32::MAX); state.sandbox_watch_bus.notify(&sandbox_id); + if let Err(error) = + reconcile_pending_chunks_after_policy_change(state, &workspace, &sandbox).await + { + warn!( + sandbox_id, + error = %error, + "failed to reconcile pending policy proposals after bulk approval" + ); + } emit_gateway_policy_audit_log( &sandbox_id, sandbox.object_name(), @@ -3656,12 +5264,17 @@ pub(super) async fn handle_edit_draft_chunk( ))); } - let rule_bytes = proposed_rule.encode_to_vec(); - state - .store - .update_draft_chunk_rule(&req.chunk_id, &rule_bytes) - .await - .map_err(|e| Status::internal(format!("update chunk rule failed: {e}")))?; + let mut edited_chunk = chunk.clone(); + edited_chunk.proposed_rule = proposed_rule.encode_to_vec(); + edited_chunk.review_token.clear(); + edited_chunk.validation_result.clear(); + edited_chunk.application_error.clear(); + edited_chunk.current_effective_policy = None; + edited_chunk.candidate_effective_policy = None; + let evaluation = + evaluate_stored_chunk_against_live_inputs(state, &workspace, &sandbox, &edited_chunk, None) + .await?; + persist_refreshed_evaluation(state, &edited_chunk, &evaluation).await?; info!( chunk_id = %req.chunk_id, @@ -3901,41 +5514,201 @@ pub(super) async fn handle_get_draft_history( // Policy helper functions // --------------------------------------------------------------------------- -/// Compute a deterministic SHA-256 hash of a `SandboxPolicy`. -fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { - let mut hasher = Sha256::new(); - hasher.update(policy.version.to_le_bytes()); - if let Some(fs) = &policy.filesystem { - hasher.update(fs.encode_to_vec()); +fn append_canonical_bytes(out: &mut Vec, value: &[u8]) { + out.extend_from_slice( + &u64::try_from(value.len()) + .expect("canonical value length fits in u64") + .to_le_bytes(), + ); + out.extend_from_slice(value); +} + +fn append_canonical_message(out: &mut Vec, value: &M) { + append_canonical_bytes(out, &value.encode_to_vec()); +} + +fn append_sorted_message_map( + out: &mut Vec, + label: &[u8], + values: &HashMap, +) { + append_canonical_bytes(out, label); + let mut entries = values.iter().collect::>(); + entries.sort_by_key(|(key, _)| key.as_str()); + out.extend_from_slice( + &u64::try_from(entries.len()) + .expect("canonical map length fits in u64") + .to_le_bytes(), + ); + for (key, value) in entries { + append_canonical_bytes(out, key.as_bytes()); + append_canonical_message(out, value); + } +} + +/// Encode a policy rule without depending on randomized protobuf map order. +fn canonical_rule_bytes(rule: &NetworkPolicyRule) -> Vec { + let mut map_free = rule.clone(); + for endpoint in &mut map_free.endpoints { + endpoint.graphql_persisted_queries.clear(); + for rule in &mut endpoint.rules { + if let Some(allow) = &mut rule.allow { + allow.query.clear(); + allow.params.clear(); + } + } + for deny in &mut endpoint.deny_rules { + deny.query.clear(); + deny.params.clear(); + } } - if let Some(ll) = &policy.landlock { - hasher.update(ll.encode_to_vec()); + + let mut out = Vec::new(); + append_canonical_message(&mut out, &map_free); + for (endpoint_index, endpoint) in rule.endpoints.iter().enumerate() { + out.extend_from_slice( + &u64::try_from(endpoint_index) + .expect("endpoint index fits in u64") + .to_le_bytes(), + ); + append_sorted_message_map( + &mut out, + b"graphql_persisted_queries", + &endpoint.graphql_persisted_queries, + ); + for (rule_index, rule) in endpoint.rules.iter().enumerate() { + out.extend_from_slice( + &u64::try_from(rule_index) + .expect("rule index fits in u64") + .to_le_bytes(), + ); + if let Some(allow) = &rule.allow { + append_sorted_message_map(&mut out, b"allow_query", &allow.query); + append_sorted_message_map(&mut out, b"allow_params", &allow.params); + } + } + for (rule_index, deny) in endpoint.deny_rules.iter().enumerate() { + out.extend_from_slice( + &u64::try_from(rule_index) + .expect("deny-rule index fits in u64") + .to_le_bytes(), + ); + append_sorted_message_map(&mut out, b"deny_query", &deny.query); + append_sorted_message_map(&mut out, b"deny_params", &deny.params); + } } - if let Some(p) = &policy.process { - hasher.update(p.encode_to_vec()); + out +} + +fn canonical_struct_bytes(value: &prost_types::Struct) -> Vec { + let mut out = Vec::new(); + let mut fields = value.fields.iter().collect::>(); + fields.sort_by_key(|(key, _)| key.as_str()); + out.extend_from_slice( + &u64::try_from(fields.len()) + .expect("struct field count fits in u64") + .to_le_bytes(), + ); + for (key, value) in fields { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_value_bytes(value)); } - let mut entries: Vec<_> = policy.network_policies.iter().collect(); - entries.sort_by_key(|(k, _)| k.as_str()); - for (key, value) in entries { - hasher.update(key.as_bytes()); - hasher.update(value.encode_to_vec()); - } - if !policy.network_middlewares.is_empty() { - hasher.update(b"network_middlewares"); - let mut entries: Vec<_> = policy.network_middlewares.iter().collect(); - entries.sort_by_key(|(name, _)| name.as_str()); - for (name, middleware) in entries { - hasher.update(name.as_bytes()); - let encoded = middleware.encode_to_vec(); - hasher.update( - u64::try_from(encoded.len()) - .expect("protobuf payload length fits in u64") + out +} + +fn canonical_value_bytes(value: &prost_types::Value) -> Vec { + use prost_types::value::Kind; + + let mut out = Vec::new(); + match &value.kind { + None => out.push(0), + Some(Kind::NullValue(value)) => { + out.push(1); + out.extend_from_slice(&value.to_le_bytes()); + } + Some(Kind::NumberValue(value)) => { + out.push(2); + out.extend_from_slice(&value.to_bits().to_le_bytes()); + } + Some(Kind::StringValue(value)) => { + out.push(3); + append_canonical_bytes(&mut out, value.as_bytes()); + } + Some(Kind::BoolValue(value)) => { + out.push(4); + out.push(u8::from(*value)); + } + Some(Kind::StructValue(value)) => { + out.push(5); + append_canonical_bytes(&mut out, &canonical_struct_bytes(value)); + } + Some(Kind::ListValue(value)) => { + out.push(6); + out.extend_from_slice( + &u64::try_from(value.values.len()) + .expect("list length fits in u64") .to_le_bytes(), ); - hasher.update(encoded); + for item in &value.values { + append_canonical_bytes(&mut out, &canonical_value_bytes(item)); + } } } - hex::encode(hasher.finalize()) + out +} + +fn canonical_middleware_bytes( + middleware: &openshell_core::proto::NetworkMiddlewareConfig, +) -> Vec { + let mut map_free = middleware.clone(); + map_free.config = None; + let mut out = Vec::new(); + append_canonical_message(&mut out, &map_free); + if let Some(config) = &middleware.config { + append_canonical_bytes(&mut out, &canonical_struct_bytes(config)); + } + out +} + +fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec { + let mut map_free = policy.clone(); + map_free.network_policies.clear(); + map_free.network_middlewares.clear(); + let mut out = Vec::new(); + append_canonical_message(&mut out, &map_free); + + let mut policy_entries = policy.network_policies.iter().collect::>(); + policy_entries.sort_by_key(|(key, _)| key.as_str()); + append_canonical_bytes(&mut out, b"network_policies"); + out.extend_from_slice( + &u64::try_from(policy_entries.len()) + .expect("policy count fits in u64") + .to_le_bytes(), + ); + for (key, rule) in policy_entries { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_rule_bytes(rule)); + } + + let mut middleware_entries = policy.network_middlewares.iter().collect::>(); + middleware_entries.sort_by_key(|(key, _)| key.as_str()); + append_canonical_bytes(&mut out, b"network_middlewares"); + out.extend_from_slice( + &u64::try_from(middleware_entries.len()) + .expect("middleware count fits in u64") + .to_le_bytes(), + ); + for (key, middleware) in middleware_entries { + append_canonical_bytes(&mut out, key.as_bytes()); + append_canonical_bytes(&mut out, &canonical_middleware_bytes(middleware)); + } + out +} + +/// Compute a deterministic SHA-256 hash of a `SandboxPolicy`, recursively +/// sorting every protobuf map while preserving repeated-field order. +fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { + hex::encode(Sha256::digest(canonical_policy_bytes(policy))) } /// Compute a fingerprint for the effective sandbox configuration. @@ -3945,10 +5718,12 @@ fn compute_config_revision_with_validation_mode( policy_source: PolicySource, supervisor_middleware_services: &[openshell_core::proto::SupervisorMiddlewareService], policy_validation_failure_mode: openshell_core::PolicyValidationFailureMode, + extension_authentication_enabled: bool, ) -> u64 { let mut hasher = Sha256::new(); hasher.update((policy_source as i32).to_le_bytes()); hasher.update(policy_validation_failure_mode.as_str().as_bytes()); + hasher.update([u8::from(extension_authentication_enabled)]); if let Some(policy) = policy { hasher.update(deterministic_policy_hash(policy).as_bytes()); } @@ -4003,6 +5778,7 @@ fn compute_config_revision( policy_source, supervisor_middleware_services, openshell_core::PolicyValidationFailureMode::default(), + false, ) } @@ -4044,6 +5820,12 @@ fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result String { for endpoint in &rule.endpoints { let host = endpoint.host.to_lowercase(); + if endpoint.allow_uninspected_credentials { + notes.push(format!( + "Endpoint '{host}' explicitly allows credentials on traffic OpenShell cannot inspect or rewrite." + )); + } + // Flag destinations that are an internal/private address. Parse the host as // an IP literal and defer to the canonical RFC-accurate classifier // (openshell-core net::is_internal_ip) rather than naive string prefixes: @@ -4396,11 +6184,21 @@ fn validate_merge_operations_for_server(operations: &[PolicyMergeOp]) -> Result< fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status { match error { openshell_policy::PolicyMergeError::MissingRuleNameForAddRule + | openshell_policy::PolicyMergeError::EmptyAddRuleEndpoints { .. } | openshell_policy::PolicyMergeError::InvalidEndpointReference { .. } | openshell_policy::PolicyMergeError::UnsupportedAccessPreset { .. } => { Status::invalid_argument(error.to_string()) } - openshell_policy::PolicyMergeError::EndpointNotFound { .. } + openshell_policy::PolicyMergeError::McpContractConflict { .. } + | openshell_policy::PolicyMergeError::NewBinaryWouldInheritAuthorization { .. } + | openshell_policy::PolicyMergeError::ExistingBinariesWouldInheritAuthorization { + .. + } + | openshell_policy::PolicyMergeError::UndeclaredPortWouldChange { .. } + | openshell_policy::PolicyMergeError::ConflictingInspectionContracts { .. } + | openshell_policy::PolicyMergeError::AmbiguousEndpointRule { .. } + | openshell_policy::PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope { .. } + | openshell_policy::PolicyMergeError::EndpointNotFound { .. } | openshell_policy::PolicyMergeError::EndpointHasNoL7Inspection { .. } | openshell_policy::PolicyMergeError::UnsupportedEndpointProtocol { .. } | openshell_policy::PolicyMergeError::EndpointHasNoAllowBase { .. } => { @@ -4415,15 +6213,160 @@ struct AtomicPolicyWriteContext<'a> { annotations: &'a HashMap, } +struct PolicyCredentialBindingValidationContext<'a> { + catalog: &'a EffectiveProviderProfileCatalog, + records: &'a [super::provider::ProviderEnvironmentRecord], + credentialed_scopes: &'a [CredentialedEndpointScope], + endpointless_provider_names: &'a HashSet, +} + +struct SandboxPolicyMergeValidationData { + provider_layers: Vec, + catalog: EffectiveProviderProfileCatalog, + records: Vec, + credentialed_scopes: Vec, + endpointless_provider_names: HashSet, +} + +impl SandboxPolicyMergeValidationData { + fn credential_binding_context(&self) -> PolicyCredentialBindingValidationContext<'_> { + PolicyCredentialBindingValidationContext { + catalog: &self.catalog, + records: &self.records, + credentialed_scopes: &self.credentialed_scopes, + endpointless_provider_names: &self.endpointless_provider_names, + } + } +} + +async fn sandbox_policy_merge_validation_data( + state: &ServerState, + workspace: &str, + sandbox: &Sandbox, + provider_names: &[String], +) -> Result { + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + sandbox_policy_merge_validation_data_with_catalog( + state, + workspace, + sandbox, + provider_names, + &catalog, + ) + .await +} + +async fn sandbox_policy_merge_validation_data_with_catalog( + state: &ServerState, + workspace: &str, + sandbox: &Sandbox, + provider_names: &[String], + catalog: &EffectiveProviderProfileCatalog, +) -> Result { + let global_settings = load_global_settings(state.store.as_ref()).await?; + let composition_enabled = provider_policy_composition_enabled_in(&global_settings)?; + let ProviderPolicyContext { + layers, + credentialed_scopes, + endpointless_provider_names, + } = provider_policy_context_with_catalog( + state.store.as_ref(), + catalog, + workspace, + provider_names, + ) + .await?; + let provider_layers = if composition_enabled { + layers + } else { + Vec::new() + }; + debug!( + sandbox_id = %sandbox.object_id(), + provider_layer_count = provider_layers.len(), + "Composed provider policy and credential context for merge validation" + ); + let records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + provider_names, + ) + .await?; + Ok(SandboxPolicyMergeValidationData { + provider_layers, + catalog: catalog.clone(), + records, + credentialed_scopes, + endpointless_provider_names, + }) +} + +#[derive(Clone, Copy)] +struct PolicyMergeValidationContext<'a> { + provider_layers: &'a [ProviderPolicyLayer], + credential_binding: Option<&'a PolicyCredentialBindingValidationContext<'a>>, +} + +fn validate_operator_merged_credential_policy( + effective_policy: &mut ProtoSandboxPolicy, + bindings: &HashMap>, + context: &PolicyCredentialBindingValidationContext<'_>, +) -> Result<(), Status> { + validate_policy_credential_binding_context( + context.catalog, + context.records, + effective_policy, + bindings, + )?; + let mut credentialed_scopes = context.credentialed_scopes.to_vec(); + extend_credentialed_scopes_from_policy_bindings( + &mut credentialed_scopes, + bindings, + context.endpointless_provider_names, + ); + clear_provider_credentialed_markers(effective_policy); + stamp_provider_credentialed_endpoints(effective_policy, &credentialed_scopes); + validate_uninspected_credentialed_endpoints(effective_policy) +} + +fn stage_validated_merge_operation( + current_policy: &ProtoSandboxPolicy, + operation: &PolicyMergeOp, + validation_context: PolicyMergeValidationContext<'_>, +) -> Result { + validate_merge_operations_for_server(std::slice::from_ref(operation))?; + let merged = merge_policy(current_policy.clone(), std::slice::from_ref(operation)) + .map_err(map_policy_merge_error)?; + let candidate = merged.policy; + validate_policy_safety(&candidate)?; + validate_candidate_effective_policy(&candidate, validation_context.provider_layers)?; + let mut effective = if validation_context.provider_layers.is_empty() { + candidate.clone() + } else { + compose_effective_policy(&candidate, validation_context.provider_layers) + }; + let bindings = policy_static_credential_endpoint_bindings(Some(&effective))?; + if let Some(context) = validation_context.credential_binding { + validate_operator_merged_credential_policy(&mut effective, &bindings, context)?; + } + Ok(candidate) +} + +#[allow(clippy::too_many_arguments)] async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, workspace: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], - provider_layers: &[ProviderPolicyLayer], + validation_context: PolicyMergeValidationContext<'_>, + expected_current_effective_hash: Option<&str>, atomic_context: Option<&AtomicPolicyWriteContext<'_>>, ) -> Result<(i64, String, Option), Status> { + let provider_layers = validation_context.provider_layers; for attempt in 1..=MERGE_RETRY_LIMIT { let latest = store .get_latest_policy(sandbox_id) @@ -4437,6 +6380,27 @@ async fn apply_merge_operations_with_retry( baseline_policy.cloned().unwrap_or_default() }; + if let Some(expected_hash) = expected_current_effective_hash { + let mut current_effective = if provider_layers.is_empty() { + current_policy.clone() + } else { + compose_effective_policy(¤t_policy, provider_layers) + }; + let bindings = policy_static_credential_endpoint_bindings(Some(¤t_effective))?; + if let Some(context) = validation_context.credential_binding { + validate_operator_merged_credential_policy( + &mut current_effective, + &bindings, + context, + )?; + } + if deterministic_policy_hash(¤t_effective) != expected_hash { + return Err(Status::failed_precondition( + "proposal inputs changed before persistence; refetch and review again", + )); + } + } + let merged = merge_policy(current_policy, operations).map_err(map_policy_merge_error)?; let new_policy = merged.policy; let hash = deterministic_policy_hash(&new_policy); @@ -4446,6 +6410,15 @@ async fn apply_merge_operations_with_retry( } validate_policy_safety(&new_policy)?; validate_candidate_effective_policy(&new_policy, provider_layers)?; + let mut effective_policy = if provider_layers.is_empty() { + new_policy.clone() + } else { + compose_effective_policy(&new_policy, provider_layers) + }; + let bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + if let Some(context) = validation_context.credential_binding { + validate_operator_merged_credential_policy(&mut effective_policy, &bindings, context)?; + } if let Some(ref current) = latest && current.policy_hash == hash @@ -4514,6 +6487,11 @@ async fn apply_merge_operations_with_retry( } Err(e) => { if e.is_unique_violation_on("objects_version_uq") { + if expected_current_effective_hash.is_some() { + return Err(Status::failed_precondition( + "policy changed while applying reviewed proposal; refetch and review again", + )); + } warn!( sandbox_id = %sandbox_id, attempt, @@ -4536,12 +6514,12 @@ async fn apply_merge_operations_with_retry( ))) } -pub(super) async fn merge_chunk_into_policy( +async fn merge_chunk_into_policy_with_validation( store: &Store, sandbox_id: &str, workspace: &str, chunk: &DraftChunkRecord, - provider_layers: &[ProviderPolicyLayer], + validation_context: PolicyMergeValidationContext<'_>, ) -> Result<(i64, String), Status> { let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; @@ -4550,19 +6528,47 @@ pub(super) async fn merge_chunk_into_policy( rule, }]; validate_merge_operations_for_server(&operations)?; + let mut baseline_policy = chunk.current_effective_policy.clone(); + if let Some(policy) = &mut baseline_policy { + strip_provider_rule_names(policy); + clear_provider_credentialed_markers(policy); + } apply_merge_operations_with_retry( store, sandbox_id, workspace, - None, + baseline_policy.as_ref(), &operations, - provider_layers, + validation_context, + (!chunk.current_effective_policy_hash.is_empty()) + .then_some(chunk.current_effective_policy_hash.as_str()), None, ) .await .map(|(version, hash, _)| (version, hash)) } +#[cfg(test)] +async fn merge_chunk_into_policy( + store: &Store, + sandbox_id: &str, + workspace: &str, + chunk: &DraftChunkRecord, + provider_layers: &[ProviderPolicyLayer], +) -> Result<(i64, String), Status> { + merge_chunk_into_policy_with_validation( + store, + sandbox_id, + workspace, + chunk, + PolicyMergeValidationContext { + provider_layers, + credential_binding: None, + }, + ) + .await +} + async fn remove_chunk_from_policy( state: &ServerState, sandbox_id: &str, @@ -4578,7 +6584,11 @@ async fn remove_chunk_from_policy( rule_name: chunk.rule_name.clone(), binary_path: chunk.binary.clone(), }], - &[], + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, + None, None, ) .await @@ -4681,31 +6691,7 @@ pub(super) async fn load_global_settings(store: &Store) -> Result Result { - let global_settings = load_global_settings(store).await?; - bool_setting_enabled(&global_settings, key) -} - -/// Test helper: set a boolean global setting, loading current settings first so -/// the CAS write succeeds whether the record already exists or not. Available to -/// sibling test modules without exposing the private `StoredSettings` type. -#[cfg(test)] -pub async fn set_global_bool_setting_for_test( - store: &Store, - key: &str, - value: bool, -) -> Result<(), Status> { - let mut settings = load_global_settings(store).await?; - settings - .settings - .insert(key.to_string(), StoredSettingValue::Bool(value)); - save_global_settings(store, &settings).await -} - -pub(super) async fn save_global_settings( +pub(super) async fn save_global_settings( store: &Store, settings: &StoredSettings, ) -> Result<(), Status> { @@ -4955,6 +6941,164 @@ mod tests { }) } + #[test] + fn provider_credentialed_stamping_matches_host_patterns_and_ports() { + let mut policy = ProtoSandboxPolicy { + network_policies: HashMap::from([( + "test".to_string(), + NetworkPolicyRule { + endpoints: vec![ + NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + provider_credentialed: true, + ..Default::default() + }, + NetworkEndpoint { + host: "api.example.com".to_string(), + port: 8443, + provider_credentialed: true, + ..Default::default() + }, + NetworkEndpoint { + host: "*.api.example.com".to_string(), + port: 443, + provider_credentialed: true, + ..Default::default() + }, + ], + ..Default::default() + }, + )]), + ..Default::default() + }; + let scopes = vec![CredentialedEndpointScope { + host: "*.example.com".to_string(), + ports: vec![443], + }]; + + clear_provider_credentialed_markers(&mut policy); + stamp_provider_credentialed_endpoints(&mut policy, &scopes); + + let endpoints = &policy.network_policies["test"].endpoints; + assert!(endpoints[0].provider_credentialed); + assert!(!endpoints[1].provider_credentialed); + assert!(!endpoints[2].provider_credentialed); + } + + #[test] + fn policy_bindings_add_scopes_only_for_attached_endpointless_providers() { + let mut scopes = vec![CredentialedEndpointScope { + host: "profile.example.com".to_string(), + ports: vec![443], + }]; + let bindings = HashMap::from([ + ( + "bound".to_string(), + vec![ + StaticCredentialEndpointBinding { + host: "API.Bound.Example".to_string(), + port: 8443, + path: "/v1".to_string(), + }, + StaticCredentialEndpointBinding { + host: "api.bound.example".to_string(), + port: 8443, + path: "/v2".to_string(), + }, + ], + ), + ( + "endpointful".to_string(), + vec![StaticCredentialEndpointBinding { + host: "profile-bound.example".to_string(), + port: 443, + path: String::new(), + }], + ), + ( + "unattached".to_string(), + vec![StaticCredentialEndpointBinding { + host: "unattached.example".to_string(), + port: 443, + path: String::new(), + }], + ), + ]); + + extend_credentialed_scopes_from_policy_bindings( + &mut scopes, + &bindings, + &HashSet::from(["bound".to_string()]), + ); + + assert_eq!( + scopes, + vec![ + CredentialedEndpointScope { + host: "profile.example.com".to_string(), + ports: vec![443], + }, + CredentialedEndpointScope { + host: "api.bound.example".to_string(), + ports: vec![8443], + }, + ] + ); + } + + #[test] + fn credentialed_l4_and_tls_skip_require_explicit_opt_in() { + let endpoint = |protocol: &str, tls: &str, allow: bool| NetworkEndpoint { + host: "api.vendor.example".to_string(), + port: 443, + protocol: protocol.to_string(), + tls: tls.to_string(), + provider_credentialed: true, + allow_uninspected_credentials: allow, + ..Default::default() + }; + let policy = |endpoint| ProtoSandboxPolicy { + network_policies: HashMap::from([( + "vendor".to_string(), + NetworkPolicyRule { + endpoints: vec![endpoint], + ..Default::default() + }, + )]), + ..Default::default() + }; + + assert!( + validate_uninspected_credentialed_endpoints(&policy(endpoint("", "", false))).is_err() + ); + assert!( + validate_uninspected_credentialed_endpoints(&policy(endpoint("rest", "skip", false))) + .is_err() + ); + assert!( + validate_uninspected_credentialed_endpoints(&policy(endpoint("", "", true))).is_ok() + ); + + let mut plain = endpoint("", "", false); + plain.provider_credentialed = false; + assert!(validate_uninspected_credentialed_endpoints(&policy(plain)).is_ok()); + } + + #[test] + fn security_notes_flag_allow_uninspected_credentials() { + let notes = generate_security_notes(&NetworkPolicyRule { + endpoints: vec![NetworkEndpoint { + host: "api.vendor.example".to_string(), + port: 443, + allow_uninspected_credentials: true, + ..Default::default() + }], + ..Default::default() + }); + assert!(notes.contains("cannot inspect or rewrite")); + } + #[test] fn security_notes_use_canonical_internal_ip_classifier() { // RFC 1918 is 172.16.0.0/12 only: the old starts_with("172.") prefix @@ -5325,6 +7469,103 @@ mod tests { assert!(err.message().contains("reserved '_provider_' prefix")); } + #[test] + fn policy_merge_error_mapping_distinguishes_request_shape_from_state_conflicts() { + let empty = + map_policy_merge_error(openshell_policy::PolicyMergeError::EmptyAddRuleEndpoints { + operation_index: 0, + rule_name: "empty".to_string(), + }); + assert_eq!(empty.code(), Code::InvalidArgument); + + let contract = + map_policy_merge_error(openshell_policy::PolicyMergeError::McpContractConflict { + operation_index: 1, + host: "mcp.example.com".to_string(), + port: 443, + existing: "mcp(max_body_bytes=65536)".to_string(), + incoming: "mcp(max_body_bytes=131072)".to_string(), + }); + assert_eq!(contract.code(), Code::FailedPrecondition); + + let inheritance = map_policy_merge_error( + openshell_policy::PolicyMergeError::NewBinaryWouldInheritAuthorization { + operation_index: 2, + rule_name: "existing".to_string(), + binary_scope: "binary '/usr/bin/client'".to_string(), + host: "mcp.example.com".to_string(), + ports: vec![443], + }, + ); + assert_eq!(inheritance.code(), Code::FailedPrecondition); + // The proposer has to know which binary scope triggered the rejection. + assert!(inheritance.message().contains("/usr/bin/client")); + + let existing_scope = map_policy_merge_error( + openshell_policy::PolicyMergeError::ExistingBinariesWouldInheritAuthorization { + operation_index: 3, + rule_name: "existing".to_string(), + host: "api.example.com".to_string(), + ports: vec![443], + undeclared_binaries: vec!["/usr/bin/other".to_string()], + }, + ); + assert_eq!(existing_scope.code(), Code::FailedPrecondition); + // The proposer has to know which binaries to add, so the remediation + // detail must survive into the status message. + assert!(existing_scope.message().contains("/usr/bin/other")); + + // Both of these describe a well-formed request the current policy state + // forbids, so they are preconditions rather than argument errors. + let ambiguous = + map_policy_merge_error(openshell_policy::PolicyMergeError::AmbiguousEndpointRule { + host: "api.example.com".to_string(), + port: 443, + targets: vec!["broad".to_string(), "narrow".to_string()], + }); + assert_eq!(ambiguous.code(), Code::FailedPrecondition); + // The operator has to know which rules collide to pick a way forward. + assert!(ambiguous.message().contains("broad")); + assert!(ambiguous.message().contains("narrow")); + + let undeclared_port = map_policy_merge_error( + openshell_policy::PolicyMergeError::UndeclaredPortWouldChange { + operation_index: 5, + rule_name: "existing".to_string(), + host: "api.example.com".to_string(), + ports: vec![8443], + }, + ); + assert_eq!(undeclared_port.code(), Code::FailedPrecondition); + // The proposer has to know which port to declare. + assert!(undeclared_port.message().contains("8443")); + + let mcp_conflict = map_policy_merge_error( + openshell_policy::PolicyMergeError::ConflictingInspectionContracts { + host: "mcp.example.com".to_string(), + port: 443, + contracts: vec![ + "mcp(strict_tool_names=true, allow_all_known_mcp_methods=false, max_body_bytes=65536)" + .to_string(), + "mcp(strict_tool_names=true, allow_all_known_mcp_methods=false, max_body_bytes=131072)" + .to_string(), + ], + }, + ); + assert_eq!(mcp_conflict.code(), Code::FailedPrecondition); + assert!(mcp_conflict.message().contains("131072")); + + let any_binary = map_policy_merge_error( + openshell_policy::PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope { + operation_index: 4, + rule_name: "wide".to_string(), + binary_path: "/usr/bin/untrusted".to_string(), + }, + ); + assert_eq!(any_binary.code(), Code::FailedPrecondition); + assert!(any_binary.message().contains("/usr/bin/untrusted")); + } + // ---- Sandbox IDOR guard (issue #1354) ---- #[tokio::test] @@ -5664,9 +7905,24 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), } } + fn test_aws_provider(name: &str, provider_type: &str) -> Provider { + let mut provider = test_provider(name, provider_type); + provider.credentials = [ + ("AWS_ACCESS_KEY_ID".to_string(), "AKIATEST".to_string()), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + "test-secret".to_string(), + ), + ] + .into_iter() + .collect(); + provider + } + fn test_policy_with_rule(rule_name: &str, host: &str) -> ProtoSandboxPolicy { ProtoSandboxPolicy { network_policies: std::iter::once(( @@ -5686,6 +7942,38 @@ mod tests { } } + fn test_policy_with_credential_binding( + rule_name: &str, + host: &str, + provider: &str, + ) -> ProtoSandboxPolicy { + let mut policy = test_policy_with_rule(rule_name, host); + policy + .network_policies + .get_mut(rule_name) + .unwrap() + .endpoints[0] + .credential_binding = Some(openshell_core::proto::NetworkCredentialBinding { + provider: provider.to_string(), + }); + policy + } + + fn test_sigv4_policy(host: &str, provider: Option<&str>) -> ProtoSandboxPolicy { + let mut policy = test_policy_with_rule("aws", host); + let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0]; + endpoint.protocol = "rest".to_string(); + endpoint.tls = "terminate".to_string(); + endpoint.access = "full".to_string(); + endpoint.credential_signing = "sigv4".to_string(); + endpoint.signing_service = "s3".to_string(); + endpoint.credential_binding = + provider.map(|provider| openshell_core::proto::NetworkCredentialBinding { + provider: provider.to_string(), + }); + policy + } + fn test_ambiguous_policy() -> ProtoSandboxPolicy { let mut left = test_policy_with_rule("left", "api.example.com"); left.network_policies.get_mut("left").unwrap().endpoints[0].tls = "skip".to_string(); @@ -5724,21 +8012,6 @@ mod tests { sandbox } - async fn enable_providers_v2(state: &Arc) { - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - } - async fn get_sandbox_policy(state: &Arc, sandbox_id: &str) -> ProtoSandboxPolicy { handle_get_sandbox_config( state, @@ -5785,6 +8058,8 @@ mod tests { endpoints: vec![NetworkEndpoint { host: host.to_string(), port: 443, + protocol: "rest".to_string(), + access: "full".to_string(), ..Default::default() }], ..Default::default() @@ -5834,7 +8109,6 @@ mod tests { Arc::clone(&fetch_count), ); let state = Arc::new(state); - enable_providers_v2(&state).await; let mut provider_a = test_provider("provider-a", "moving-a"); provider_a.credentials = HashMap::from([("TOKEN_A".to_string(), "a".to_string())]); @@ -5902,6 +8176,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-snapshot-consistency".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6016,6 +8291,48 @@ mod tests { assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example"); } + #[tokio::test] + async fn provider_policy_layers_prefer_exact_imported_alias_profile() { + let store = test_store().await; + store + .put_message(&test_provider("enterprise-github", "gh")) + .await + .unwrap(); + store + .put_message(&openshell_core::proto::StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-gh".to_string(), + name: "gh".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + profile: Some(openshell_core::proto::ProviderProfile { + id: "gh".to_string(), + display_name: "Enterprise GitHub".to_string(), + endpoints: vec![NetworkEndpoint { + host: "github.enterprise.example".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }), + }) + .await + .unwrap(); + + let layers = + profile_provider_policy_layers(&store, "default", &["enterprise-github".to_string()]) + .await + .unwrap(); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].rule.endpoints.len(), 1); + assert_eq!( + layers[0].rule.endpoints[0].host, + "github.enterprise.example" + ); + } + #[tokio::test] #[allow(deprecated)] async fn provider_policy_layers_include_custom_provider_profiles() { @@ -6156,6 +8473,13 @@ mod tests { assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule_name, "_provider_work_github"); assert_eq!(layers[0].rule.endpoints.len(), 3); + assert!( + layers[0] + .rule + .endpoints + .iter() + .all(|endpoint| endpoint.provider_credentialed) + ); assert!( layers[0] .rule @@ -6225,6 +8549,36 @@ mod tests { ); } + #[tokio::test] + async fn provider_policy_layers_skip_public_vendor_endpoints_for_alternate_upstreams() { + let store = test_store().await; + let mut openai = test_provider("alternate-openai", "openai"); + openai.config.insert( + "OPENAI_BASE_URL".to_string(), + "https://api.example.com/v1".to_string(), + ); + let mut anthropic = test_provider("alternate-anthropic", "anthropic"); + anthropic.config.insert( + "ANTHROPIC_BASE_URL".to_string(), + "https://api.example.com/v1".to_string(), + ); + store.put_message(&openai).await.unwrap(); + store.put_message(&anthropic).await.unwrap(); + + let layers = profile_provider_policy_layers( + &store, + "default", + &[ + "alternate-openai".to_string(), + "alternate-anthropic".to_string(), + ], + ) + .await + .unwrap(); + + assert!(layers.is_empty()); + } + #[tokio::test] async fn provider_policy_layers_respect_profile_workspace_scope() { let store = test_store().await; @@ -6298,65 +8652,9 @@ mod tests { ); } - #[test] - fn providers_v2_enabled_defaults_false_when_unset() { - assert!( - !bool_setting_enabled( - &StoredSettings::default(), - settings::PROVIDERS_V2_ENABLED_KEY - ) - .unwrap() - ); - } - - #[test] - fn providers_v2_enabled_reads_global_bool_setting() { - let mut settings = StoredSettings::default(); - settings.settings.insert( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - ); - - assert!(bool_setting_enabled(&settings, settings::PROVIDERS_V2_ENABLED_KEY).unwrap()); - } - - #[tokio::test] - async fn sandbox_config_omits_provider_layers_when_v2_disabled() { - let state = test_server_state().await; - state - .store - .put_message(&test_provider("work-github", "github")) - .await - .unwrap(); - state - .store - .put_message(&test_sandbox( - "sb-v2-disabled", - "v2-disabled", - test_policy_with_rule("sandbox_only", "sandbox.example.com"), - vec!["work-github".to_string()], - )) - .await - .unwrap(); - - let effective_policy = get_sandbox_policy(&state, "sb-v2-disabled").await; - - assert!( - effective_policy - .network_policies - .contains_key("sandbox_only") - ); - assert!( - !effective_policy - .network_policies - .contains_key("_provider_work_github") - ); - } - #[tokio::test] - async fn sandbox_config_composes_provider_layers_when_v2_enabled() { + async fn sandbox_config_always_composes_provider_layers() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -6454,35 +8752,39 @@ mod tests { } #[tokio::test] - async fn merge_operations_reject_ambiguity_before_persisting_revision() { + async fn update_config_rejects_credential_binding_to_unattached_provider() { let state = test_server_state().await; - let mut policy = test_ambiguous_policy(); - policy.network_policies.get_mut("left").unwrap().endpoints[0].path = "/v1/*".to_string(); - policy.network_policies.get_mut("right").unwrap().endpoints[0].path = - "/v1/users".to_string(); - let operations = policy - .network_policies - .into_iter() - .map(|(rule_name, rule)| PolicyMergeOp::AddRule { rule_name, rule }) - .collect::>(); + let mut sandbox = test_sandbox( + "sb-unattached-binding", + "unattached-binding", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); - let error = apply_merge_operations_with_retry( - state.store.as_ref(), - "sb-ambiguous-merge", - "default", - None, - &operations, - &[], - None, - ) - .await - .expect_err("ambiguous merge must fail before persistence"); + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "unattached-binding".to_string(), + workspace: "default".to_string(), + policy: Some(test_policy_with_credential_binding( + "cloud", + "api.cloud.example", + "missing-provider", + )), + ..Default::default() + })), + ) + .await + .expect_err("unattached provider binding must fail before persistence"); assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("not attached")); assert!( state .store - .get_latest_policy("sb-ambiguous-merge") + .get_latest_policy("sb-unattached-binding") .await .unwrap() .is_none() @@ -6490,36 +8792,63 @@ mod tests { } #[tokio::test] - async fn provider_attachment_preflight_rejects_composed_ambiguity() { + async fn update_config_rejects_policy_binding_for_endpointful_profile() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let mut sandbox = test_sandbox( + "sb-double-binding", + "double-binding", + ProtoSandboxPolicy::default(), + vec!["work-github".to_string()], + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "double-binding".to_string(), + workspace: "default".to_string(), + policy: Some(test_policy_with_credential_binding( + "cloud", + "api.cloud.example", + "work-github", + )), + ..Default::default() + })), + ) + .await + .expect_err("profile and policy must not both define credential endpoints"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("already defines endpoints")); + } + + #[tokio::test] + async fn update_config_gates_uninspected_endpointless_credential_binding() { use openshell_core::proto::{ ProviderProfile, ProviderProfileCategory, StoredProviderProfile, }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&StoredProviderProfile { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "profile-ambiguous".to_string(), - name: "ambiguous".to_string(), - created_at_ms: 1_000_000, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), + id: "profile-endpointless-gating".to_string(), + name: "endpointless-gating".to_string(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + ..Default::default() }), profile: Some(ProviderProfile { - id: "ambiguous".to_string(), - display_name: "Ambiguous".to_string(), + id: "endpointless-gating".to_string(), + display_name: "Endpointless Gating".to_string(), category: ProviderProfileCategory::Other as i32, - endpoints: vec![NetworkEndpoint { - host: "api.example.com".to_string(), - port: 443, - tls: "skip".to_string(), - ..Default::default() - }], + endpoints: Vec::new(), ..Default::default() }), }) @@ -6527,445 +8856,1372 @@ mod tests { .unwrap(); state .store - .put_message(&test_provider("candidate-provider", "ambiguous")) + .put_message(&test_provider("work-endpointless", "endpointless-gating")) .await .unwrap(); - let sandbox = test_sandbox( - "sb-provider-ambiguity", - "provider-ambiguity", - test_policy_with_rule("base", "api.example.com"), - Vec::new(), + let mut sandbox = test_sandbox( + "sb-endpointless-gating", + "endpointless-gating", + ProtoSandboxPolicy::default(), + vec!["work-endpointless".to_string()], ); + sandbox.spec.as_mut().unwrap().policy = None; state.store.put_message(&sandbox).await.unwrap(); - let error = super::super::sandbox::handle_attach_sandbox_provider( + let l4 = + test_policy_with_credential_binding("bound", "api.bound.example", "work-endpointless"); + let add_bound_rule = |policy: &ProtoSandboxPolicy| PolicyMergeOperation { + operation: Some(policy_merge_operation::Operation::AddRule( + openshell_core::proto::AddNetworkRule { + rule_name: "bound".to_string(), + rule: Some(policy.network_policies["bound"].clone()), + }, + )), + }; + let l4_error = handle_update_config( &state, - authed_request(openshell_core::proto::AttachSandboxProviderRequest { - sandbox_name: "provider-ambiguity".to_string(), - provider_name: "candidate-provider".to_string(), - expected_resource_version: 0, + with_user(Request::new(UpdateConfigRequest { + name: "endpointless-gating".to_string(), workspace: "default".to_string(), - }), + policy: Some(l4.clone()), + ..Default::default() + })), ) .await - .expect_err("provider attachment must validate the composed policy"); + .expect_err("L4-only credential binding must be rejected"); + assert_eq!(l4_error.code(), Code::FailedPrecondition); + assert!(l4_error.message().contains("L4-only")); - assert_eq!(error.code(), Code::FailedPrecondition); - assert!(error.message().contains("tls")); - let stored = state - .store - .get_message_by_name::("default", "provider-ambiguity") - .await + let mut tls_skip = l4.clone(); + let tls_endpoint = &mut tls_skip + .network_policies + .get_mut("bound") .unwrap() - .unwrap(); - assert!(stored.spec.unwrap().providers.is_empty()); - } + .endpoints[0]; + tls_endpoint.protocol = "rest".to_string(); + tls_endpoint.access = "full".to_string(); + tls_endpoint.tls = "skip".to_string(); + let tls_error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "endpointless-gating".to_string(), + workspace: "default".to_string(), + policy: Some(tls_skip.clone()), + ..Default::default() + })), + ) + .await + .expect_err("tls: skip credential binding must be rejected"); + assert_eq!(tls_error.code(), Code::FailedPrecondition); + assert!(tls_error.message().contains("tls: skip")); - #[tokio::test] - async fn sandbox_config_rejects_invalid_provider_composed_policy() { - use openshell_core::proto::{ - MiddlewareEndpointSelector, NetworkMiddlewareConfig, ProviderProfile, - ProviderProfileCategory, StoredProviderProfile, - }; + let merge_l4_error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "endpointless-gating".to_string(), + workspace: "default".to_string(), + merge_operations: vec![add_bound_rule(&l4)], + ..Default::default() + })), + ) + .await + .expect_err("L4-only credential binding merge must be rejected"); + assert_eq!(merge_l4_error.code(), Code::FailedPrecondition); + assert!(merge_l4_error.message().contains("L4-only")); - let state = test_server_state().await; - enable_providers_v2(&state).await; + let merge_tls_error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "endpointless-gating".to_string(), + workspace: "default".to_string(), + merge_operations: vec![add_bound_rule(&tls_skip)], + ..Default::default() + })), + ) + .await + .expect_err("tls: skip credential binding merge must be rejected"); + assert_eq!(merge_tls_error.code(), Code::FailedPrecondition); + assert!(merge_tls_error.message().contains("tls: skip")); - let profile = StoredProviderProfile { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "profile-tls-skip".to_string(), - name: "tls-skip".to_string(), - created_at_ms: 1_000_000, - labels: HashMap::new(), - annotations: HashMap::new(), - resource_version: 0, + assert!( + state + .store + .get_latest_policy("sb-endpointless-gating") + .await + .unwrap() + .is_none(), + "rejected policies must not leave a revision in history" + ); + + let mut opted_in = l4; + opted_in + .network_policies + .get_mut("bound") + .unwrap() + .endpoints[0] + .allow_uninspected_credentials = true; + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "endpointless-gating".to_string(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - profile: Some(ProviderProfile { - id: "tls-skip".to_string(), - display_name: "TLS skip".to_string(), - category: ProviderProfileCategory::Other as i32, - endpoints: vec![NetworkEndpoint { - host: "api.example.com".to_string(), - port: 443, - tls: "skip".to_string(), - ..Default::default() - }], + merge_operations: vec![add_bound_rule(&opted_in)], ..Default::default() - }), - }; - state.store.put_message(&profile).await.unwrap(); - state - .store - .put_message(&test_provider("work-tls-skip", "tls-skip")) - .await - .unwrap(); + })), + ) + .await + .expect("explicit opt-in must admit the endpointless credential binding merge"); + assert!( + state + .store + .get_latest_policy("sb-endpointless-gating") + .await + .unwrap() + .is_some() + ); + } - let policy = ProtoSandboxPolicy { - network_middlewares: HashMap::from([( - "redactor".to_string(), - NetworkMiddlewareConfig { - middleware: "openshell/regex".to_string(), - on_error: "fail_closed".to_string(), - endpoints: Some(MiddlewareEndpointSelector { - include: vec!["api.example.com".to_string()], - exclude: Vec::new(), - }), - ..Default::default() - }, - )]), - ..Default::default() - }; - state - .store - .put_message(&test_sandbox( - "sb-invalid-composed-policy", - "invalid-composed-policy", - policy, - vec!["work-tls-skip".to_string()], - )) - .await - .unwrap(); + #[tokio::test] + async fn update_config_rejects_sigv4_without_credential_source_before_persisting_revision() { + let state = test_server_state().await; + let mut sandbox = test_sandbox( + "sb-signing-no-source", + "signing-no-source", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); - let error = handle_get_sandbox_config( + let error = handle_update_config( &state, - with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-invalid-composed-policy".to_string(), + with_user(Request::new(UpdateConfigRequest { + name: "signing-no-source".to_string(), + workspace: "default".to_string(), + policy: Some(test_sigv4_policy("s3.amazonaws.com", None)), + ..Default::default() })), ) .await - .expect_err("invalid composed policy must not be delivered"); + .expect_err("SigV4 policy without an AWS credential source must fail before persistence"); assert_eq!(error.code(), Code::FailedPrecondition); - assert!(error.message().contains("provider composition")); - assert!(error.message().contains("tls: skip")); + assert!( + error + .message() + .contains("no resolvable AWS credential source") + ); + assert!( + state + .store + .get_latest_policy("sb-signing-no-source") + .await + .unwrap() + .is_none(), + "invalid policy must not leave a revision in history" + ); } #[tokio::test] - async fn sandbox_config_skips_profileless_provider_types_when_v2_enabled() { + async fn update_config_rejects_sigv4_for_unbound_endpointless_aws_profile() { let state = test_server_state().await; - enable_providers_v2(&state).await; - state - .store - .put_message(&test_provider("legacy-generic", "generic")) - .await - .unwrap(); - state - .store - .put_message(&test_provider("custom-provider", "custom")) - .await - .unwrap(); state .store - .put_message(&test_sandbox( - "sb-profileless", - "profileless", - test_policy_with_rule("sandbox_only", "sandbox.example.com"), - vec!["legacy-generic".to_string(), "custom-provider".to_string()], - )) + .put_message(&test_aws_provider("aws-prod", "aws")) .await .unwrap(); + let mut sandbox = test_sandbox( + "sb-signing-unbound-aws", + "signing-unbound-aws", + ProtoSandboxPolicy::default(), + vec!["aws-prod".to_string()], + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); - let effective_policy = get_sandbox_policy(&state, "sb-profileless").await; + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "signing-unbound-aws".to_string(), + workspace: "default".to_string(), + policy: Some(test_sigv4_policy("s3.amazonaws.com", None)), + ..Default::default() + })), + ) + .await + .expect_err("endpointless AWS profile must be bound explicitly"); - assert_eq!(effective_policy.network_policies.len(), 1); + assert_eq!(error.code(), Code::FailedPrecondition); assert!( - effective_policy - .network_policies - .contains_key("sandbox_only") + error + .message() + .contains("no resolvable AWS credential source") ); } #[tokio::test] - async fn sandbox_config_composition_is_jit_and_does_not_persist_provider_layers() { + async fn update_config_accepts_sigv4_bound_to_endpointless_aws_profile() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store - .put_message(&test_provider("work-github", "github")) + .put_message(&test_aws_provider("aws-prod", "aws")) .await .unwrap(); - state + let mut sandbox = test_sandbox( + "sb-signing-bound-aws", + "signing-bound-aws", + ProtoSandboxPolicy::default(), + vec!["aws-prod".to_string()], + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "signing-bound-aws".to_string(), + workspace: "default".to_string(), + policy: Some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))), + ..Default::default() + })), + ) + .await + .expect("bound endpointless AWS profile supplies SigV4 credentials"); + + assert!( + state + .store + .get_latest_policy("sb-signing-bound-aws") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn update_config_accepts_sigv4_covered_by_endpointful_aws_profile() { + let state = test_server_state().await; + state + .store + .put_message(&test_aws_provider("s3-prod", "aws-s3")) + .await + .unwrap(); + let mut sandbox = test_sandbox( + "sb-signing-profile-endpoint", + "signing-profile-endpoint", + ProtoSandboxPolicy::default(), + vec!["s3-prod".to_string()], + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + let mut policy = test_sigv4_policy("bucket.s3.amazonaws.com", None); + let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0]; + endpoint.access = "read-write".to_string(); + endpoint.enforcement = "enforce".to_string(); + + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "signing-profile-endpoint".to_string(), + workspace: "default".to_string(), + policy: Some(policy), + ..Default::default() + })), + ) + .await + .expect("endpoint-bearing AWS profile covers the signed endpoint"); + } + + #[tokio::test] + async fn update_config_rejects_sigv4_outside_endpointful_aws_profile_boundary() { + let state = test_server_state().await; + state + .store + .put_message(&test_aws_provider("s3-prod", "aws-s3")) + .await + .unwrap(); + let mut sandbox = test_sandbox( + "sb-signing-profile-mismatch", + "signing-profile-mismatch", + ProtoSandboxPolicy::default(), + vec!["s3-prod".to_string()], + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "signing-profile-mismatch".to_string(), + workspace: "default".to_string(), + policy: Some(test_sigv4_policy("api.example.com", None)), + ..Default::default() + })), + ) + .await + .expect_err("profile endpoint boundary must cover a signed endpoint"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!( + error + .message() + .contains("no resolvable AWS credential source") + ); + } + + #[tokio::test] + async fn merge_operations_reject_ambiguity_before_persisting_revision() { + let state = test_server_state().await; + let mut policy = test_ambiguous_policy(); + policy.network_policies.get_mut("left").unwrap().endpoints[0].path = "/v1/*".to_string(); + policy.network_policies.get_mut("right").unwrap().endpoints[0].path = + "/v1/users".to_string(); + let operations = policy + .network_policies + .into_iter() + .map(|(rule_name, rule)| PolicyMergeOp::AddRule { rule_name, rule }) + .collect::>(); + + let error = apply_merge_operations_with_retry( + state.store.as_ref(), + "sb-ambiguous-merge", + "default", + None, + &operations, + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, + None, + None, + ) + .await + .expect_err("ambiguous merge must fail before persistence"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!( + state + .store + .get_latest_policy("sb-ambiguous-merge") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn provider_attachment_preflight_rejects_composed_ambiguity() { + use openshell_core::proto::{ + ProviderProfile, ProviderProfileCategory, StoredProviderProfile, + }; + + let state = test_server_state().await; + state + .store + .put_message(&StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-ambiguous".to_string(), + name: "ambiguous".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "ambiguous".to_string(), + display_name: "Ambiguous".to_string(), + category: ProviderProfileCategory::Other as i32, + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + tls: "skip".to_string(), + ..Default::default() + }], + ..Default::default() + }), + }) + .await + .unwrap(); + state + .store + .put_message(&test_provider("candidate-provider", "ambiguous")) + .await + .unwrap(); + let sandbox = test_sandbox( + "sb-provider-ambiguity", + "provider-ambiguity", + test_policy_with_rule("base", "api.example.com"), + Vec::new(), + ); + state.store.put_message(&sandbox).await.unwrap(); + + let error = super::super::sandbox::handle_attach_sandbox_provider( + &state, + authed_request(openshell_core::proto::AttachSandboxProviderRequest { + sandbox_name: "provider-ambiguity".to_string(), + provider_name: "candidate-provider".to_string(), + expected_resource_version: 0, + workspace: "default".to_string(), + }), + ) + .await + .expect_err("provider attachment must validate the composed policy"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("tls")); + let stored = state + .store + .get_message_by_name::("default", "provider-ambiguity") + .await + .unwrap() + .unwrap(); + assert!(stored.spec.unwrap().providers.is_empty()); + } + + #[tokio::test] + async fn sandbox_config_rejects_invalid_provider_composed_policy() { + use openshell_core::proto::{ + MiddlewareEndpointSelector, NetworkMiddlewareConfig, ProviderProfile, + ProviderProfileCategory, StoredProviderProfile, + }; + + let state = test_server_state().await; + + let profile = StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-tls-skip".to_string(), + name: "tls-skip".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "tls-skip".to_string(), + display_name: "TLS skip".to_string(), + category: ProviderProfileCategory::Other as i32, + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + tls: "skip".to_string(), + ..Default::default() + }], + ..Default::default() + }), + }; + state.store.put_message(&profile).await.unwrap(); + state + .store + .put_message(&test_provider("work-tls-skip", "tls-skip")) + .await + .unwrap(); + + let policy = ProtoSandboxPolicy { + network_middlewares: HashMap::from([( + "redactor".to_string(), + NetworkMiddlewareConfig { + middleware: "openshell/regex".to_string(), + on_error: "fail_closed".to_string(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".to_string()], + exclude: Vec::new(), + }), + ..Default::default() + }, + )]), + ..Default::default() + }; + state + .store + .put_message(&test_sandbox( + "sb-invalid-composed-policy", + "invalid-composed-policy", + policy, + vec!["work-tls-skip".to_string()], + )) + .await + .unwrap(); + + let error = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-invalid-composed-policy".to_string(), + })), + ) + .await + .expect_err("invalid composed policy must not be delivered"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("provider composition")); + assert!(error.message().contains("tls: skip")); + } + + #[tokio::test] + async fn sandbox_config_skips_profileless_provider_types() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("legacy-generic", "generic")) + .await + .unwrap(); + state + .store + .put_message(&test_provider("custom-provider", "custom")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-profileless", + "profileless", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["legacy-generic".to_string(), "custom-provider".to_string()], + )) + .await + .unwrap(); + + let effective_policy = get_sandbox_policy(&state, "sb-profileless").await; + + assert_eq!(effective_policy.network_policies.len(), 1); + assert!( + effective_policy + .network_policies + .contains_key("sandbox_only") + ); + } + + #[tokio::test] + async fn sandbox_config_composition_is_jit_and_does_not_persist_provider_layers() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-jit", + "jit", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let effective_policy = get_sandbox_policy(&state, "sb-jit").await; + assert!( + effective_policy + .network_policies + .contains_key("_provider_work_github") + ); + + let persisted = state + .store + .get_latest_policy("sb-jit") + .await + .unwrap() + .expect("sandbox policy should be lazily backfilled"); + let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice()) + .expect("persisted sandbox policy should decode"); + assert!( + persisted_policy + .network_policies + .contains_key("sandbox_only") + ); + assert!( + !persisted_policy + .network_policies + .contains_key("_provider_work_github") + ); + } + + #[tokio::test] + async fn sandbox_config_uses_updated_custom_provider_profile_without_rewriting_provider() { + use crate::grpc::provider::handle_update_provider_profiles; + use openshell_core::proto::{ + ProviderProfile, ProviderProfileCategory, ProviderProfileImportItem, + StoredProviderProfile, UpdateProviderProfilesRequest, + }; + + fn stored_profile(host: &str) -> StoredProviderProfile { + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-custom-policy".to_string(), + name: "custom-policy".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "custom-policy".to_string(), + resource_version: 0, + annotations: HashMap::new(), + display_name: "Custom Policy".to_string(), + description: String::new(), + category: ProviderProfileCategory::Other as i32, + credentials: Vec::new(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + binaries: Vec::new(), + inference_capable: false, + discovery: None, + source: String::new(), + scope: String::new(), + }), + } + } + + let state = test_server_state().await; + state + .store + .put_message(&stored_profile("api.before.example")) + .await + .unwrap(); + let provider = test_provider("work-custom", "custom-policy"); + state.store.put_message(&provider).await.unwrap(); + state .store .put_message(&test_sandbox( - "sb-jit", - "jit", + "sb-custom-policy-update", + "custom-policy-update", test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-custom".to_string()], + )) + .await + .unwrap(); + + let before_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await; + assert!( + before_policy.network_policies["_provider_work_custom"] + .endpoints + .iter() + .any(|endpoint| endpoint.host == "api.before.example") + ); + + let mut updated_profile = stored_profile("api.after.example").profile.unwrap(); + updated_profile.resource_version = state + .store + .get_message_by_name::("default", "custom-policy") + .await + .unwrap() + .unwrap() + .metadata + .as_ref() + .unwrap() + .resource_version; + let response = handle_update_provider_profiles( + &state, + with_user(Request::new(UpdateProviderProfilesRequest { + profile: Some(ProviderProfileImportItem { + profile: Some(updated_profile), + source: "custom-policy.yaml".to_string(), + }), + expected_resource_version: 0, + id: "custom-policy".to_string(), + workspace: "default".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert!(response.updated); + + let after_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await; + let provider_rule = &after_policy.network_policies["_provider_work_custom"]; + assert!( + provider_rule + .endpoints + .iter() + .any(|endpoint| endpoint.host == "api.after.example") + ); + assert!( + !provider_rule + .endpoints + .iter() + .any(|endpoint| endpoint.host == "api.before.example") + ); + + let persisted_provider: Provider = state + .store + .get_message_by_name::("default", "work-custom") + .await + .unwrap() + .unwrap(); + assert_eq!(persisted_provider.r#type, provider.r#type); + assert_eq!(persisted_provider.credentials, provider.credentials); + + let persisted_policy = state + .store + .get_latest_policy("sb-custom-policy-update") + .await + .unwrap() + .expect("sandbox policy should be lazily backfilled"); + let persisted_policy = + ProtoSandboxPolicy::decode(persisted_policy.policy_payload.as_slice()) + .expect("persisted sandbox policy should decode"); + assert!( + persisted_policy + .network_policies + .contains_key("sandbox_only") + ); + assert!( + !persisted_policy + .network_policies + .contains_key("_provider_work_custom") + ); + } + + #[tokio::test] + async fn sandbox_config_composes_user_and_provider_rules() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let mut policy = test_policy_with_rule("custom_github", "api.github.com"); + let endpoint = &mut policy + .network_policies + .get_mut("custom_github") + .expect("custom rule") + .endpoints[0]; + endpoint.protocol = "rest".to_string(); + endpoint.access = "read-only".to_string(); + state + .store + .put_message(&test_sandbox( + "sb-overlap", + "overlap", + policy, vec!["work-github".to_string()], )) .await .unwrap(); - let effective_policy = get_sandbox_policy(&state, "sb-jit").await; + let effective_policy = get_sandbox_policy(&state, "sb-overlap").await; + + assert!( + effective_policy + .network_policies + .contains_key("custom_github") + ); assert!( effective_policy .network_policies .contains_key("_provider_work_github") ); + assert_eq!( + effective_policy + .network_policies + .get("custom_github") + .unwrap() + .endpoints[0] + .host, + "api.github.com" + ); + } + + #[tokio::test] + async fn provider_environment_resolution_is_stable_across_policy_composition() { + use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-provider-env", + "provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let legacy_env = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-provider-env".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner() + .environment; + + let v2_env = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-provider-env".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner() + .environment; + + assert_eq!(legacy_env, v2_env); + assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); + } + + #[tokio::test] + async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() { + use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-legacy-provider-env", + "legacy-provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-legacy-provider-env".to_string(), + supports_static_credential_bindings: false, + })), + ) + .await + .unwrap() + .into_inner(); + + assert!(!response.environment.contains_key("GITHUB_TOKEN")); + assert!(response.static_credential_bindings.is_empty()); + } + + #[tokio::test] + async fn provider_environment_withholds_unbound_static_credentials_independently() { + use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let mut profileless_openai = test_provider("gateway-openai", "legacy-openai"); + profileless_openai.credentials = + HashMap::from([("OPENAI_API_KEY".to_string(), "openai-secret".to_string())]); + state.store.put_message(&profileless_openai).await.unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-unbound-provider-env", + "unbound-provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string(), "gateway-openai".to_string()], + )) + .await + .unwrap(); + + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-unbound-provider-env".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner(); - let persisted = state - .store - .get_latest_policy("sb-jit") - .await - .unwrap() - .expect("sandbox policy should be lazily backfilled"); - let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice()) - .expect("persisted sandbox policy should decode"); + assert!(!response.environment.contains_key("OPENAI_API_KEY")); assert!( - persisted_policy - .network_policies - .contains_key("sandbox_only") + !response + .static_credential_bindings + .contains_key("OPENAI_API_KEY") + ); + assert_eq!( + response.environment.get("GITHUB_TOKEN"), + Some(&"ghp-test".to_string()) ); assert!( - !persisted_policy - .network_policies - .contains_key("_provider_work_github") + response + .static_credential_bindings + .get("GITHUB_TOKEN") + .is_some_and(|binding| !binding.endpoints.is_empty()) ); } #[tokio::test] - async fn sandbox_config_uses_updated_custom_provider_profile_without_rewriting_provider() { - use crate::grpc::provider::handle_update_provider_profiles; + async fn provider_environment_uses_policy_binding_for_endpointless_profile() { use openshell_core::proto::{ - ProviderProfile, ProviderProfileCategory, ProviderProfileImportItem, - StoredProviderProfile, UpdateProviderProfilesRequest, + GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, + NetworkCredentialBinding, ProviderProfile, ProviderProfileCategory, + StoredProviderProfile, }; - fn stored_profile(host: &str) -> StoredProviderProfile { - StoredProviderProfile { + let state = test_server_state().await; + state + .store + .put_message(&StoredProviderProfile { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "profile-custom-policy".to_string(), - name: "custom-policy".to_string(), - created_at_ms: 1_000_000, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), + id: "profile-endpointless".to_string(), + name: "endpointless".to_string(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + ..Default::default() }), profile: Some(ProviderProfile { - id: "custom-policy".to_string(), - resource_version: 0, - annotations: HashMap::new(), - display_name: "Custom Policy".to_string(), - description: String::new(), + id: "endpointless".to_string(), + display_name: "Endpointless".to_string(), category: ProviderProfileCategory::Other as i32, - credentials: Vec::new(), - endpoints: vec![NetworkEndpoint { - host: host.to_string(), - port: 443, + credentials: vec![openshell_core::proto::ProviderProfileCredential { + name: "cloud_token".to_string(), + env_vars: vec!["CLOUD_TOKEN".to_string()], ..Default::default() }], - binaries: Vec::new(), - inference_capable: false, - discovery: None, - source: String::new(), - scope: String::new(), + endpoints: Vec::new(), + ..Default::default() }), - } - } - - let state = test_server_state().await; - enable_providers_v2(&state).await; - state - .store - .put_message(&stored_profile("api.before.example")) + }) .await .unwrap(); - let provider = test_provider("work-custom", "custom-policy"); + let mut provider = test_provider("work-cloud", "endpointless"); + provider.credentials = + HashMap::from([("CLOUD_TOKEN".to_string(), "cloud-secret".to_string())]); state.store.put_message(&provider).await.unwrap(); + + let mut policy = test_policy_with_rule("cloud_api", "api.cloud.example"); + policy + .network_policies + .get_mut("cloud_api") + .unwrap() + .endpoints[0] + .credential_binding = Some(NetworkCredentialBinding { + provider: "work-cloud".to_string(), + }); + let bound_endpoint = &mut policy + .network_policies + .get_mut("cloud_api") + .unwrap() + .endpoints[0]; + bound_endpoint.protocol = "rest".to_string(); + bound_endpoint.access = "full".to_string(); + bound_endpoint.tls = "terminate".to_string(); + openshell_policy::ensure_sandbox_process_identity(&mut policy); state .store .put_message(&test_sandbox( - "sb-custom-policy-update", - "custom-policy-update", - test_policy_with_rule("sandbox_only", "sandbox.example.com"), - vec!["work-custom".to_string()], + "sb-policy-binding", + "policy-binding", + policy.clone(), + vec!["work-cloud".to_string()], )) .await .unwrap(); - let before_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await; + let config = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-policy-binding".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + 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, + })), + ) + .await + .unwrap() + .into_inner(); + assert!( - before_policy.network_policies["_provider_work_custom"] - .endpoints - .iter() - .any(|endpoint| endpoint.host == "api.before.example") + config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0] + .provider_credentialed, + "config delivery must derive provenance from the endpointless binding" + ); + assert_eq!( + environment.environment.get("CLOUD_TOKEN"), + Some(&"cloud-secret".to_string()) + ); + assert_eq!( + environment.static_credential_bindings["CLOUD_TOKEN"].endpoints, + vec![StaticCredentialEndpointBinding { + host: "api.cloud.example".to_string(), + port: 443, + path: String::new(), + }] + ); + assert_eq!( + config.provider_env_revision, environment.provider_env_revision, + "config and provider environment must advertise one atomic revision" ); - let mut updated_profile = stored_profile("api.after.example").profile.unwrap(); - updated_profile.resource_version = state - .store - .get_message_by_name::("default", "custom-policy") - .await - .unwrap() - .unwrap() - .metadata - .as_ref() + let mut next_policy = policy; + next_policy + .network_policies + .get_mut("cloud_api") .unwrap() - .resource_version; - let response = handle_update_provider_profiles( + .endpoints[0] + .host = "api2.cloud.example".to_string(); + handle_update_config( &state, - with_user(Request::new(UpdateProviderProfilesRequest { - profile: Some(ProviderProfileImportItem { - profile: Some(updated_profile), - source: "custom-policy.yaml".to_string(), - }), - expected_resource_version: 0, - id: "custom-policy".to_string(), + with_user(Request::new(UpdateConfigRequest { + name: "policy-binding".to_string(), workspace: "default".to_string(), + policy: Some(next_policy.clone()), + ..Default::default() + })), + ) + .await + .expect("policy binding update must succeed"); + let next_config = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-policy-binding".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + let next_environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-policy-binding".to_string(), + supports_static_credential_bindings: true, })), ) .await .unwrap() .into_inner(); - assert!(response.updated); - let after_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await; - let provider_rule = &after_policy.network_policies["_provider_work_custom"]; assert!( - provider_rule - .endpoints - .iter() - .any(|endpoint| endpoint.host == "api.after.example") + next_config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0] + .provider_credentialed ); - assert!( - !provider_rule - .endpoints - .iter() - .any(|endpoint| endpoint.host == "api.before.example") + assert_ne!( + config.provider_env_revision, next_config.provider_env_revision, + "changing the policy binding must rotate the provider environment revision" + ); + assert_eq!( + next_config.provider_env_revision, + next_environment.provider_env_revision + ); + assert_eq!( + next_environment.static_credential_bindings["CLOUD_TOKEN"].endpoints[0].host, + "api2.cloud.example" ); - let persisted_provider: Provider = state - .store - .get_message_by_name::("default", "work-custom") - .await + let mut unbound_policy = next_policy; + unbound_policy + .network_policies + .get_mut("cloud_api") .unwrap() - .unwrap(); - assert_eq!(persisted_provider.r#type, provider.r#type); - assert_eq!(persisted_provider.credentials, provider.credentials); + .endpoints[0] + .credential_binding = None; + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "policy-binding".to_string(), + workspace: "default".to_string(), + policy: Some(unbound_policy), + ..Default::default() + })), + ) + .await + .expect("removing a policy binding must succeed"); + let unbound_config = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-policy-binding".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + let unbound_environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-policy-binding".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner(); - let persisted_policy = state - .store - .get_latest_policy("sb-custom-policy-update") - .await - .unwrap() - .expect("sandbox policy should be lazily backfilled"); - let persisted_policy = - ProtoSandboxPolicy::decode(persisted_policy.policy_payload.as_slice()) - .expect("persisted sandbox policy should decode"); assert!( - persisted_policy - .network_policies - .contains_key("sandbox_only") + !unbound_config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0] + .provider_credentialed, + "removing the binding must clear the derived provenance" + ); + assert_ne!( + next_environment.provider_env_revision, unbound_environment.provider_env_revision, + "removing the binding must rotate the provider environment revision" ); assert!( - !persisted_policy - .network_policies - .contains_key("_provider_work_custom") + !unbound_environment.environment.contains_key("CLOUD_TOKEN"), + "removing the only binding must withhold the static credential" + ); + assert!( + !unbound_environment + .static_credential_bindings + .contains_key("CLOUD_TOKEN"), + "an endpointless profile must not emit incomplete binding metadata" ); } #[tokio::test] - async fn sandbox_config_composes_user_and_provider_rules() { + async fn invalid_static_binding_does_not_suppress_valid_dynamic_credentials() { + use openshell_core::proto::{ + GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant, ProviderProfile, + ProviderProfileCategory, ProviderProfileCredential, StoredProviderProfile, + }; + let state = test_server_state().await; - enable_providers_v2(&state).await; - state - .store - .put_message(&test_provider("work-github", "github")) - .await - .unwrap(); + let mut invalid_static = test_provider("invalid-static", "profile-without-endpoints"); + invalid_static.credentials = + HashMap::from([("INVALID_TOKEN".to_string(), "static-secret".to_string())]); + let dynamic = test_provider("dynamic", "custom-dynamic"); + let dynamic_profile = StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-custom-dynamic".to_string(), + name: "custom-dynamic".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "custom-dynamic".to_string(), + display_name: "Custom Dynamic".to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: "https://auth.example.test/token".to_string(), + audience: "api://default".to_string(), + ..Default::default() + }), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: "api.dynamic.example.test".to_string(), + port: 443, + path: "/**".to_string(), + protocol: "rest".to_string(), + access: "full".to_string(), + ..Default::default() + }], + ..Default::default() + }), + }; + + state.store.put_message(&invalid_static).await.unwrap(); + state.store.put_message(&dynamic).await.unwrap(); + state.store.put_message(&dynamic_profile).await.unwrap(); state .store .put_message(&test_sandbox( - "sb-overlap", - "overlap", - test_policy_with_rule("custom_github", "api.github.com"), - vec!["work-github".to_string()], + "sb-mixed-provider-env", + "mixed-provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["invalid-static".to_string(), "dynamic".to_string()], )) .await .unwrap(); - let effective_policy = get_sandbox_policy(&state, "sb-overlap").await; + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-mixed-provider-env".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .expect("mixed snapshot must be returned") + .into_inner(); assert!( - effective_policy - .network_policies - .contains_key("custom_github") + !response.environment.contains_key("INVALID_TOKEN"), + "an unbound static credential must be withheld before the supervisor snapshot" ); - assert!( - effective_policy - .network_policies - .contains_key("_provider_work_github") + assert!( + !response + .static_credential_bindings + .contains_key("INVALID_TOKEN"), + "an unbound static credential must not emit incomplete binding metadata" ); - assert_eq!( - effective_policy - .network_policies - .get("custom_github") - .unwrap() - .endpoints[0] - .host, - "api.github.com" + assert!( + !response.dynamic_credentials.is_empty(), + "valid dynamic credentials must survive an unrelated static binding failure" ); } #[tokio::test] - async fn provider_environment_resolution_is_unchanged_by_providers_v2_setting() { - use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + async fn provider_environment_withholds_token_exchange_subject_credential() { + use openshell_core::proto::{ + GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, + ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, + StoredProviderProfile, + }; let state = test_server_state().await; - state - .store - .put_message(&test_provider("work-github", "github")) - .await - .unwrap(); + let profile = StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-token-exchange-subject".to_string(), + name: "token-exchange-subject".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "token-exchange-subject".to_string(), + display_name: "Token Exchange Subject".to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://auth.example.test/token".to_string(), + audience: "api://exchange".to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ], + endpoints: vec![NetworkEndpoint { + host: "api.exchange.example.test".to_string(), + port: 443, + protocol: "rest".to_string(), + access: "full".to_string(), + ..Default::default() + }], + ..Default::default() + }), + }; + state.store.put_message(&profile).await.unwrap(); + + let mut provider = test_provider("exchange-provider", "token-exchange-subject"); + provider.credentials = HashMap::from([( + "subject_token".to_string(), + "raw-gateway-oidc-token".to_string(), + )]); + provider.credential_expires_at_ms = + HashMap::from([("subject_token".to_string(), current_time_ms() + 60_000)]); + state.store.put_message(&provider).await.unwrap(); state .store .put_message(&test_sandbox( - "sb-provider-env", - "provider-env", + "sb-token-exchange-subject", + "token-exchange-subject", test_policy_with_rule("sandbox_only", "sandbox.example.com"), - vec!["work-github".to_string()], + vec!["exchange-provider".to_string()], )) .await .unwrap(); - let legacy_env = handle_get_sandbox_provider_environment( - &state, - with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-provider-env".to_string(), - })), - ) - .await - .unwrap() - .into_inner() - .environment; - - enable_providers_v2(&state).await; - let v2_env = handle_get_sandbox_provider_environment( + let response = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-provider-env".to_string(), + sandbox_id: "sb-token-exchange-subject".to_string(), + supports_static_credential_bindings: true, })), ) .await .unwrap() - .into_inner() - .environment; + .into_inner(); - assert_eq!(legacy_env, v2_env); - assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); + assert!( + !response.environment.contains_key("subject_token"), + "token-exchange subject credentials must not enter sandbox environment material" + ); + assert!( + !response + .static_credential_bindings + .contains_key("subject_token"), + "token-exchange subject credentials must not own workload placeholders" + ); + assert!( + !response + .credential_expires_at_ms + .contains_key("subject_token"), + "withheld subject credentials must not emit sandbox expiry metadata" + ); + let dynamic_access_token = response + .dynamic_credentials + .values() + .find(|credential| credential.name == "access_token") + .expect("dynamic access_token credential should remain available"); + assert_eq!( + dynamic_access_token + .token_grant + .as_ref() + .and_then(|grant| grant.subject_token.as_ref()) + .map(|subject| subject.credential.as_str()), + Some("subject_token") + ); } #[tokio::test] - async fn provider_env_revision_changes_when_attached_provider_record_changes() { + async fn provider_env_revision_changes_on_consecutive_provider_updates_without_delay() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; - use std::time::Duration; let state = test_server_state().await; let mut provider = test_provider("work-github", "github"); state.store.put_message(&provider).await.unwrap(); + let first_resource_version = state + .store + .get_by_name(Provider::object_type(), "default", "work-github") + .await + .unwrap() + .unwrap() + .resource_version; state .store .put_message(&test_sandbox( @@ -6981,22 +10237,46 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-revision".to_string(), + supports_static_credential_bindings: true, })), ) .await .unwrap() .into_inner(); - tokio::time::sleep(Duration::from_millis(2)).await; provider .credentials .insert("GITHUB_TOKEN".to_string(), "rotated".to_string()); - state.store.put_message(&provider).await.unwrap(); + state + .store + .put_if( + Provider::object_type(), + provider.object_id(), + provider.object_name(), + provider.object_workspace(), + &provider.encode_to_vec(), + None, + crate::persistence::WriteCondition::Unconditional, + ) + .await + .unwrap(); + let second_resource_version = state + .store + .get_by_name(Provider::object_type(), "default", "work-github") + .await + .unwrap() + .unwrap() + .resource_version; + assert_ne!( + first_resource_version, second_resource_version, + "consecutive writes must advance the authoritative resource version" + ); let second = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-revision".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7013,6 +10293,193 @@ mod tests { ); } + #[tokio::test] + async fn provider_environment_revision_and_payload_share_immutable_record_snapshot() { + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, StoredProviderProfile, + }; + + fn dynamic_profile( + id: &str, + endpoint_host: &str, + token_endpoint: &str, + ) -> StoredProviderProfile { + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("profile-{id}"), + name: id.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: id.to_string(), + display_name: id.to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + env_vars: vec!["GITHUB_TOKEN".to_string()], + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: token_endpoint.to_string(), + audience: "api://snapshot".to_string(), + ..Default::default() + }), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: endpoint_host.to_string(), + port: 443, + path: "/**".to_string(), + protocol: "rest".to_string(), + access: "full".to_string(), + ..Default::default() + }], + ..Default::default() + }), + } + } + + let state = test_server_state().await; + state + .store + .put_message(&dynamic_profile( + "snapshot-a", + "api.snapshot-a.example", + "https://auth.snapshot-a.example/token", + )) + .await + .unwrap(); + state + .store + .put_message(&dynamic_profile( + "snapshot-b", + "api.snapshot-b.example", + "https://auth.snapshot-b.example/token", + )) + .await + .unwrap(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + + let mut first_provider = test_provider("replaceable", "snapshot-a"); + first_provider.metadata.as_mut().unwrap().id = "provider-identity-a".to_string(); + first_provider.credentials = + HashMap::from([("GITHUB_TOKEN".to_string(), "secret-a".to_string())]); + state.store.put_message(&first_provider).await.unwrap(); + + let provider_names = vec!["replaceable".to_string()]; + let first_records = crate::grpc::provider::load_provider_environment_records( + state.store.as_ref(), + "default", + &provider_names, + ) + .await + .unwrap(); + let first_revision = + compute_provider_env_revision_from_records(&catalog, &first_records).unwrap(); + let mut next_version_records = first_records.clone(); + next_version_records[0].resource_version += 1; + assert_ne!( + first_revision, + compute_provider_env_revision_from_records(&catalog, &next_version_records).unwrap(), + "resource version alone must advance the provider environment revision" + ); + + state + .store + .delete_by_name(Provider::object_type(), "default", "replaceable") + .await + .unwrap(); + let mut replacement = test_provider("replaceable", "snapshot-b"); + replacement.metadata.as_mut().unwrap().id = "provider-identity-b".to_string(); + replacement.credentials = + HashMap::from([("GITHUB_TOKEN".to_string(), "secret-b".to_string())]); + state.store.put_message(&replacement).await.unwrap(); + + let first_environment = crate::grpc::provider::resolve_provider_environment_from_records( + state.store.as_ref(), + &catalog, + &first_records, + ) + .await + .unwrap(); + assert_eq!( + first_environment.environment.get("GITHUB_TOKEN"), + Some(&"secret-a".to_string()) + ); + assert_eq!( + first_environment + .static_credential_bindings + .get("GITHUB_TOKEN") + .map(|binding| binding.credential_identity.as_str()), + Some("provider-identity-a:GITHUB_TOKEN") + ); + assert_eq!(first_environment.dynamic_credentials.len(), 1); + assert!( + first_environment + .dynamic_credentials + .values() + .all(|credential| { + credential.token_grant.as_ref().is_some_and(|grant| { + grant.token_endpoint == "https://auth.snapshot-a.example/token" + }) + }), + "dynamic grants must come from the first loaded provider snapshot" + ); + + let replacement_records = crate::grpc::provider::load_provider_environment_records( + state.store.as_ref(), + "default", + &provider_names, + ) + .await + .unwrap(); + let replacement_revision = + compute_provider_env_revision_from_records(&catalog, &replacement_records).unwrap(); + let replacement_environment = + crate::grpc::provider::resolve_provider_environment_from_records( + state.store.as_ref(), + &catalog, + &replacement_records, + ) + .await + .unwrap(); + + assert_ne!(first_revision, replacement_revision); + assert_eq!( + replacement_environment.environment.get("GITHUB_TOKEN"), + Some(&"secret-b".to_string()) + ); + assert_eq!( + replacement_environment + .static_credential_bindings + .get("GITHUB_TOKEN") + .map(|binding| binding.credential_identity.as_str()), + Some("provider-identity-b:GITHUB_TOKEN") + ); + assert_eq!(replacement_environment.dynamic_credentials.len(), 1); + assert!( + replacement_environment + .dynamic_credentials + .values() + .all(|credential| { + credential.token_grant.as_ref().is_some_and(|grant| { + grant.token_endpoint == "https://auth.snapshot-b.example/token" + }) + }), + "dynamic grants must change only after loading the replacement record" + ); + } + #[tokio::test] async fn provider_env_revision_changes_when_custom_profile_token_grant_changes() { use crate::grpc::provider::handle_update_provider_profiles; @@ -7056,6 +10523,8 @@ mod tests { endpoints: vec![NetworkEndpoint { host: "api.custom.example".to_string(), port: 443, + protocol: "rest".to_string(), + access: "full".to_string(), ..Default::default() }], binaries: Vec::new(), @@ -7108,25 +10577,113 @@ mod tests { profile: Some(rotated_profile), source: "custom-token.yaml".to_string(), }), - expected_resource_version: 0, - id: "custom-token".to_string(), - workspace: "default".to_string(), - })), - ) - .await - .unwrap(); + expected_resource_version: 0, + id: "custom-token".to_string(), + workspace: "default".to_string(), + })), + ) + .await + .unwrap(); + + let second = compute_provider_env_revision( + state.store.as_ref(), + "default", + &["work-custom-token".to_string()], + ) + .await + .unwrap(); + + assert_ne!( + first, second, + "custom provider profile updates must trigger sandbox dynamic credential refresh" + ); + } + + #[tokio::test] + async fn platform_profile_narrowing_changes_platform_provider_revision_when_shadowed() { + use crate::persistence::WriteCondition; + use openshell_core::proto::{ + ProviderProfile, ProviderProfileCategory, StoredProviderProfile, + }; + + fn stored_profile(workspace: &str, path: &str) -> StoredProviderProfile { + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!( + "profile-scoped-revision-{}", + if workspace.is_empty() { + "platform" + } else { + workspace + } + ), + name: "scoped-revision".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "scoped-revision".to_string(), + display_name: format!("{workspace} scoped revision"), + category: ProviderProfileCategory::Other as i32, + endpoints: vec![NetworkEndpoint { + host: "api.example.test".to_string(), + port: 443, + path: path.to_string(), + ..Default::default() + }], + ..Default::default() + }), + } + } - let second = compute_provider_env_revision( - state.store.as_ref(), - "default", - &["work-custom-token".to_string()], - ) - .await - .unwrap(); + let store = test_store().await; + store.put_message(&stored_profile("", "/**")).await.unwrap(); + store + .put_message(&stored_profile("default", "/workspace/**")) + .await + .unwrap(); + let mut provider = test_provider("platform-scoped", "scoped-revision"); + provider.profile_workspace = String::new(); + store.put_message(&provider).await.unwrap(); + + let first = + compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()]) + .await + .unwrap(); + + let mut platform = store + .get_message_by_name::("", "scoped-revision") + .await + .unwrap() + .unwrap(); + let metadata = platform.metadata.as_ref().unwrap(); + let object_id = metadata.id.clone(); + let resource_version = metadata.resource_version; + platform.profile.as_mut().unwrap().endpoints[0].path = "/v1/**".to_string(); + store + .put_if( + StoredProviderProfile::object_type(), + &object_id, + "scoped-revision", + "", + &platform.encode_to_vec(), + None, + WriteCondition::MatchResourceVersion(resource_version), + ) + .await + .unwrap(); + let second = + compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()]) + .await + .unwrap(); assert_ne!( first, second, - "custom provider profile updates must trigger sandbox dynamic credential refresh" + "narrowing the selected platform fallback must refresh sandbox credentials" ); } @@ -7141,7 +10698,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -7168,6 +10724,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7197,6 +10754,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7234,6 +10792,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7261,7 +10820,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { @@ -7336,6 +10894,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7368,6 +10927,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7404,6 +10964,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7417,7 +10978,7 @@ mod tests { } #[tokio::test] - async fn global_policy_suppresses_provider_profile_layers_when_v2_enabled() { + async fn global_policy_suppresses_provider_profile_layers() { use openshell_core::proto::{ GetSandboxConfigRequest, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -7485,17 +11046,10 @@ mod tests { }; let global_settings = StoredSettings { revision: 1, - settings: [ - ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - ), - ( - POLICY_SETTING_KEY.to_string(), - StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())), - ), - ] - .into_iter() + settings: std::iter::once(( + POLICY_SETTING_KEY.to_string(), + StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())), + )) .collect(), ..Default::default() }; @@ -7613,18 +11167,358 @@ mod tests { .unwrap(); } - /// Test helper: pin the gateway-wide proposal approval mode, mirroring - /// `openshell settings set --global proposal_approval_mode `. - async fn seed_global_approval_mode(state: &Arc, mode: &str) { - let mut settings = load_global_settings(state.store.as_ref()).await.unwrap(); - settings.settings.insert( - settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), - StoredSettingValue::String(mode.to_string()), - ); - settings.revision = settings.revision.wrapping_add(1); - save_global_settings(state.store.as_ref(), &settings) + /// Test helper: pin the gateway-wide proposal approval mode, mirroring + /// `openshell settings set --global proposal_approval_mode `. + async fn seed_global_approval_mode(state: &Arc, mode: &str) { + let mut settings = load_global_settings(state.store.as_ref()).await.unwrap(); + settings.settings.insert( + settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + StoredSettingValue::String(mode.to_string()), + ); + settings.revision = settings.revision.wrapping_add(1); + save_global_settings(state.store.as_ref(), &settings) + .await + .unwrap(); + } + + #[tokio::test] + async fn approve_all_applies_multiple_independent_chunks_and_reuses_cached_validation() { + let state = test_server_state().await; + let sandbox_id = "sb-approve-all-multiple"; + let sandbox_name = "approve-all-multiple"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_name, + ProtoSandboxPolicy::default(), + vec![], + )) + .await + .unwrap(); + + let submit = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: [ + ("alpha", "alpha.example.com", "/usr/bin/curl"), + ("beta", "beta.example.com", "/usr/bin/wget"), + ] + .into_iter() + .map(|(name, host, binary)| PolicyChunk { + rule_name: name.to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: binary.to_string(), + ..Default::default() + }], + }), + ..Default::default() + }) + .collect(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(submit.accepted_chunk_ids.len(), 2); + + let mut chunks = Vec::new(); + for (index, chunk_id) in submit.accepted_chunk_ids.iter().enumerate() { + let mut chunk = state + .store + .get_draft_chunk(chunk_id) + .await + .unwrap() + .unwrap(); + chunk.validation_result = format!("prover: cached sentinel {index}"); + assert!( + state + .store + .update_draft_chunk_evaluation(&chunk) + .await + .unwrap() + ); + chunks.push(chunk); + } + + let approved = handle_approve_all_draft_chunks( + &state, + with_user(Request::new(ApproveAllDraftChunksRequest { + name: sandbox_name.to_string(), + workspace: "default".to_string(), + approvals: chunks + .iter() + .map(|chunk| openshell_core::proto::DraftChunkApproval { + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + }) + .collect(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(approved.chunks_approved, 2); + assert_eq!(approved.chunks_skipped, 0); + assert_eq!(approved.policy_version, 1); + let revision = state + .store + .get_latest_policy(sandbox_id) + .await + .unwrap() + .unwrap(); + let policy = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap(); + assert!(policy.network_policies.contains_key("alpha")); + assert!(policy.network_policies.contains_key("beta")); + for (index, chunk) in chunks.iter().enumerate() { + let stored = state + .store + .get_draft_chunk(&chunk.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "approved"); + assert_eq!( + stored.validation_result, + format!("prover: cached sentinel {index}") + ); + } + } + + #[tokio::test] + async fn approve_all_skips_later_tls_conflict_and_applies_compatible_prefix() { + let state = test_server_state().await; + let sandbox_id = "sb-approve-all-conflict"; + let sandbox_name = "approve-all-conflict"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_name, + ProtoSandboxPolicy::default(), + vec![], + )) + .await + .unwrap(); + + let submit = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![ + PolicyChunk { + rule_name: "inspected".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "inspected".to_string(), + endpoints: vec![NetworkEndpoint { + host: "shared.example.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "read-only".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }), + ..Default::default() + }, + PolicyChunk { + rule_name: "passthrough".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "passthrough".to_string(), + endpoints: vec![NetworkEndpoint { + host: "shared.example.com".to_string(), + port: 443, + tls: "skip".to_string(), + advisor_proposed: true, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/wget".to_string(), + ..Default::default() + }], + }), + ..Default::default() + }, + ], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(submit.accepted_chunk_ids.len(), 2); + let chunks = futures::future::try_join_all( + submit + .accepted_chunk_ids + .iter() + .map(|id| state.store.get_draft_chunk(id)), + ) + .await + .unwrap() + .into_iter() + .map(Option::unwrap) + .collect::>(); + + let approved = handle_approve_all_draft_chunks( + &state, + with_user(Request::new(ApproveAllDraftChunksRequest { + name: sandbox_name.to_string(), + workspace: "default".to_string(), + approvals: chunks + .iter() + .map(|chunk| openshell_core::proto::DraftChunkApproval { + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + }) + .collect(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(approved.chunks_approved, 1); + assert_eq!(approved.chunks_skipped, 1); + assert_eq!( + state + .store + .get_draft_chunk(&chunks[0].id) + .await + .unwrap() + .unwrap() + .status, + "approved" + ); + let skipped = state + .store + .get_draft_chunk(&chunks[1].id) + .await + .unwrap() + .unwrap(); + assert_eq!(skipped.status, "pending"); + assert!(!skipped.application_error.is_empty()); + let revision = state + .store + .get_latest_policy(sandbox_id) + .await + .unwrap() + .unwrap(); + let policy = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap(); + assert!(policy.network_policies.contains_key("inspected")); + assert!(!policy.network_policies.contains_key("passthrough")); + } + + #[tokio::test] + async fn reviewed_batch_operations_stale_snapshot_apply_nothing() { + let state = test_server_state().await; + let sandbox_id = "sb-approve-all-stale"; + let sandbox_name = "approve-all-stale"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_name, + ProtoSandboxPolicy::default(), + vec![], + )) + .await + .unwrap(); + + let reviewed_policy = ProtoSandboxPolicy::default(); + let reviewed_hash = deterministic_policy_hash(&reviewed_policy); + let changed_policy = test_policy_with_rule("concurrent", "concurrent.example.com"); + let changed_hash = deterministic_policy_hash(&changed_policy); + state + .store + .put_policy_revision( + "concurrent-policy", + sandbox_id, + "default", + 1, + &changed_policy.encode_to_vec(), + &changed_hash, + ) + .await + .unwrap(); + + let operations = [ + PolicyMergeOp::AddRule { + rule_name: "alpha".to_string(), + rule: NetworkPolicyRule { + name: "alpha".to_string(), + endpoints: vec![NetworkEndpoint { + host: "alpha.example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + }, + PolicyMergeOp::AddRule { + rule_name: "beta".to_string(), + rule: NetworkPolicyRule { + name: "beta".to_string(), + endpoints: vec![NetworkEndpoint { + host: "beta.example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/wget".to_string(), + ..Default::default() + }], + }, + }, + ]; + let error = apply_merge_operations_with_retry( + state.store.as_ref(), + sandbox_id, + "default", + Some(&reviewed_policy), + &operations, + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, + Some(&reviewed_hash), + None, + ) + .await + .expect_err("a stale reviewed snapshot must reject the complete batch"); + + assert_eq!(error.code(), Code::FailedPrecondition); + let latest = state + .store + .get_latest_policy(sandbox_id) .await + .unwrap() .unwrap(); + assert_eq!(latest.policy_hash, changed_hash); + let policy = ProtoSandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap(); + assert!(policy.network_policies.contains_key("concurrent")); + assert!(!policy.network_policies.contains_key("alpha")); + assert!(!policy.network_policies.contains_key("beta")); } #[tokio::test] @@ -7689,6 +11583,10 @@ mod tests { name: sandbox_name.to_string(), include_security_flagged: false, workspace: "default".to_string(), + approvals: vec![openshell_core::proto::DraftChunkApproval { + chunk_id: chunk_id.clone(), + review_token: chunk.review_token.clone(), + }], })), ) .await @@ -7713,6 +11611,10 @@ mod tests { name: sandbox_name.to_string(), include_security_flagged: true, workspace: "default".to_string(), + approvals: vec![openshell_core::proto::DraftChunkApproval { + chunk_id: chunk_id.clone(), + review_token: chunk.review_token.clone(), + }], })), ) .await @@ -7823,6 +11725,7 @@ mod tests { name: sandbox_name.to_string(), include_security_flagged: false, workspace: "default".to_string(), + ..Default::default() })), ) .await @@ -7888,6 +11791,7 @@ mod tests { name: sandbox_name.to_string(), include_security_flagged: false, workspace: "default".to_string(), + ..Default::default() })), ) .await @@ -8163,6 +12067,7 @@ mod tests { endpoints: vec![NetworkEndpoint { host: "api.github.com".to_string(), port: 443, + allow_uninspected_credentials: true, ..Default::default() }], binaries: vec![NetworkBinary { @@ -8210,11 +12115,12 @@ mod tests { .into_inner(); assert_eq!(draft_policy.draft_version, 1); assert_eq!(draft_policy.chunks.len(), 1); - // The proposal is L4 to a host with a credential in scope, so the - // prover emits a HIGH finding and the chunk stays pending for the + // The proposal explicitly opts in to L4 credentials. The prover emits + // a HIGH finding and the security note keeps the chunk pending for the // manual approve path this test exercises. assert_eq!(draft_policy.chunks[0].status, "pending"); let chunk_id = draft_policy.chunks[0].id.clone(); + let review_token = draft_policy.chunks[0].review_token.clone(); let approve = handle_approve_draft_chunk( &state, @@ -8222,6 +12128,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), + review_token, }), ) .await @@ -8390,8 +12297,239 @@ mod tests { let proposed_rule = NetworkPolicyRule { name: "allow_example".to_string(), endpoints: vec![NetworkEndpoint { - host: "api.example.com".to_string(), + host: "api.example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + let submit = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + proposed_chunks: vec![PolicyChunk { + rule_name: "allow_example".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "agent intent".to_string(), + ..Default::default() + }], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + let chunk_id = submit.accepted_chunk_ids[0].clone(); + + let guidance = "scope to docs/ paths only, not all repo contents"; + handle_reject_draft_chunk( + &state, + authed_request(RejectDraftChunkRequest { + name: sandbox_name.clone(), + chunk_id: chunk_id.clone(), + reason: guidance.to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + with_user(Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + workspace: "default".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + let rejected = draft + .chunks + .iter() + .find(|c| c.id == chunk_id) + .expect("rejected chunk should still be visible"); + assert_eq!(rejected.status, "rejected"); + assert_eq!( + rejected.rejection_reason, guidance, + "reviewer's free-form reason must round-trip into the chunk for agent readback" + ); + // The prover now runs on every proposal regardless of analysis_mode. + // For this rule (L4 to api.example.com, no provider attached, no + // credential in scope), v1 calibration emits no finding — so the + // verdict is the clean "no new findings" string, not empty. + assert_eq!(rejected.validation_result, "prover: no new findings"); + } + + #[tokio::test] + async fn agent_authored_exact_l7_proposal_gets_prover_pass_verdict() { + use openshell_core::proto::{ + FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, + SandboxPolicy, SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "agent-l7-verdict".to_string(); + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-agent-l7-verdict".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + 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(); + // Opt this sandbox into auto-approval via the settings model — same + // path the CLI's `--approval-mode auto` exercises — to test the + // empty-delta → approved path. + seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; + + let proposed_rule = NetworkPolicyRule { + name: "github_contents_write".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/org/repo/contents/demo/file.md".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + + handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_contents_write".to_string(), + proposed_rule: Some(proposed_rule), + rationale: "write one demo file".to_string(), + ..Default::default() + }], + ..Default::default() + })), + ) + .await + .unwrap(); + + let draft = handle_get_draft_policy( + &state, + with_user(Request::new(GetDraftPolicyRequest { + name: sandbox_name, + status_filter: String::new(), + workspace: "default".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + let verdict = &draft.chunks[0].validation_result; + assert_eq!( + verdict, "prover: no new findings", + "exact L7 PUT against an inspected endpoint should not introduce \ + any new findings over baseline; got: {verdict}" + ); + // Auto-approval gate: empty delta + sandbox opted into auto mode → + // status flips to approved without human action. The canonical + // happy path for agent speed. + assert_eq!( + draft.chunks[0].status, "approved", + "empty-delta agent-authored proposal under auto mode must auto-approve; \ + got status: {}", + draft.chunks[0].status + ); + } + + /// Implicit supersede: when a refined agent-authored proposal lands for + /// the same `(host, port, binary)` as a pending mechanistic chunk, the + /// older mechanistic chunk is auto-rejected with a "superseded by + /// chunk X" reason. This is the refinement loop without a + /// `supersedes_chunk_id` field — structural overlap is enough. + #[tokio::test] + async fn agent_authored_submission_supersedes_pending_mechanistic_for_same_endpoint() { + use openshell_core::proto::{ + FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, + SandboxPolicy, SandboxSpec, + }; + + let state = test_server_state().await; + // github provider attached so the mechanistic L4 lands a HIGH + // finding and stays pending. + state + .store + .put_message(&test_provider("github-pat", "github")) + .await + .unwrap(); + let sandbox_name = "supersede-flow".to_string(); + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-supersede-flow".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + providers: vec!["github-pat".to_string()], + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sandbox).await.unwrap(); + + // Step 1: mechanistic submits a broad L4 grant; the prover flags it + // HIGH, so it lands in pending. + let mechanistic_rule = NetworkPolicyRule { + name: "allow_api_github_com_443".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), port: 443, + allow_uninspected_credentials: true, ..Default::default() }], binaries: vec![NetworkBinary { @@ -8399,15 +12537,15 @@ mod tests { ..Default::default() }], }; - - let submit = handle_submit_policy_analysis( + let mechanistic_submit = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), + analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { - rule_name: "allow_example".to_string(), - proposed_rule: Some(proposed_rule), - rationale: "agent intent".to_string(), + rule_name: "allow_api_github_com_443".to_string(), + proposed_rule: Some(mechanistic_rule), + rationale: "Allow /usr/bin/curl to connect to api.github.com:443.".to_string(), ..Default::default() }], ..Default::default() @@ -8416,22 +12554,87 @@ mod tests { .await .unwrap() .into_inner(); - let chunk_id = submit.accepted_chunk_ids[0].clone(); + let mechanistic_chunk_id = mechanistic_submit.accepted_chunk_ids[0].clone(); - let guidance = "scope to docs/ paths only, not all repo contents"; - handle_reject_draft_chunk( + // Sanity-check: the mechanistic chunk is pending and carries a HIGH + // finding. + let draft = handle_get_draft_policy( &state, - authed_request(RejectDraftChunkRequest { + with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), - chunk_id: chunk_id.clone(), - reason: guidance.to_string(), + status_filter: String::new(), workspace: "default".to_string(), - }), + })), ) .await - .unwrap(); + .unwrap() + .into_inner(); + let mech = draft + .chunks + .iter() + .find(|c| c.id == mechanistic_chunk_id) + .expect("mechanistic chunk present"); + assert_eq!(mech.status, "pending"); + // The attached GitHub profile already grants credentialed reach for + // this host, so the mechanistic proposal does not expand reach. + assert!( + !mech + .validation_result + .contains("credential_reach_expansion"), + "profile-composed reach should prevent a duplicate expansion finding; got: {}", + mech.validation_result + ); - let draft = handle_get_draft_policy( + // Step 2: the agent refines into a narrow L7 proposal for the SAME + // (host, port, binary). Under the v1 calibration, an L7 PUT on a + // host where the binary already had credentialed reach (read-only) + // emits a capability_expansion finding (new method on already- + // reached host) rather than a fresh reach expansion. The agent + // chunk stays pending for human review. The mechanistic chunk gets + // auto-rejected as superseded regardless of the agent chunk's own + // validation verdict — supersede is unconditional on `(host, port, + // binary)` overlap. + let agent_rule = NetworkPolicyRule { + name: "github_contents_put".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "PUT".to_string(), + path: "/repos/owner/name/contents/path/file.md".to_string(), + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }; + let agent_submit = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "github_contents_put".to_string(), + proposed_rule: Some(agent_rule), + rationale: "refined L7 scope for the demo write".to_string(), + ..Default::default() + }], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + let agent_chunk_id = agent_submit.accepted_chunk_ids[0].clone(); + + let draft_after = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, @@ -8442,35 +12645,67 @@ mod tests { .await .unwrap() .into_inner(); - let rejected = draft + + let agent = draft_after .chunks .iter() - .find(|c| c.id == chunk_id) - .expect("rejected chunk should still be visible"); - assert_eq!(rejected.status, "rejected"); + .find(|c| c.id == agent_chunk_id) + .expect("agent chunk present"); + let mech_after = draft_after + .chunks + .iter() + .find(|c| c.id == mechanistic_chunk_id) + .expect("mechanistic chunk should still be visible (with new status)"); + assert_eq!( - rejected.rejection_reason, guidance, - "reviewer's free-form reason must round-trip into the chunk for agent readback" + agent.status, "pending", + "agent-authored L7 PUT with credential in scope must land in pending; \ + the baseline policy has no pre-existing rule for curl on api.github.com \ + so the agent's chunk grants brand-new credentialed reach. got: {}", + agent.status + ); + assert!( + agent + .validation_result + .contains("credential_reach_expansion"), + "agent chunk should carry credential_reach_expansion (new credentialed reach \ + on api.github.com); got: {}", + agent.validation_result + ); + assert_eq!( + mech_after.status, "rejected", + "older mechanistic chunk for same (host, port, binary) should be superseded; \ + got: {}", + mech_after.status + ); + assert!( + mech_after.rejection_reason.contains(&agent_chunk_id), + "rejection reason should cite the superseding chunk id; got: {}", + mech_after.rejection_reason + ); + assert!( + mech_after.rejection_reason.contains("superseded"), + "rejection reason should explain the supersede; got: {}", + mech_after.rejection_reason ); - // The prover now runs on every proposal regardless of analysis_mode. - // For this rule (L4 to api.example.com, no provider attached, no - // credential in scope), v1 calibration emits no finding — so the - // verdict is the clean "no new findings" string, not empty. - assert_eq!(rejected.validation_result, "prover: no new findings"); } + /// Auto-approval is **proposer-agnostic**: a mechanistic proposal whose + /// prover delta is empty auto-approves the same way an agent-authored one + /// does. Source provenance is preserved in the audit trail (OCSF event + /// `source=mechanistic`) but does not change the safety decision. #[tokio::test] - async fn agent_authored_exact_l7_proposal_gets_prover_pass_verdict() { + async fn mechanistic_proposal_with_empty_delta_also_auto_approves() { use openshell_core::proto::{ - FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, - SandboxPolicy, SandboxSpec, + FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + SandboxSpec, }; let state = test_server_state().await; - let sandbox_name = "agent-l7-verdict".to_string(); + let sandbox_name = "mechanistic-clean".to_string(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-agent-l7-verdict".to_string(), + id: "sb-mechanistic-clean".to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), @@ -8488,31 +12723,22 @@ mod tests { }), ..Default::default() }), + // No providers → no credential in scope for the proposed host. ..Default::default() }), ..Default::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - // Opt this sandbox into auto-approval via the settings model — same - // path the CLI's `--approval-mode auto` exercises — to test the - // empty-delta → approved path. + // Opt into auto mode via the settings model to test the + // proposer-agnostic gate. seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; let proposed_rule = NetworkPolicyRule { - name: "github_contents_write".to_string(), + name: "anon_l4".to_string(), endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), + host: "example.com".to_string(), port: 443, - protocol: "rest".to_string(), - enforcement: "enforce".to_string(), - rules: vec![L7Rule { - allow: Some(L7Allow { - method: "PUT".to_string(), - path: "/repos/org/repo/contents/demo/file.md".to_string(), - ..Default::default() - }), - }], ..Default::default() }], binaries: vec![NetworkBinary { @@ -8525,11 +12751,11 @@ mod tests { &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), - analysis_mode: "agent_authored".to_string(), + analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { - rule_name: "github_contents_write".to_string(), + rule_name: "anon_l4".to_string(), proposed_rule: Some(proposed_rule), - rationale: "write one demo file".to_string(), + rationale: "Allow /usr/bin/curl to connect to example.com:443.".to_string(), ..Default::default() }], ..Default::default() @@ -8550,172 +12776,210 @@ mod tests { .unwrap() .into_inner(); let verdict = &draft.chunks[0].validation_result; - assert_eq!( - verdict, "prover: no new findings", - "exact L7 PUT against an inspected endpoint should not introduce \ - any new findings over baseline; got: {verdict}" - ); - // Auto-approval gate: empty delta + sandbox opted into auto mode → - // status flips to approved without human action. The canonical - // happy path for agent speed. + assert_eq!(verdict, "prover: no new findings"); assert_eq!( draft.chunks[0].status, "approved", - "empty-delta agent-authored proposal under auto mode must auto-approve; \ - got status: {}", + "empty-delta mechanistic proposal under auto mode must auto-approve \ + (proposer-agnostic); got status: {}", draft.chunks[0].status ); } - /// Implicit supersede: when a refined agent-authored proposal lands for - /// the same `(host, port, binary)` as a pending mechanistic chunk, the - /// older mechanistic chunk is auto-rejected with a "superseded by - /// chunk X" reason. This is the refinement loop without a - /// `supersedes_chunk_id` field — structural overlap is enough. #[tokio::test] - async fn agent_authored_submission_supersedes_pending_mechanistic_for_same_endpoint() { + async fn mechanistic_existing_multi_port_rest_endpoint_auto_approves_narrow_overlay() { use openshell_core::proto::{ - FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase, - SandboxPolicy, SandboxSpec, + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy, + SandboxSpec, }; let state = test_server_state().await; - // github provider attached so the mechanistic L4 lands a HIGH - // finding and stays pending. - state - .store - .put_message(&test_provider("github-pat", "github")) - .await - .unwrap(); - let sandbox_name = "supersede-flow".to_string(); + let sandbox_name = "mechanistic-existing-rest".to_string(); + let mut base_policy = SandboxPolicy::default(); + base_policy.network_policies.insert( + "cargo_registry".to_string(), + NetworkPolicyRule { + name: "cargo-registry".to_string(), + endpoints: vec![NetworkEndpoint { + host: "index.crates.io".to_string(), + port: 80, + ports: vec![80, 443], + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "read-only".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/cargo".to_string(), + ..Default::default() + }], + }, + ); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-supersede-flow".to_string(), + id: "sb-mechanistic-existing-rest".to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { - version: 1, - filesystem: Some(FilesystemPolicy { - read_write: vec!["/sandbox".to_string()], - ..Default::default() - }), - ..Default::default() - }), - providers: vec!["github-pat".to_string()], - ..Default::default() - }), - ..Default::default() - }; - sandbox.set_phase(SandboxPhase::Ready as i32); - state.store.put_message(&sandbox).await.unwrap(); - - // Step 1: mechanistic submits a broad L4 grant; the prover flags it - // HIGH, so it lands in pending. - let mechanistic_rule = NetworkPolicyRule { - name: "allow_api_github_com_443".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), + policy: Some(base_policy), ..Default::default() - }], + }), + ..Default::default() }; - let mechanistic_submit = handle_submit_policy_analysis( + sandbox.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sandbox).await.unwrap(); + seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; + + let mut advisor_binary = NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }; + #[allow(deprecated)] + { + advisor_binary.harness = true; + } + handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "mechanistic".to_string(), proposed_chunks: vec![PolicyChunk { - rule_name: "allow_api_github_com_443".to_string(), - proposed_rule: Some(mechanistic_rule), - rationale: "Allow /usr/bin/curl to connect to api.github.com:443.".to_string(), + rule_name: "allow_index_crates_io_443".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "allow_index_crates_io_443".to_string(), + endpoints: vec![NetworkEndpoint { + host: "index.crates.io".to_string(), + port: 443, + advisor_proposed: true, + ..Default::default() + }], + binaries: vec![advisor_binary], + }), + hit_count: 1, ..Default::default() }], ..Default::default() })), ) .await - .unwrap() - .into_inner(); - let mechanistic_chunk_id = mechanistic_submit.accepted_chunk_ids[0].clone(); + .unwrap(); - // Sanity-check: the mechanistic chunk is pending and carries a HIGH - // finding. let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), - status_filter: String::new(), + name: sandbox_name, workspace: "default".to_string(), + ..Default::default() })), ) .await .unwrap() .into_inner(); - let mech = draft - .chunks - .iter() - .find(|c| c.id == mechanistic_chunk_id) - .expect("mechanistic chunk present"); - assert_eq!(mech.status, "pending"); - // Mechanistic L4 with credential in scope flags as new credentialed - // reach for the binary on the host. + let chunk = &draft.chunks[0]; + assert_eq!( + chunk.status, "approved", + "application error: {}; prover: {}", + chunk.application_error, chunk.validation_result + ); + assert_eq!(chunk.rule_name, "allow_index_crates_io_443"); + assert_eq!(chunk.validation_result, "prover: no new findings"); + assert!(chunk.application_error.is_empty()); + assert!(!chunk.review_token.is_empty()); + let canonical = chunk.proposed_rule.as_ref().unwrap(); + assert_eq!(canonical.endpoints[0].protocol, "rest"); + assert_eq!(canonical.endpoints[0].access, "read-only"); assert!( - mech.validation_result - .contains("credential_reach_expansion"), - "mechanistic L4 with credential in scope should emit \ - credential_reach_expansion; got: {}", - mech.validation_result + canonical.endpoints[0].advisor_proposed, + "a new advisor overlay must retain proposal provenance" ); - // Step 2: the agent refines into a narrow L7 proposal for the SAME - // (host, port, binary). Under the v1 calibration, an L7 PUT on a - // host where the binary already had credentialed reach (read-only) - // emits a capability_expansion finding (new method on already- - // reached host) rather than a fresh reach expansion. The agent - // chunk stays pending for human review. The mechanistic chunk gets - // auto-rejected as superseded regardless of the agent chunk's own - // validation verdict — supersede is unconditional on `(host, port, - // binary)` overlap. - let agent_rule = NetworkPolicyRule { - name: "github_contents_put".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - protocol: "rest".to_string(), - enforcement: "enforce".to_string(), - rules: vec![L7Rule { - allow: Some(L7Allow { - method: "PUT".to_string(), - path: "/repos/owner/name/contents/path/file.md".to_string(), - ..Default::default() - }), - }], + let revision = state + .store + .get_latest_policy("sb-mechanistic-existing-rest") + .await + .unwrap() + .expect("auto approval persisted a policy revision"); + let applied = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap(); + let cargo_rule = &applied.network_policies["cargo_registry"]; + assert_eq!(cargo_rule.endpoints.len(), 1); + assert_eq!(cargo_rule.endpoints[0].ports, vec![80, 443]); + assert_eq!(cargo_rule.endpoints[0].protocol, "rest"); + assert_eq!(cargo_rule.endpoints[0].access, "read-only"); + assert_eq!(cargo_rule.binaries.len(), 1); + assert_eq!(cargo_rule.binaries[0].path, "/usr/bin/cargo"); + let curl_rule = &applied.network_policies["allow_index_crates_io_443"]; + assert_eq!(curl_rule.endpoints.len(), 1); + assert_eq!(curl_rule.endpoints[0].ports, vec![443]); + assert_eq!(curl_rule.endpoints[0].protocol, "rest"); + assert_eq!(curl_rule.endpoints[0].access, "read-only"); + assert!( + curl_rule.endpoints[0].advisor_proposed, + "the persisted advisor overlay must retain proposal provenance" + ); + assert_eq!(curl_rule.binaries.len(), 1); + assert_eq!(curl_rule.binaries[0].path, "/usr/bin/curl"); + } + + #[tokio::test] + async fn malformed_graphql_candidate_is_rejected_before_reviewer_inbox() { + use openshell_core::proto::{ + L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, + SandboxPolicy, SandboxSpec, + }; + + let state = test_server_state().await; + let sandbox_name = "invalid-graphql-preflight".to_string(); + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-invalid-graphql-preflight".to_string(), + name: sandbox_name.clone(), + created_at_ms: 1_000_000, + workspace: "default".to_string(), ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".to_string(), + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy::default()), ..Default::default() - }], + }), + ..Default::default() }; - let agent_submit = handle_submit_policy_analysis( + sandbox.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let response = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { - rule_name: "github_contents_put".to_string(), - proposed_rule: Some(agent_rule), - rationale: "refined L7 scope for the demo write".to_string(), + rule_name: "bad_graphql".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "bad-graphql".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + protocol: "graphql".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + // Runtime requires an operation type for + // GraphQL rules; this intentionally omits it. + fields: vec!["viewer".to_string()], + ..Default::default() + }), + }], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }), ..Default::default() }], ..Default::default() @@ -8724,110 +12988,192 @@ mod tests { .await .unwrap() .into_inner(); - let agent_chunk_id = agent_submit.accepted_chunk_ids[0].clone(); - let draft_after = handle_get_draft_policy( + assert_eq!(response.accepted_chunks, 0); + assert_eq!(response.rejected_chunks, 1); + assert!(response.rejection_reasons[0].contains("operation_type")); + let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, - status_filter: String::new(), workspace: "default".to_string(), + ..Default::default() })), ) .await .unwrap() .into_inner(); - - let agent = draft_after - .chunks - .iter() - .find(|c| c.id == agent_chunk_id) - .expect("agent chunk present"); - let mech_after = draft_after - .chunks - .iter() - .find(|c| c.id == mechanistic_chunk_id) - .expect("mechanistic chunk should still be visible (with new status)"); - - assert_eq!( - agent.status, "pending", - "agent-authored L7 PUT with credential in scope must land in pending; \ - the baseline policy has no pre-existing rule for curl on api.github.com \ - so the agent's chunk grants brand-new credentialed reach. got: {}", - agent.status - ); - assert!( - agent - .validation_result - .contains("credential_reach_expansion"), - "agent chunk should carry credential_reach_expansion (new credentialed reach \ - on api.github.com); got: {}", - agent.validation_result - ); - assert_eq!( - mech_after.status, "rejected", - "older mechanistic chunk for same (host, port, binary) should be superseded; \ - got: {}", - mech_after.status - ); - assert!( - mech_after.rejection_reason.contains(&agent_chunk_id), - "rejection reason should cite the superseding chunk id; got: {}", - mech_after.rejection_reason - ); - assert!( - mech_after.rejection_reason.contains("superseded"), - "rejection reason should explain the supersede; got: {}", - mech_after.rejection_reason - ); + assert!(draft.chunks.is_empty()); } - /// Auto-approval is **proposer-agnostic**: a mechanistic proposal whose - /// prover delta is empty auto-approves the same way an agent-authored one - /// does. Source provenance is preserved in the audit trail (OCSF event - /// `source=mechanistic`) but does not change the safety decision. #[tokio::test] - async fn mechanistic_proposal_with_empty_delta_also_auto_approves() { + async fn changed_policy_inputs_refresh_token_and_require_fresh_review() { use openshell_core::proto::{ - FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy, + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy, SandboxSpec, }; let state = test_server_state().await; - let sandbox_name = "mechanistic-clean".to_string(); + let sandbox_name = "stale-review-token".to_string(); + let sandbox_id = "sb-stale-review-token"; let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-mechanistic-clean".to_string(), + id: sandbox_id.to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, - labels: std::collections::HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + ..Default::default() }), spec: Some(SandboxSpec { - policy: Some(SandboxPolicy { - version: 1, - filesystem: Some(FilesystemPolicy { - read_write: vec!["/sandbox".to_string()], - ..Default::default() - }), - ..Default::default() - }), - // No providers → no credential in scope for the proposed host. + policy: Some(SandboxPolicy::default()), ..Default::default() }), ..Default::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); - // Opt into auto mode via the settings model to test the - // proposer-agnostic gate. - seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await; - let proposed_rule = NetworkPolicyRule { - name: "anon_l4".to_string(), + let submit = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "example".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }), + ..Default::default() + }], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + let chunk_id = &submit.accepted_chunk_ids[0]; + let before = state + .store + .get_draft_chunk(chunk_id) + .await + .unwrap() + .unwrap(); + assert!(!before.review_token.is_empty()); + + // The cached verdict is deliberately replaced with a sentinel while + // retaining its token. With unchanged inputs, evaluation must carry + // that value through instead of invoking the prover again. + let mut cached = before.clone(); + cached.validation_result = "prover: cached sentinel".to_string(); + assert!( + state + .store + .update_draft_chunk_evaluation(&cached) + .await + .unwrap() + ); + let unchanged = require_current_proposal_evaluation( + &state, + "default", + &sandbox, + &cached, + Some(&cached.review_token), + ) + .await + .unwrap(); + assert_eq!(unchanged.validation_result, "prover: cached sentinel"); + + let mut changed_base = SandboxPolicy::default(); + changed_base.network_policies.insert( + "unrelated".to_string(), + NetworkPolicyRule { + name: "unrelated".to_string(), + endpoints: vec![NetworkEndpoint { + host: "unrelated.example".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/wget".to_string(), + ..Default::default() + }], + }, + ); + let changed_hash = deterministic_policy_hash(&changed_base); + state + .store + .put_policy_revision( + "stale-token-policy", + sandbox_id, + "default", + 1, + &changed_base.encode_to_vec(), + &changed_hash, + ) + .await + .unwrap(); + + let error = handle_approve_draft_chunk( + &state, + with_user(Request::new(ApproveDraftChunkRequest { + name: sandbox_name, + chunk_id: chunk_id.clone(), + workspace: "default".to_string(), + review_token: before.review_token.clone(), + })), + ) + .await + .expect_err("changed base policy must invalidate the reviewed token"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("inputs changed")); + let refreshed = state + .store + .get_draft_chunk(chunk_id) + .await + .unwrap() + .unwrap(); + assert_eq!(refreshed.status, "pending"); + assert_ne!(refreshed.review_token, before.review_token); + assert_eq!( + state + .store + .get_latest_policy(sandbox_id) + .await + .unwrap() + .unwrap() + .policy_hash, + changed_hash + ); + } + + #[tokio::test] + async fn policy_change_reconciles_pending_chunk_already_covered_by_live_policy() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + + let state = test_server_state().await; + let sandbox_id = "sb-covered-pending"; + let sandbox_name = "covered-pending"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_name, + ProtoSandboxPolicy::default(), + Vec::new(), + )) + .await + .unwrap(); + let rule = NetworkPolicyRule { + name: "example".to_string(), endpoints: vec![NetworkEndpoint { host: "example.com".to_string(), port: 443, @@ -8838,42 +13184,42 @@ mod tests { ..Default::default() }], }; - - handle_submit_policy_analysis( + let submit = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { - name: sandbox_name.clone(), - analysis_mode: "mechanistic".to_string(), + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), proposed_chunks: vec![PolicyChunk { - rule_name: "anon_l4".to_string(), - proposed_rule: Some(proposed_rule), - rationale: "Allow /usr/bin/curl to connect to example.com:443.".to_string(), + rule_name: "example".to_string(), + proposed_rule: Some(rule.clone()), ..Default::default() }], ..Default::default() })), ) .await - .unwrap(); - - let draft = handle_get_draft_policy( - &state, - with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, - status_filter: String::new(), - workspace: "default".to_string(), - })), - ) - .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; - assert_eq!(verdict, "prover: no new findings"); + let chunk_id = &submit.accepted_chunk_ids[0]; + + let mut live = ProtoSandboxPolicy::default(); + live.network_policies.insert("example".to_string(), rule); assert_eq!( - draft.chunks[0].status, "approved", - "empty-delta mechanistic proposal under auto mode must auto-approve \ - (proposer-agnostic); got status: {}", - draft.chunks[0].status + reconcile_pending_chunks_covered_by_policy(&state, sandbox_id, &live, 7) + .await + .unwrap(), + 1 + ); + let reconciled = state + .store + .get_draft_chunk(chunk_id) + .await + .unwrap() + .unwrap(); + assert_eq!(reconciled.status, "rejected"); + assert_eq!( + reconciled.rejection_reason, + "covered by active policy revision 7" ); } @@ -9501,6 +13847,7 @@ mod tests { endpoints: vec![NetworkEndpoint { host: "api.github.com".to_string(), port: 443, + allow_uninspected_credentials: true, ..Default::default() }], binaries: vec![NetworkBinary { @@ -9589,6 +13936,7 @@ mod tests { last_seen_ms: 0, validation_result: String::new(), rejection_reason: String::new(), + ..Default::default() }; state .store @@ -9602,6 +13950,7 @@ mod tests { name: sandbox_name.to_string(), chunk_id: chunk.id.clone(), workspace: "default".to_string(), + ..Default::default() })), ) .await @@ -9680,6 +14029,7 @@ mod tests { endpoints: vec![NetworkEndpoint { host: "api.github.com".to_string(), port: 443, + allow_uninspected_credentials: true, ..Default::default() }], binaries: vec![NetworkBinary { @@ -9918,7 +14268,7 @@ mod tests { } #[tokio::test] - async fn agent_authored_validation_uses_providers_v2_effective_policy() { + async fn agent_authored_validation_uses_profile_composed_effective_policy() { use openshell_core::proto::{ FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, ProviderProfile, ProviderProfileCategory, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -9926,7 +14276,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-custom", "custom-api")) @@ -9978,10 +14327,11 @@ mod tests { .await .unwrap(); + let sandbox_id = "sb-agent-provider-effective-policy"; let sandbox_name = "agent-provider-effective-policy".to_string(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-agent-provider-effective-policy".to_string(), + id: sandbox_id.to_string(), name: sandbox_name.clone(), created_at_ms: 1_000_000, labels: HashMap::new(), @@ -10007,13 +14357,17 @@ mod tests { sandbox.set_phase(SandboxPhase::Ready as i32); state.store.put_message(&sandbox).await.unwrap(); + #[allow(deprecated)] let proposed_rule = NetworkPolicyRule { name: "github_contents_write".to_string(), endpoints: vec![NetworkEndpoint { host: "api.github.com".to_string(), port: 443, protocol: "rest".to_string(), - enforcement: "enforce".to_string(), + // Match the provider-owned endpoint contract so this test + // exercises prover composition rather than a deterministic + // application failure. + enforcement: "audit".to_string(), rules: vec![L7Rule { allow: Some(L7Allow { method: "PUT".to_string(), @@ -10021,15 +14375,16 @@ mod tests { ..Default::default() }), }], + advisor_proposed: true, ..Default::default() }], binaries: vec![NetworkBinary { path: "/usr/bin/curl".to_string(), - ..Default::default() + harness: true, }], }; - handle_submit_policy_analysis( + let submit = handle_submit_policy_analysis( &state, with_user(Request::new(SubmitPolicyAnalysisRequest { name: sandbox_name.clone(), @@ -10044,12 +14399,16 @@ mod tests { })), ) .await - .unwrap(); + .unwrap() + .into_inner(); + assert_eq!(submit.accepted_chunks, 1); + assert_eq!(submit.rejected_chunks, 0); + let chunk_id = submit.accepted_chunk_ids[0].clone(); let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + name: sandbox_name.clone(), status_filter: String::new(), workspace: "default".to_string(), })), @@ -10057,7 +14416,13 @@ mod tests { .await .unwrap() .into_inner(); - let verdict = &draft.chunks[0].validation_result; + let chunk = draft + .chunks + .iter() + .find(|chunk| chunk.id == chunk_id) + .expect("provider-overlap proposal should reach the draft inbox"); + assert_eq!(chunk.status, "pending"); + let verdict = &chunk.validation_result; let first_line = verdict.lines().next().unwrap_or(""); assert!( first_line.starts_with("prover: "), @@ -10067,7 +14432,49 @@ mod tests { assert!( !verdict.contains("validation unavailable"), "providers-v2 composition must not break the prover pipeline; \ - got: {verdict}" + got: {verdict}" + ); + + handle_approve_draft_chunk( + &state, + authed_request(ApproveDraftChunkRequest { + name: sandbox_name, + chunk_id, + workspace: "default".to_string(), + review_token: chunk.review_token.clone(), + }), + ) + .await + .expect("provider-overlap proposal should approve"); + + let stored = state + .store + .get_latest_policy(sandbox_id) + .await + .unwrap() + .expect("approval should persist a base-policy revision"); + let base_policy = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()).unwrap(); + assert!( + base_policy + .network_policies + .contains_key("github_contents_write") + ); + assert!( + !base_policy + .network_policies + .contains_key("_provider_work_custom"), + "provider-composed rules must not be copied into the mutable base policy" + ); + + let effective_policy = get_sandbox_policy(&state, sandbox_id).await; + let provider_rule = &effective_policy.network_policies["_provider_work_custom"]; + assert_eq!(provider_rule.endpoints[0].access, "full"); + assert_eq!(provider_rule.endpoints[0].deny_rules.len(), 1); + assert!(!provider_rule.endpoints[0].advisor_proposed); + assert!( + effective_policy + .network_policies + .contains_key("github_contents_write") ); } @@ -10092,7 +14499,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; // Github provider attached: a credential ends up in scope for // api.github.com (PUT proposal flags MEDIUM). raw.githubusercontent.com @@ -10681,6 +15087,7 @@ mod tests { last_seen_ms: 1_000, validation_result: String::new(), rejection_reason: String::new(), + ..Default::default() } } @@ -10844,6 +15251,13 @@ mod tests { .unwrap() .into_inner(); let chunk_id = submit.accepted_chunk_ids[0].clone(); + let review_token = state + .store + .get_draft_chunk(&chunk_id) + .await + .unwrap() + .unwrap() + .review_token; handle_reject_draft_chunk( &state, @@ -10863,6 +15277,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), + review_token, }), ) .await @@ -10908,9 +15323,9 @@ mod tests { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec}; let state = test_server_state().await; - // Attach a github provider so the L4 proposal below has a credential - // in scope and the prover emits a HIGH finding — keeps the chunk - // pending so this cross-sandbox approve check is reachable. + // Attach a github provider so the explicitly opted-in L4 proposal + // below has a credential in scope and stays pending, keeping this + // cross-sandbox approve check reachable. state .store .put_message(&test_provider("github-pat", "github")) @@ -10961,6 +15376,7 @@ mod tests { endpoints: vec![NetworkEndpoint { host: "api.github.com".to_string(), port: 443, + allow_uninspected_credentials: true, ..Default::default() }], binaries: vec![NetworkBinary { @@ -11002,6 +15418,7 @@ mod tests { .unwrap() .into_inner(); let chunk_id = draft_policy.chunks[0].id.clone(); + let review_token = draft_policy.chunks[0].review_token.clone(); let other_name = sandbox_b.object_name().to_string(); let approve_err = handle_approve_draft_chunk( @@ -11010,6 +15427,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), + review_token: String::new(), }), ) .await @@ -11048,6 +15466,7 @@ mod tests { name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), + review_token, }), ) .await @@ -11275,6 +15694,7 @@ mod tests { last_seen_ms: 0, validation_result: String::new(), rejection_reason: String::new(), + ..Default::default() }; let (version, _) = @@ -11373,6 +15793,7 @@ mod tests { last_seen_ms: 0, validation_result: String::new(), rejection_reason: String::new(), + ..Default::default() }; let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk, &[]) @@ -11475,6 +15896,7 @@ mod tests { last_seen_ms: 0, validation_result: String::new(), rejection_reason: String::new(), + ..Default::default() }; let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk, &[]) @@ -11564,7 +15986,11 @@ mod tests { "default", None, &add_allow, - &[], + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, + None, None ), apply_merge_operations_with_retry( @@ -11573,7 +15999,11 @@ mod tests { "default", None, &add_deny, - &[], + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, + None, None ), ); @@ -12042,32 +16472,6 @@ mod tests { .unwrap(); } - #[tokio::test] - async fn enabling_provider_composition_rejects_existing_ambiguous_binding() { - let state = test_server_state().await; - install_ambiguous_provider_binding(&state, "enable").await; - - let error = handle_update_config( - &state, - with_user(Request::new(UpdateConfigRequest { - global: true, - setting_key: settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }), - ..Default::default() - })), - ) - .await - .expect_err("provider composition must be validated before activation"); - - assert_eq!(error.code(), Code::FailedPrecondition); - assert!(error.message().contains("sandbox-enable")); - assert!(error.message().contains("tls")); - let settings = load_global_settings(state.store.as_ref()).await.unwrap(); - assert!(!bool_setting_enabled(&settings, settings::PROVIDERS_V2_ENABLED_KEY).unwrap()); - } - #[tokio::test] async fn deleting_global_policy_rejects_reactivated_ambiguous_provider_binding() { let state = test_server_state().await; @@ -12083,20 +16487,6 @@ mod tests { ) .await .expect("global policy should suppress provider composition"); - handle_update_config( - &state, - with_user(Request::new(UpdateConfigRequest { - global: true, - setting_key: settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }), - ..Default::default() - })), - ) - .await - .expect("providers may be enabled while a global policy is active"); - let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { @@ -12115,21 +16505,28 @@ mod tests { assert!(settings.settings.contains_key(POLICY_SETTING_KEY)); } + #[tokio::test] + async fn startup_preflight_rejects_persisted_ambiguous_provider_binding() { + let state = test_server_state().await; + install_ambiguous_provider_binding(&state, "upgrade").await; + + let error = validate_provider_composition_startup_preflight(&state) + .await + .expect_err("startup must reject policy that unconditional composition would activate"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("sandbox-upgrade")); + assert!(error.message().contains("invalid effective policy")); + } + #[test] fn merge_effective_settings_global_overrides_sandbox_key() { let global = StoredSettings { revision: 2, - settings: [ - ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(false), - ), - ( - settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(false), - ), - ] - .into_iter() + settings: std::iter::once(( + settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + StoredSettingValue::Bool(false), + )) .collect(), ..Default::default() }; @@ -12137,7 +16534,7 @@ mod tests { revision: 1, settings: [ ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), + settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), StoredSettingValue::Bool(true), ), ( @@ -12151,15 +16548,6 @@ mod tests { }; let merged = merge_effective_settings(&global, &sandbox).unwrap(); - let providers_v2 = merged - .get(settings::PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled present"); - assert_eq!(providers_v2.scope, SettingScope::Global as i32); - assert_eq!( - providers_v2.value.as_ref().and_then(|v| v.value.as_ref()), - Some(&setting_value::Value::BoolValue(false)) - ); - let ocsf_json = merged .get("ocsf_json_enabled") .expect("ocsf_json_enabled present"); @@ -12352,6 +16740,138 @@ mod tests { ); } + #[test] + fn review_token_and_policy_hash_are_stable_across_nested_proto_map_order() { + use openshell_core::proto::{GraphqlOperation, L7Allow, L7QueryMatcher}; + + fn matcher(value: &str) -> L7QueryMatcher { + L7QueryMatcher { + glob: value.to_string(), + any: Vec::new(), + } + } + + fn rule(reverse: bool) -> NetworkPolicyRule { + let mut query = HashMap::new(); + let mut params = HashMap::new(); + let mut persisted = HashMap::new(); + let entries = if reverse { + [("zeta", "two"), ("alpha", "one")] + } else { + [("alpha", "one"), ("zeta", "two")] + }; + for (key, value) in entries { + query.insert(key.to_string(), matcher(value)); + params.insert(key.to_string(), matcher(value)); + persisted.insert( + key.to_string(), + GraphqlOperation { + operation_type: "query".to_string(), + operation_name: value.to_string(), + fields: vec![value.to_string()], + }, + ); + } + NetworkPolicyRule { + name: "mapped".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ports: vec![443], + protocol: "graphql".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "POST".to_string(), + path: "/graphql".to_string(), + query, + params, + operation_type: "query".to_string(), + ..Default::default() + }), + }], + graphql_persisted_queries: persisted, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + } + } + + let left_rule = rule(false); + let right_rule = rule(true); + assert_eq!(left_rule, right_rule); + let left_policy = ProtoSandboxPolicy { + network_policies: HashMap::from([("mapped".to_string(), left_rule.clone())]), + ..Default::default() + }; + let right_policy = ProtoSandboxPolicy { + network_policies: HashMap::from([("mapped".to_string(), right_rule.clone())]), + ..Default::default() + }; + + assert_eq!( + deterministic_policy_hash(&left_policy), + deterministic_policy_hash(&right_policy) + ); + assert_eq!( + compute_proposal_review_token( + "mapped", + &left_rule, + &left_policy, + &left_policy, + &CredentialSet::default(), + ), + compute_proposal_review_token( + "mapped", + &right_rule, + &right_policy, + &right_policy, + &CredentialSet::default(), + ) + ); + assert_eq!( + compute_failed_proposal_evaluation_hash("mapped", &left_rule, &left_policy, "failed",), + compute_failed_proposal_evaluation_hash("mapped", &right_rule, &right_policy, "failed",) + ); + + let mut changed_rule = right_rule; + changed_rule.endpoints[0].rules[0] + .allow + .as_mut() + .unwrap() + .query + .get_mut("alpha") + .unwrap() + .glob = "changed".to_string(); + let changed_policy = ProtoSandboxPolicy { + network_policies: HashMap::from([("mapped".to_string(), changed_rule.clone())]), + ..Default::default() + }; + assert_ne!( + deterministic_policy_hash(&left_policy), + deterministic_policy_hash(&changed_policy), + "map values remain decision-relevant after canonical ordering" + ); + assert_ne!( + compute_proposal_review_token( + "mapped", + &left_rule, + &left_policy, + &left_policy, + &CredentialSet::default(), + ), + compute_proposal_review_token( + "mapped", + &changed_rule, + &changed_policy, + &changed_policy, + &CredentialSet::default(), + ) + ); + } + #[test] fn config_revision_changes_when_policy_source_changes() { let policy = ProtoSandboxPolicy::default(); @@ -12373,6 +16893,7 @@ mod tests { PolicySource::Sandbox, &[], openshell_core::PolicyValidationFailureMode::FailClosed, + false, ); let retain_last_valid = compute_config_revision_with_validation_mode( Some(&policy), @@ -12380,10 +16901,35 @@ mod tests { PolicySource::Sandbox, &[], openshell_core::PolicyValidationFailureMode::RetainLastValid, + false, ); assert_ne!(fail_closed, retain_last_valid); } + #[test] + fn config_revision_changes_when_extension_authentication_capability_changes() { + let policy = ProtoSandboxPolicy::default(); + let settings = HashMap::new(); + + let disabled = compute_config_revision_with_validation_mode( + Some(&policy), + &settings, + PolicySource::Sandbox, + &[], + openshell_core::PolicyValidationFailureMode::FailClosed, + false, + ); + let enabled = compute_config_revision_with_validation_mode( + Some(&policy), + &settings, + PolicySource::Sandbox, + &[], + openshell_core::PolicyValidationFailureMode::FailClosed, + true, + ); + assert_ne!(disabled, enabled); + } + #[test] fn config_revision_changes_when_supervisor_middleware_services_change() { let policy = ProtoSandboxPolicy::default(); @@ -12391,7 +16937,7 @@ mod tests { let service = openshell_core::proto::SupervisorMiddlewareService { name: "local-guard".into(), grpc_endpoint: "http://127.0.0.1:50051".into(), - max_body_bytes: 1024, + max_payload_bytes: 1024, ..Default::default() }; @@ -13012,6 +17558,7 @@ mod tests { assert_eq!(response.version, 1); // Verify the resource_version incremented and policy was backfilled + // without replacing an omitted process identity component. let updated_sandbox = state .store .get_message_by_name::("default", "test-sandbox") @@ -13023,9 +17570,9 @@ mod tests { .as_ref() .and_then(|spec| spec.policy.as_ref()) .and_then(|policy| policy.process.as_ref()) - .expect("legacy process identity should be persisted"); + .expect("partial process identity should be persisted"); assert_eq!(process.run_as_user, "1234"); - assert_eq!(process.run_as_group, "sandbox"); + assert!(process.run_as_group.is_empty()); assert_eq!( updated_sandbox.metadata.as_ref().unwrap().resource_version, current_version + 1, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index b5cf0c258e..764c0bbfde 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5,6 +5,8 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> +#[cfg(test)] +use crate::credentials::RefreshMaterialScope; use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; @@ -14,15 +16,18 @@ use crate::provider_profile_sources::{ }; use openshell_core::metadata::ObjectWorkspace; use openshell_core::proto::{ - Provider, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, - ProviderProfileCredential, Sandbox, + CredentialHandle, Provider, ProviderCredentialRefreshStrategy, + ProviderCredentialTokenGrantAudienceOverride, ProviderCredentialTokenGrantType, + ProviderProfile, ProviderProfileCredential, Sandbox, StaticCredentialBinding, + StaticCredentialEndpointBinding, StoredProviderCredentialRefreshState, }; use openshell_core::telemetry::{ LifecycleOperation, ProviderProfile as TelemetryProviderProfile, TelemetryOutcome, }; use openshell_policy::ProviderPolicyLayer; use prost::Message; -use std::collections::HashMap; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; use tonic::Status; use tracing::warn; @@ -31,6 +36,8 @@ use super::{ MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_PAGE_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, clamp_limit, }; +const GATEWAY_SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET"; + // --------------------------------------------------------------------------- // CRUD helpers // --------------------------------------------------------------------------- @@ -43,6 +50,13 @@ fn redact_provider_credentials(mut provider: Provider) -> Provider { for value in provider.credentials.values_mut() { *value = "REDACTED".to_string(); } + for key in provider.credential_handles.keys() { + provider + .credentials + .entry(key.clone()) + .or_insert_with(|| "REDACTED".to_string()); + } + provider.credential_handles.clear(); provider } @@ -51,6 +65,22 @@ pub(super) struct ProviderEnvironment { pub environment: HashMap, pub credential_expires_at_ms: HashMap, pub dynamic_credentials: HashMap, + pub static_credential_bindings: HashMap, + pub static_credential_keys: HashSet, +} + +/// Immutable provider records used to build one provider-environment response. +/// +/// The persistence metadata is kept alongside the decoded provider so callers +/// can derive both the revision and credential identities from the exact same +/// records used to resolve environment values and dynamic grants. +#[derive(Debug, Clone)] +pub(super) struct ProviderEnvironmentRecord { + pub name: String, + pub object_id: String, + pub resource_version: u64, + pub provider: Provider, + pub refresh_states: Vec, } impl ProviderEnvironment { @@ -82,11 +112,22 @@ pub(super) async fn create_provider_record( create_provider_record_with_catalog(store, &catalog, workspace, provider).await } +#[cfg(test)] pub(super) async fn create_provider_record_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, workspace: &str, + provider: Provider, +) -> Result { + create_provider_record_validating(store, workspace, catalog, provider, None).await +} + +async fn create_provider_record_validating( + store: &Store, + workspace: &str, + catalog: &EffectiveProviderProfileCatalog, mut provider: Provider, + credentials: Option<&crate::credentials::CredentialRuntime>, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -121,13 +162,23 @@ pub(super) async fn create_provider_record_with_catalog( if provider.r#type.trim().is_empty() { return Err(Status::invalid_argument("provider.type is required")); } + if !provider.credential_handles.is_empty() { + return Err(Status::invalid_argument( + "provider.credential_handles is internal gateway state and cannot be supplied", + )); + } + if !provider.profile_workspace.is_empty() && provider.profile_workspace != workspace { return Err(Status::invalid_argument( "profile_workspace must be empty (global) or match the provider workspace", )); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(catalog, &provider.r#type) + && !provider_type_allows_empty_credentials( + catalog, + &provider.r#type, + &provider.profile_workspace, + ) { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -144,6 +195,16 @@ pub(super) async fn create_provider_record_with_catalog( metadata.id.clone_from(&provider_id); } + let credentials_to_store = provider.credentials.clone(); + store_provider_credentials_if_configured( + credentials, + &mut provider, + &credentials_to_store, + &HashMap::new(), + ) + .await?; + validate_provider_fields(&provider)?; + // Create with MustCreate condition to prevent duplicate creation race let labels_map = provider.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { @@ -154,7 +215,7 @@ pub(super) async fn create_provider_record_with_catalog( .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, ) }; - let result = store + let write_result = store .put_if( Provider::object_type(), &provider_id, @@ -164,17 +225,32 @@ pub(super) async fn create_provider_record_with_catalog( labels_json.as_deref(), WriteCondition::MustCreate, ) - .await - .map_err(|e| { + .await; + + let result = match write_result { + Ok(result) => result, + Err(e) => { + if !provider.credential_handles.is_empty() + && let Some(credentials) = credentials + { + let _ = credentials + .delete_provider_credential_handles( + provider.object_name(), + provider.object_workspace(), + provider.object_id(), + &provider.credential_handles, + ) + .await; + } if matches!( e, crate::persistence::PersistenceError::UniqueViolation { .. } ) { - Status::already_exists("provider already exists") - } else { - Status::internal(format!("persist provider failed: {e}")) + return Err(Status::already_exists("provider already exists")); } - })?; + return Err(Status::internal(format!("persist provider failed: {e}"))); + } + }; if let Some(metadata) = provider.metadata.as_mut() { metadata.resource_version = result.resource_version; @@ -234,6 +310,56 @@ pub(super) async fn update_provider_record_with_catalog( catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider: Provider, +) -> Result { + update_provider_record_validating(store, workspace, catalog, provider, None).await +} + +async fn reject_refresh_owned_credential_updates( + store: &Store, + provider_id: &str, + credential_updates: &HashMap, +) -> Result<(), Status> { + if credential_updates.is_empty() { + return Ok(()); + } + + let mut refresh_owned_keys = HashSet::new(); + for refresh_state in + crate::provider_refresh::list_refresh_states_for_provider(store, provider_id).await? + { + let strategy = + ProviderCredentialRefreshStrategy::try_from(refresh_state.strategy).unwrap_or_default(); + if !crate::provider_refresh::is_gateway_mintable_strategy(strategy) { + continue; + } + + for key in std::iter::once(refresh_state.credential_key) + .chain(refresh_state.additional_output_keys.into_values()) + { + if credential_updates.contains_key(&key) { + refresh_owned_keys.insert(key); + } + } + } + + if refresh_owned_keys.is_empty() { + return Ok(()); + } + + let mut refresh_owned_keys: Vec<_> = refresh_owned_keys.into_iter().collect(); + refresh_owned_keys.sort(); + Err(Status::failed_precondition(format!( + "credentials managed by provider refresh cannot be updated or deleted with provider update: {}; use provider refresh rotate, configure, or delete", + refresh_owned_keys.join(", ") + ))) +} + +async fn update_provider_record_validating( + store: &Store, + workspace: &str, + catalog: &EffectiveProviderProfileCatalog, + provider: Provider, + credentials: Option<&crate::credentials::CredentialRuntime>, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -269,6 +395,14 @@ pub(super) async fn update_provider_record_with_catalog( "profile_workspace cannot be changed; delete and recreate the provider", )); } + if !provider.credential_handles.is_empty() { + return Err(Status::invalid_argument( + "provider.credential_handles is internal gateway state and cannot be supplied", + )); + } + + reject_refresh_owned_credential_updates(store, existing.object_id(), &provider.credentials) + .await?; let current_version = existing.metadata.as_ref().map_or(0, |m| m.resource_version); @@ -280,6 +414,14 @@ pub(super) async fn update_provider_record_with_catalog( // Apply merge to create candidate let mut candidate = existing.clone(); + let existing_handles = existing.credential_handles.clone(); + let removed_credential_handles = credential_handles_removed_by_update(&existing, &provider); + let updated_credential_values = provider + .credentials + .iter() + .filter(|(_, value)| !value.is_empty()) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); candidate.credentials = merge_map(candidate.credentials, provider.credentials); candidate.config = merge_map(candidate.config, provider.config); candidate.credential_expires_at_ms = merge_i64_map( @@ -294,50 +436,99 @@ pub(super) async fn update_provider_record_with_catalog( // strand legacy records whose stored type predates current limits. See // #1347. super::validation::validate_object_metadata(candidate.metadata.as_ref(), "provider")?; - validate_provider_mutable_fields(&candidate)?; - validate_provider_update_against_attached_sandboxes_with_catalog( - store, catalog, workspace, &candidate, + let credential_update = prepare_provider_credential_update( + credentials, + candidate.object_name(), + candidate.object_workspace(), + candidate.object_id(), + &removed_credential_handles, + &updated_credential_values, + &existing_handles, ) .await?; + for key in credential_update.pre_stored_handles.keys() { + candidate.credential_handles.remove(key); + candidate.credentials.remove(key); + } + for key in credential_update.deferred_store_values.keys() { + candidate.credentials.remove(key); + } + if credentials.is_some_and(crate::credentials::CredentialRuntime::stores_provider_credentials) { + for key in updated_credential_values.keys() { + candidate.credentials.remove(key); + } + } + for key in removed_credential_handles.keys() { + candidate.credential_handles.remove(key); + } + candidate + .credential_handles + .extend(credential_update.pre_stored_handles.clone()); - // Serialize labels for storage - let labels_map = candidate.object_labels(); - let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { - None - } else { - Some( - serde_json::to_string(&labels_map) - .map_err(|e| Status::internal(format!("serialize labels failed: {e}")))?, + let cas_result = async { + validate_provider_mutable_fields(&candidate)?; + if let Some(profile) = get_provider_type_profile_for_scope( + catalog, + &candidate.r#type, + &candidate.profile_workspace, + ) { + validate_provider_credentials(&profile, &candidate, &updated_credential_values)?; + } + validate_provider_update_against_attached_sandboxes_with_catalog( + store, catalog, workspace, &candidate, ) + .await?; + + let labels_map = candidate.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map) + .map_err(|e| Status::internal(format!("serialize labels failed: {e}")))?, + ) + }; + + store + .put_if( + Provider::object_type(), + candidate.object_id(), + candidate.object_name(), + workspace, + &candidate.encode_to_vec(), + labels_json.as_deref(), + WriteCondition::MatchResourceVersion(cas_version), + ) + .await + .map_err(provider_update_persistence_error_to_status) + } + .await; + + let result = match cas_result { + Ok(result) => result, + Err(err) => { + cleanup_pre_stored_provider_credentials( + credentials, + candidate.object_name(), + candidate.object_workspace(), + candidate.object_id(), + &credential_update.pre_stored_handles, + ) + .await; + return Err(err); + } }; - // Write validated candidate with CAS condition - let result = store - .put_if( - Provider::object_type(), - candidate.object_id(), - candidate.object_name(), - workspace, - &candidate.encode_to_vec(), - labels_json.as_deref(), - WriteCondition::MatchResourceVersion(cas_version), - ) - .await - .map_err(|e| { - if matches!(e, crate::persistence::PersistenceError::Conflict { .. }) { - Status::aborted(format!( - "provider was modified concurrently (current resource_version: {})", - match e { - crate::persistence::PersistenceError::Conflict { - current_resource_version, - } => current_resource_version.unwrap_or(0), - _ => 0, - } - )) - } else { - Status::internal(format!("update provider failed: {e}")) - } - })?; + finish_provider_credential_update( + credentials, + candidate.object_name(), + candidate.object_workspace(), + candidate.object_id(), + credential_update, + &removed_credential_handles, + &existing_handles, + ) + .await?; // Update resource_version from successful write if let Some(metadata) = candidate.metadata.as_mut() { @@ -347,10 +538,24 @@ pub(super) async fn update_provider_record_with_catalog( Ok(redact_provider_credentials(candidate)) } +#[cfg(test)] pub(super) async fn delete_provider_record( store: &Store, workspace: &str, name: &str, +) -> Result { + let credentials = crate::credentials::CredentialRuntime::from_config( + &openshell_core::Config::new(None).with_credential_drivers(["test-static"]), + ) + .map_err(|err| Status::internal(format!("create test credential runtime failed: {err}")))?; + delete_provider_record_with_credentials(store, workspace, &credentials, name).await +} + +pub(super) async fn delete_provider_record_with_credentials( + store: &Store, + workspace: &str, + credentials: &crate::credentials::CredentialRuntime, + name: &str, ) -> Result { if name.is_empty() { return Err(Status::invalid_argument("name is required")); @@ -372,7 +577,20 @@ pub(super) async fn delete_provider_record( ))); } - crate::provider_refresh::delete_refresh_states_for_provider(store, provider.object_id()) + crate::provider_refresh::delete_refresh_states_for_provider_with_credentials( + store, + credentials, + provider.object_id(), + ) + .await?; + + credentials + .delete_provider_credential_handles( + provider.object_name(), + provider.object_workspace(), + provider.object_id(), + &provider.credential_handles, + ) .await?; store @@ -439,6 +657,55 @@ where Ok(out) } +async fn providers_using_profile( + store: &Store, + workspace: &str, + profile_id: &str, +) -> Result, Status> { + let is_platform_scope = workspace.is_empty(); + let mut offset = 0u32; + let mut blocking = Vec::new(); + loop { + let records = if is_platform_scope { + store + .list_by_type(Provider::object_type(), 1000, offset) + .await + } else { + store + .list(Provider::object_type(), workspace, 1000, offset) + .await + } + .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; + if records.is_empty() { + break; + } + offset = offset + .checked_add( + u32::try_from(records.len()) + .map_err(|_| Status::internal("provider page size exceeded u32"))?, + ) + .ok_or_else(|| Status::internal("provider pagination offset overflow"))?; + for record in records { + let provider = Provider::decode(record.payload.as_slice()) + .map_err(|e| Status::internal(format!("decode provider failed: {e}")))?; + if provider.profile_workspace != workspace + || normalize_profile_id(&provider.r#type).as_deref() != Some(profile_id) + { + continue; + } + let label = if is_platform_scope { + format!("{}/{}", provider.object_workspace(), provider.object_name()) + } else { + provider.object_name().to_string() + }; + blocking.push(label); + } + } + blocking.sort(); + blocking.dedup(); + Ok(blocking) +} + async fn sandboxes_using_provider( store: &Store, workspace: &str, @@ -515,6 +782,206 @@ fn merge_i64_map( existing } +fn credential_handles_removed_by_update( + existing: &Provider, + incoming: &Provider, +) -> HashMap { + incoming + .credentials + .iter() + .filter(|(_, value)| value.is_empty()) + .filter_map(|(key, _)| { + existing + .credential_handles + .get(key) + .cloned() + .map(|handle| (key.clone(), handle)) + }) + .collect() +} + +#[derive(Debug, Clone, Default)] +struct ProviderCredentialUpdate { + pre_stored_handles: HashMap, + deferred_store_values: HashMap, + replaced_handles: HashMap, +} + +async fn prepare_provider_credential_update( + credentials: Option<&crate::credentials::CredentialRuntime>, + provider_name: &str, + workspace: &str, + provider_id: &str, + _removed_handles: &HashMap, + updated_values: &HashMap, + existing_handles: &HashMap, +) -> Result { + let Some(credentials) = credentials else { + return Ok(ProviderCredentialUpdate::default()); + }; + if !credentials.stores_provider_credentials() || updated_values.is_empty() { + return Ok(ProviderCredentialUpdate::default()); + } + + let mut update = ProviderCredentialUpdate::default(); + let mut values_requiring_new_handles = HashMap::new(); + for (credential_key, value) in updated_values { + match existing_handles.get(credential_key) { + Some(existing_handle) if credentials.storage_owns_handle(existing_handle) => { + update + .deferred_store_values + .insert(credential_key.clone(), value.clone()); + } + Some(replaced_handle) => { + values_requiring_new_handles.insert(credential_key.clone(), value.clone()); + update + .replaced_handles + .insert(credential_key.clone(), replaced_handle.clone()); + } + None => { + values_requiring_new_handles.insert(credential_key.clone(), value.clone()); + } + } + } + + if !values_requiring_new_handles.is_empty() { + update.pre_stored_handles = credentials + .store_provider_credentials( + provider_name, + workspace, + provider_id, + &values_requiring_new_handles, + &HashMap::new(), + ) + .await?; + } + + Ok(update) +} + +async fn finish_provider_credential_update( + credentials: Option<&crate::credentials::CredentialRuntime>, + provider_name: &str, + workspace: &str, + provider_id: &str, + update: ProviderCredentialUpdate, + removed_handles: &HashMap, + existing_handles: &HashMap, +) -> Result<(), Status> { + let Some(credentials) = credentials else { + return Ok(()); + }; + if !credentials.stores_provider_credentials() { + return Ok(()); + } + + if !update.deferred_store_values.is_empty() { + credentials + .store_provider_credentials( + provider_name, + workspace, + provider_id, + &update.deferred_store_values, + existing_handles, + ) + .await?; + } + + let mut handles_to_delete = removed_handles.clone(); + handles_to_delete.extend(update.replaced_handles); + if !handles_to_delete.is_empty() { + credentials + .delete_provider_credential_handles( + provider_name, + workspace, + provider_id, + &handles_to_delete, + ) + .await?; + } + + Ok(()) +} + +// TODO(credential-drivers): A gateway crash between CAS success and +// finish_provider_credential_update leaves replaced/removed credential handles +// orphaned in the backing store. This best-effort cleanup only covers pre-CAS +// failures. A background reconciliation loop should be added to detect and +// reclaim orphaned handles. +async fn cleanup_pre_stored_provider_credentials( + credentials: Option<&crate::credentials::CredentialRuntime>, + provider_name: &str, + workspace: &str, + provider_id: &str, + handles: &HashMap, +) { + if handles.is_empty() { + return; + } + let Some(credentials) = credentials else { + return; + }; + if let Err(err) = credentials + .delete_provider_credential_handles(provider_name, workspace, provider_id, handles) + .await + { + warn!( + provider_name = %provider_name, + error = %err, + "failed to clean up staged provider credentials after provider update failure" + ); + } +} + +fn provider_update_persistence_error_to_status( + err: crate::persistence::PersistenceError, +) -> Status { + if let crate::persistence::PersistenceError::Conflict { + current_resource_version, + } = err + { + Status::aborted(format!( + "provider was modified concurrently (current resource_version: {})", + current_resource_version.unwrap_or(0) + )) + } else { + Status::internal(format!("update provider failed: {err}")) + } +} + +async fn store_provider_credentials_if_configured( + credentials: Option<&crate::credentials::CredentialRuntime>, + provider: &mut Provider, + values_to_store: &HashMap, + existing_handles: &HashMap, +) -> Result<(), Status> { + let Some(credentials) = credentials else { + return Ok(()); + }; + if !credentials.stores_provider_credentials() || values_to_store.is_empty() { + return Ok(()); + } + + let provider_name = provider.object_name().to_string(); + let workspace = provider.object_workspace().to_string(); + let provider_id = provider.object_id().to_string(); + let stored_handles = credentials + .store_provider_credentials( + &provider_name, + &workspace, + &provider_id, + values_to_store, + existing_handles, + ) + .await?; + + for key in stored_handles.keys() { + provider.credentials.remove(key); + } + provider.credential_handles.extend(stored_handles); + Ok(()) +} + // --------------------------------------------------------------------------- // Provider environment resolution // --------------------------------------------------------------------------- @@ -537,39 +1004,235 @@ pub(super) async fn resolve_provider_environment( resolve_provider_environment_with_catalog(store, &catalog, workspace, provider_names).await } +#[cfg(test)] pub(super) async fn resolve_provider_environment_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], ) -> Result { - if provider_names.is_empty() { - return Ok(ProviderEnvironment::default()); - } - - let mut env = HashMap::new(); - let mut expires = HashMap::new(); - let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at( + let credentials = crate::credentials::CredentialRuntime::from_config( + &openshell_core::Config::new(None).with_credential_drivers(["test-static"]), + ) + .map_err(|err| Status::internal(format!("initialize credential runtime failed: {err}")))?; + resolve_provider_environment_with_credentials( store, catalog, workspace, provider_names, - None, - now_ms, + &credentials, ) - .await?; - let registry = openshell_providers::ProviderRegistry::new(); + .await +} + +#[cfg(test)] +pub(super) async fn resolve_provider_environment_with_credentials( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + workspace: &str, + provider_names: &[String], + credentials: &crate::credentials::CredentialRuntime, +) -> Result { + let records = load_provider_environment_records(store, workspace, provider_names).await?; + resolve_provider_environment_from_records_with_credentials( + store, + catalog, + &records, + credentials, + ) + .await +} +pub(super) async fn load_provider_environment_records( + store: &Store, + workspace: &str, + provider_names: &[String], +) -> Result, Status> { + let mut records = Vec::with_capacity(provider_names.len()); for name in provider_names { - let provider = store - .get_message_by_name::(workspace, name) + let record = store + .get_by_name(Provider::object_type(), workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + let provider = Provider::decode(record.payload.as_slice()) + .map_err(|e| Status::internal(format!("failed to decode provider '{name}': {e}")))?; + let refresh_states = + crate::provider_refresh::list_refresh_states_for_provider(store, &record.id).await?; + records.push(ProviderEnvironmentRecord { + name: name.clone(), + object_id: record.id, + resource_version: record.resource_version, + provider, + refresh_states, + }); + } + Ok(records) +} + +#[cfg(test)] +pub(super) async fn resolve_provider_environment_from_records( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], +) -> Result { + let credentials = crate::credentials::CredentialRuntime::from_config( + &openshell_core::Config::new(None).with_credential_drivers(["test-static"]), + ) + .map_err(|err| Status::internal(format!("initialize credential runtime failed: {err}")))?; + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + store, + catalog, + records, + &HashMap::new(), + &credentials, + None, + ) + .await +} + +#[cfg(test)] +pub(super) async fn resolve_provider_environment_from_records_with_credentials( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], + credentials: &crate::credentials::CredentialRuntime, +) -> Result { + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + store, + catalog, + records, + &HashMap::new(), + credentials, + None, + ) + .await +} + +#[cfg(test)] +pub(super) async fn resolve_provider_environment_from_records_with_policy_bindings( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], + policy_bindings: &HashMap>, +) -> Result { + let credentials = crate::credentials::CredentialRuntime::from_config( + &openshell_core::Config::new(None).with_credential_drivers(["test-static"]), + ) + .map_err(|err| Status::internal(format!("initialize credential runtime failed: {err}")))?; + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + store, + catalog, + records, + policy_bindings, + &credentials, + None, + ) + .await +} + +pub(super) async fn resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], + policy_bindings: &HashMap>, + credentials: &crate::credentials::CredentialRuntime, + sandbox_id: Option<&str>, +) -> Result { + if records.is_empty() { + return Ok(ProviderEnvironment::default()); + } + + let mut env = HashMap::new(); + let mut expires = HashMap::new(); + let mut static_credential_bindings = HashMap::new(); + let mut static_credential_keys = HashSet::new(); + let now_ms = crate::persistence::current_time_ms(); + validate_provider_environment_records_unique_at(store, catalog, records, now_ms).await?; + let registry = openshell_providers::ProviderRegistry::new(); + + for record in records { + let name = &record.name; + let provider = &record.provider; + let mut provider_env = HashMap::new(); + let profile = get_provider_type_profile_for_scope( + catalog, + &provider.r#type, + &provider.profile_workspace, + ); + let accepted_stored_credential_keys = profile.as_ref().map(|profile| { + profile + .credentials + .iter() + .flat_map(|credential| credential.accepted_stored_keys()) + .map(str::to_string) + .collect::>() + }); + let profile_endpoints_are_active = profile + .as_ref() + .is_none_or(|profile| provider_profile_endpoints_are_active(profile, provider)); + let profile_proto = profile.as_ref().map(ProviderTypeProfile::to_proto); + let broker_only_credential_keys = profile_proto + .as_ref() + .map(broker_only_provider_credential_keys) + .unwrap_or_default(); + let profile_endpoints = profile_proto.as_ref().map(|profile| { + if !profile_endpoints_are_active { + return Vec::new(); + } + profile + .endpoints + .iter() + .flat_map(|endpoint| { + endpoint_ports(endpoint.port, &endpoint.ports) + .into_iter() + .map(move |port| StaticCredentialEndpointBinding { + host: endpoint.host.clone(), + port, + path: endpoint.path.clone(), + }) + }) + .collect::>() + }); + let policy_endpoints = policy_bindings.get(name); + let effective_endpoints = match (&profile_endpoints, policy_endpoints) { + (Some(profile_endpoints), Some(_policy_endpoints)) if !profile_endpoints.is_empty() => { + return Err(Status::failed_precondition(format!( + "provider '{name}' profile already defines credential endpoints; \ + remove credential_binding from the sandbox policy endpoint" + ))); + } + (Some(profile_endpoints), Some(policy_endpoints)) => { + debug_assert!(profile_endpoints.is_empty()); + Some(policy_endpoints) + } + (Some(profile_endpoints), None) => Some(profile_endpoints), + (None, Some(_)) => { + return Err(Status::failed_precondition(format!( + "provider '{name}' has no provider profile; policy credential binding \ + requires an endpointless provider profile" + ))); + } + (None, None) => None, + }; + let has_no_usable_endpoint = effective_endpoints.is_some_and(Vec::is_empty); + let refresh_epochs = refresh_authorization_epochs_by_key(record)?; for (key, value) in &provider.credentials { - if is_non_injectable_provider_credential(&provider, key) { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(key)) + { + warn!( + provider_name = %name, + key = %key, + "withholding provider credential not declared by resolved profile" + ); + continue; + } + if is_non_injectable_provider_credential(provider, key) + || broker_only_credential_keys.contains(key) + { warn!( provider_name = %name, key = %key, @@ -578,6 +1241,18 @@ pub(super) async fn resolve_provider_environment_with_catalog( continue; } if is_valid_env_key(key) { + if has_no_usable_endpoint { + // Static credentials need a complete binding. Do not send + // endpointless profile credentials as invalid metadata, + // because one rejected key would revoke every unrelated + // static credential in the supervisor snapshot. + warn!( + provider_name = %name, + key = %key, + "withholding static provider credential from endpointless profile" + ); + continue; + } let expires_at_ms = provider .credential_expires_at_ms .get(key) @@ -595,7 +1270,25 @@ pub(super) async fn resolve_provider_environment_with_catalog( if expires_at_ms > 0 { expires.entry(key.clone()).or_insert(expires_at_ms); } - env.entry(key.clone()).or_insert_with(|| value.clone()); + provider_env.insert(key.clone(), value.clone()); + static_credential_keys.insert(key.clone()); + if let Some(endpoints) = effective_endpoints { + if record.object_id.is_empty() { + return Err(Status::failed_precondition(format!( + "provider '{name}' has no stable object identity" + ))); + } + static_credential_bindings.insert( + key.clone(), + static_credential_binding( + sandbox_id, + record, + key, + endpoints, + refresh_epochs.get(key).map(String::as_str), + ), + ); + } } else { warn!( provider_name = %name, @@ -605,66 +1298,220 @@ pub(super) async fn resolve_provider_environment_with_catalog( } } - registry.inject_env(&provider, &mut env); + let resolved_refs = credentials + .resolve_provider_handles(provider, now_ms) + .await?; + for (key, value) in resolved_refs.values { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(&key)) + { + warn!( + provider_name = %name, + key = %key, + "withholding provider credential handle not declared by resolved profile" + ); + continue; + } + if is_non_injectable_provider_credential(provider, &key) + || broker_only_credential_keys.contains(&key) + { + warn!( + provider_name = %name, + key = %key, + "skipping non-injectable provider credential handle" + ); + continue; + } + if is_valid_env_key(&key) { + if has_no_usable_endpoint { + warn!( + provider_name = %name, + key = %key, + "withholding static provider credential handle from endpointless profile" + ); + continue; + } + if let Some(expires_at_ms) = resolved_refs + .expires_at_ms + .get(&key) + .copied() + .filter(|expires_at_ms| *expires_at_ms > 0) + { + expires.entry(key.clone()).or_insert(expires_at_ms); + } + provider_env.insert(key.clone(), value); + static_credential_keys.insert(key.clone()); + if let Some(endpoints) = effective_endpoints { + if record.object_id.is_empty() { + return Err(Status::failed_precondition(format!( + "provider '{name}' has no stable object identity" + ))); + } + static_credential_bindings.insert( + key.clone(), + static_credential_binding( + sandbox_id, + record, + &key, + endpoints, + refresh_epochs.get(&key).map(String::as_str), + ), + ); + } + } else { + warn!( + provider_name = %name, + key = %key, + "skipping credential handle with invalid env var key" + ); + } + } + + // Build each provider's emitted environment independently so another + // provider's earlier output cannot change how this provider classifies + // 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, value) in provider_env { + env.entry(key).or_insert(value); + } } Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials_with_catalog( - store, - catalog, - workspace, - provider_names, - ) - .await?, + dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records), + static_credential_bindings, + static_credential_keys, }) } -/// Resolve dynamic credentials (token grants) from provider profiles. -/// -/// Returns a map of endpoint-bound keys to credential metadata for credentials -/// that have `token_grant` configuration. Keys are internal supervisor metadata: -/// host, port, endpoint path, and provider credential identity. -pub(super) async fn resolve_dynamic_credentials_with_catalog( - store: &Store, - catalog: &EffectiveProviderProfileCatalog, - workspace: &str, - provider_names: &[String], -) -> Result, Status> { - if provider_names.is_empty() { - return Ok(HashMap::new()); +fn refresh_authorization_epochs_by_key( + record: &ProviderEnvironmentRecord, +) -> Result, Status> { + let mut epochs = HashMap::new(); + for state in &record.refresh_states { + if state + .metadata + .as_ref() + .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + { + continue; + } + if !crate::provider_refresh::is_gateway_mintable_strategy( + ProviderCredentialRefreshStrategy::try_from(state.strategy).unwrap_or_default(), + ) { + continue; + } + let epoch = crate::provider_refresh::effective_authorization_epoch(state)?.to_string(); + for key in + std::iter::once(&state.credential_key).chain(state.additional_output_keys.values()) + { + if let Some(previous) = epochs.insert(key.clone(), epoch.clone()) + && previous != epoch + { + return Err(Status::failed_precondition( + "multiple provider refresh authorizations claim one credential key", + )); + } + } } + Ok(epochs) +} - let mut dynamic_creds = HashMap::new(); +fn static_credential_binding( + sandbox_id: Option<&str>, + record: &ProviderEnvironmentRecord, + key: &str, + endpoints: &[StaticCredentialEndpointBinding], + authorization_epoch: Option<&str>, +) -> StaticCredentialBinding { + let workload_credential_handle = sandbox_id + .zip(authorization_epoch) + .map(|(sandbox_id, epoch)| { + derive_workload_credential_handle(sandbox_id, &record.object_id, key, epoch, endpoints) + }) + .unwrap_or_default(); + let credential_identity = if workload_credential_handle.is_empty() { + format!("{}:{key}", record.object_id) + } else { + format!("refresh:{workload_credential_handle}") + }; + StaticCredentialBinding { + endpoints: endpoints.to_vec(), + credential_identity, + workload_credential_handle, + } +} - for provider_name in provider_names { - let provider = store - .get_message_by_name::(workspace, provider_name) - .await - .map_err(|e| { - Status::internal(format!("failed to fetch provider '{provider_name}': {e}")) - })? - .ok_or_else(|| { - Status::failed_precondition(format!("provider '{provider_name}' not found")) - })?; +fn derive_workload_credential_handle( + sandbox_id: &str, + provider_id: &str, + credential_key: &str, + authorization_epoch: &str, + endpoints: &[StaticCredentialEndpointBinding], +) -> String { + let mut endpoints = endpoints + .iter() + .map(|endpoint| { + let host = endpoint + .host + .trim() + .trim_end_matches('.') + .to_ascii_lowercase(); + let path = match endpoint.path.trim() { + "" | "**" | "/**" => "/**".to_string(), + path => path.to_string(), + }; + (host, endpoint.port, path) + }) + .collect::>(); + endpoints.sort(); + endpoints.dedup(); + + let mut hasher = Sha256::new(); + hash_handle_component(&mut hasher, b"openshell-workload-credential-handle-v1"); + hash_handle_component(&mut hasher, sandbox_id.as_bytes()); + hash_handle_component(&mut hasher, provider_id.as_bytes()); + hash_handle_component(&mut hasher, credential_key.as_bytes()); + hash_handle_component(&mut hasher, authorization_epoch.as_bytes()); + for (host, port, path) in endpoints { + hash_handle_component(&mut hasher, host.as_bytes()); + hash_handle_component(&mut hasher, &port.to_le_bytes()); + hash_handle_component(&mut hasher, path.as_bytes()); + } + hex::encode(hasher.finalize()) +} - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) - else { +fn hash_handle_component(hasher: &mut Sha256, value: &[u8]) { + hasher.update(value.len().to_le_bytes()); + hasher.update(value); +} + +/// Resolve dynamic credentials (token grants) from the same records used for +/// the provider-environment revision and static credential bindings. +fn resolve_dynamic_credentials_from_records( + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], +) -> HashMap { + let mut dynamic_creds = HashMap::new(); + for record in records { + let provider = &record.provider; + let Some(profile) = get_provider_type_profile_for_scope( + catalog, + &provider.r#type, + &provider.profile_workspace, + ) else { continue; }; - insert_dynamic_credentials_for_profile( &mut dynamic_creds, &profile.to_proto(), - provider_name, + &record.name, ); } - - Ok(dynamic_creds) + dynamic_creds } fn insert_dynamic_credentials_for_profile( @@ -928,18 +1775,10 @@ fn path_prefix_pattern(path: &str) -> Option<&str> { } fn endpoint_path_matches(pattern: &str, path: &str) -> bool { - if path_matches_all(pattern) { - return true; - } - if pattern == path { - return true; - } - if let Some(prefix) = path_prefix_pattern(pattern) { - return path == prefix || path.starts_with(&format!("{prefix}/")); - } - glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path)) + openshell_core::endpoint_path::matches(pattern, path) } +#[cfg(test)] pub async fn validate_provider_environment_keys_unique( store: &Store, workspace: &str, @@ -1046,7 +1885,8 @@ async fn validate_provider_environment_keys_unique_at( candidate_provider: Option<&Provider>, now_ms: i64, ) -> Result<(), Status> { - let mut seen = HashMap::::new(); + let mut seen_credentials = HashMap::::new(); + let mut seen_plugin_config = HashMap::::new(); let mut dynamic_bindings = Vec::new(); for name in provider_names { let provider = match candidate_provider { @@ -1060,17 +1900,13 @@ async fn validate_provider_environment_keys_unique_at( })?, }; let provider_name = provider.object_name().to_string(); - for key in active_provider_environment_keys(store, &provider, now_ms).await? { - if let Some(first_provider) = seen.get(&key) { - if first_provider != &provider_name { - return Err(Status::failed_precondition(format!( - "credential env key '{key}' is provided by both provider '{first_provider}' and provider '{provider_name}'; use provider-specific env names" - ))); - } - } else { - seen.insert(key, provider_name.clone()); - } - } + validate_provider_environment_key_ownership( + &mut seen_credentials, + &mut seen_plugin_config, + &provider_name, + active_provider_environment_keys(store, catalog, &provider, now_ms).await?, + provider_plugin_environment_keys(catalog, &provider), + )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( catalog, &provider, )); @@ -1079,6 +1915,121 @@ async fn validate_provider_environment_keys_unique_at( Ok(()) } +async fn validate_provider_environment_records_unique_at( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], + now_ms: i64, +) -> Result<(), Status> { + let mut seen_credentials = HashMap::::new(); + let mut seen_plugin_config = HashMap::::new(); + let mut dynamic_bindings = Vec::new(); + for record in records { + let provider = &record.provider; + validate_provider_environment_key_ownership( + &mut seen_credentials, + &mut seen_plugin_config, + &record.name, + active_provider_environment_keys_for_identity( + store, + catalog, + provider, + &record.object_id, + now_ms, + ) + .await?, + provider_plugin_environment_keys(catalog, provider), + )?; + dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( + catalog, provider, + )); + } + validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; + Ok(()) +} + +fn provider_plugin_environment_keys( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, +) -> Vec { + let mut plugin_environment = HashMap::new(); + let registry = openshell_providers::ProviderRegistry::new(); + inject_provider_plugin_environment(catalog, provider, ®istry, &mut plugin_environment); + plugin_environment.into_keys().collect() +} + +fn inject_provider_plugin_environment( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, + registry: &openshell_providers::ProviderRegistry, + environment: &mut HashMap, +) { + if let Some(profile) = + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) + { + registry.inject_env_for_profile_id(provider, &profile.id, environment); + } else { + // Preserve config projection for legacy records when their profile + // source is temporarily unavailable. + registry.inject_env(provider, environment); + } +} + +fn validate_provider_environment_key_ownership( + seen_credentials: &mut HashMap, + seen_plugin_config: &mut HashMap, + provider_name: &str, + credential_keys: Vec, + plugin_config_keys: Vec, +) -> Result<(), Status> { + for key in credential_keys { + if let Some(first_provider) = seen_credentials.get(&key) { + if first_provider != provider_name { + return Err(Status::failed_precondition(format!( + "credential env key '{key}' is provided by both provider '{first_provider}' and provider '{provider_name}'; use provider-specific env names" + ))); + } + } else { + seen_credentials.insert(key.clone(), provider_name.to_string()); + } + if let Some(config_provider) = seen_plugin_config.get(&key) + && config_provider != provider_name + { + return Err(provider_credential_config_key_collision( + &key, + provider_name, + config_provider, + )); + } + } + + for key in plugin_config_keys { + if let Some(credential_provider) = seen_credentials.get(&key) + && credential_provider != provider_name + { + return Err(provider_credential_config_key_collision( + &key, + credential_provider, + provider_name, + )); + } + seen_plugin_config + .entry(key) + .or_insert_with(|| provider_name.to_string()); + } + Ok(()) +} + +fn provider_credential_config_key_collision( + key: &str, + credential_provider: &str, + config_provider: &str, +) -> Status { + Status::failed_precondition(format!( + "credential env key '{key}' from provider '{credential_provider}' conflicts with provider-generated config from provider '{config_provider}'; use provider-specific env names" + )) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct DynamicTokenGrantBinding { provider_name: String, @@ -1094,9 +2045,8 @@ fn dynamic_token_grant_bindings_for_provider_with_catalog( provider: &Provider, ) -> Vec { let provider_name = provider.object_name().to_string(); - let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let Some(profile) = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) else { return Vec::new(); }; @@ -1234,13 +2184,33 @@ fn validate_dynamic_token_grant_bindings_unambiguous( async fn active_provider_environment_keys( store: &Store, + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, + now_ms: i64, +) -> Result, Status> { + active_provider_environment_keys_for_identity( + store, + catalog, + provider, + provider.object_id(), + now_ms, + ) + .await +} + +async fn active_provider_environment_keys_for_identity( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, + provider_identity: &str, now_ms: i64, ) -> Result, Status> { - let mut keys = active_provider_credential_keys(provider, now_ms); - if !provider.object_id().is_empty() { + let broker_only_credential_keys = + broker_only_provider_credential_keys_for_provider(catalog, provider); + let mut keys = active_provider_credential_keys(provider, now_ms, &broker_only_credential_keys); + if !provider_identity.is_empty() { for state in - crate::provider_refresh::list_refresh_states_for_provider(store, provider.object_id()) + crate::provider_refresh::list_refresh_states_for_provider(store, provider_identity) .await? { // The primary key plus every co-minted output key this refresh owns, @@ -1248,6 +2218,7 @@ async fn active_provider_environment_keys( keys.extend( std::iter::once(state.credential_key) .chain(state.additional_output_keys.into_values()) + .filter(|key| !broker_only_credential_keys.contains(key)) .filter(|key| is_valid_env_key(key)), ); } @@ -1257,22 +2228,70 @@ async fn active_provider_environment_keys( Ok(keys) } -fn active_provider_credential_keys(provider: &Provider, now_ms: i64) -> Vec { - provider +fn active_provider_credential_keys( + provider: &Provider, + now_ms: i64, + broker_only_credential_keys: &HashSet, +) -> Vec { + let mut keys: Vec = provider .credentials .keys() .filter(|key| !is_non_injectable_provider_credential(provider, key)) + .filter(|key| !broker_only_credential_keys.contains(*key)) .filter(|key| is_valid_env_key(key)) - .filter(|key| { - provider - .credential_expires_at_ms - .get(*key) - .is_none_or(|expires_at_ms| *expires_at_ms <= 0 || *expires_at_ms > now_ms) - }) + .filter(|key| provider_credential_not_expired(provider, key, now_ms)) .cloned() + .collect(); + keys.extend( + provider + .credential_handles + .keys() + .filter(|key| !is_non_injectable_provider_credential(provider, key)) + .filter(|key| !broker_only_credential_keys.contains(*key)) + .filter(|key| is_valid_env_key(key)) + .filter(|key| provider_credential_not_expired(provider, key, now_ms)) + .cloned(), + ); + keys +} + +fn broker_only_provider_credential_keys_for_provider( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, +) -> HashSet { + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) + .as_ref() + .map(ProviderTypeProfile::to_proto) + .map(|profile| broker_only_provider_credential_keys(&profile)) + .unwrap_or_default() +} + +fn broker_only_provider_credential_keys(profile: &ProviderProfile) -> HashSet { + profile + .credentials + .iter() + .filter_map(|credential| credential.token_grant.as_ref()) + .filter(|token_grant| { + ProviderCredentialTokenGrantType::try_from(token_grant.grant_type).is_ok_and( + |grant_type| grant_type == ProviderCredentialTokenGrantType::TokenExchange, + ) + }) + .filter_map(|token_grant| token_grant.subject_token.as_ref()) + .filter(|subject_token| subject_token.source.trim() == "provider_credential") + .filter_map(|subject_token| { + let key = subject_token.credential.trim(); + (!key.is_empty()).then(|| key.to_string()) + }) .collect() } +fn provider_credential_not_expired(provider: &Provider, key: &str, now_ms: i64) -> bool { + provider + .credential_expires_at_ms + .get(key) + .is_none_or(|expires_at_ms| *expires_at_ms <= 0 || *expires_at_ms > now_ms) +} + fn is_non_injectable_provider_credential(provider: &Provider, key: &str) -> bool { openshell_core::inference::normalize_inference_provider_type(&provider.r#type) == Some("google-vertex-ai") @@ -1309,25 +2328,114 @@ use openshell_core::proto::{ ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, ImportProviderProfilesRequest, ImportProviderProfilesResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, - ProviderCredentialRefreshStrategy, ProviderProfileDiagnostic, ProviderProfileImportItem, - ProviderProfileResponse, ProviderResponse, RotateProviderCredentialRequest, - RotateProviderCredentialResponse, StoredProviderProfile, UpdateProviderProfilesRequest, - UpdateProviderProfilesResponse, UpdateProviderRequest, + ProviderProfileDiagnostic, ProviderProfileImportItem, ProviderProfileResponse, + ProviderResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, + StoredProviderProfile, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, + UpdateProviderRequest, +}; +use openshell_core::spiffe::{ + JwtSvidParseError, SpiffeJwtClaims, parse_unverified_jwt_svid_claims, + trust_domain as spiffe_trust_domain, workload_api_endpoint, }; use openshell_providers::{ CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, normalize_profile_id, normalize_provider_type, strategy_output_env_key, strategy_output_spec, strategy_primary_env_key, validate_profile_set, }; -use std::sync::Arc; +use std::sync::{Arc, LazyLock, RwLock}; use tonic::{Request, Response}; use crate::auth::principal::Principal; use crate::auth::workspace_authz::{MinWorkspaceRole, authorize_workspace, require_platform_admin}; +use openshell_core::oauth::{ + self, TokenExchangeParams, effective_client_assertion_type, effective_token_type, +}; +const DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 300; +const MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 3600; +const INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; +const MAX_INTERMEDIATE_TOKEN_CACHE_ENTRIES: usize = 1024; + +static TOKEN_EXCHANGE_HTTP_CLIENT: LazyLock> = + LazyLock::new(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(30)) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|err| { + format!("provider token exchange HTTP client configuration failed: {err}") + }) + }); +static INTERMEDIATE_TOKEN_CACHE: LazyLock = + LazyLock::new(IntermediateTokenCache::new); + +fn token_exchange_http_client() -> Result<&'static reqwest::Client, Status> { + TOKEN_EXCHANGE_HTTP_CLIENT + .as_ref() + .map_err(|err| Status::internal(err.clone())) +} + +#[derive(Clone)] +struct CachedIntermediateToken { + access_token: String, + token_type: String, + expires_at_ms: i64, +} + +struct IntermediateTokenCache { + tokens: Arc>>, +} + +impl IntermediateTokenCache { + fn new() -> Self { + Self { + tokens: Arc::new(RwLock::new(HashMap::new())), + } + } + + fn get(&self, key: &str) -> Option { + let now_ms = crate::persistence::current_time_ms(); + let tokens = self.tokens.read().ok()?; + let cached = tokens.get(key)?; + if cached.expires_at_ms <= now_ms { + return None; + } + Some(oauth::OAuthTokenResponse { + access_token: cached.access_token.clone(), + expires_in: cached.expires_at_ms.saturating_sub(now_ms) / 1000, + token_type: cached.token_type.clone(), + }) + } + + fn set(&self, key: String, token: &oauth::OAuthTokenResponse, expires_at_ms: i64) { + if let Ok(mut tokens) = self.tokens.write() { + let now_ms = crate::persistence::current_time_ms(); + tokens.retain(|_, cached| cached.expires_at_ms > now_ms); + if tokens.len() >= MAX_INTERMEDIATE_TOKEN_CACHE_ENTRIES + && let Some(evict_key) = tokens + .iter() + .min_by_key(|(_, cached)| cached.expires_at_ms) + .map(|(k, _)| k.clone()) + { + tokens.remove(&evict_key); + } + tokens.insert( + key, + CachedIntermediateToken { + access_token: token.access_token.clone(), + token_type: token.token_type.clone(), + expires_at_ms, + }, + ); + } + } +} async fn authorize_and_resolve_profile_workspace( state: &Arc, @@ -1382,14 +2490,30 @@ pub(super) async fn handle_create_provider( if let Some(metadata) = provider.metadata.as_mut() { metadata.workspace.clone_from(&workspace); } + if !provider.credential_handles.is_empty() { + return Err(Status::invalid_argument( + "provider.credential_handles is internal gateway state and cannot be supplied", + )); + } let provider_type = provider.r#type.clone(); + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - let result = - create_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, provider) - .await; + let profile = resolve_provider_create_profile(&catalog, &mut provider)?; + validate_provider_create_credentials(&profile, &provider)?; + if state.credentials.stores_provider_credentials() && !provider.credentials.is_empty() { + state.compute.ensure_workspace(&workspace).await?; + } + let result = create_provider_record_validating( + state.store.as_ref(), + &workspace, + &catalog, + provider, + Some(&state.credentials), + ) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1825,11 +2949,11 @@ pub(super) async fn handle_delete_provider_profile( return Err(Status::not_found("provider profile not found")); } - let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &workspace, &id).await?; - if !blocking_sandboxes.is_empty() { + let blocking_providers = providers_using_profile(state.store.as_ref(), &workspace, &id).await?; + if !blocking_providers.is_empty() { return Err(Status::failed_precondition(format!( - "provider profile '{id}' is in use by sandboxes: {}", - blocking_sandboxes.join(", ") + "provider profile '{id}' is in use by providers: {}", + blocking_providers.join(", ") ))); } @@ -1842,13 +2966,6 @@ pub(super) async fn handle_delete_provider_profile( Ok(Response::new(DeleteProviderProfileResponse { deleted })) } -pub(super) fn get_provider_type_profile_with_catalog( - catalog: &EffectiveProviderProfileCatalog, - id: &str, -) -> Option { - catalog.get_type_profile(id) -} - pub(super) fn get_provider_type_profile_for_scope( catalog: &EffectiveProviderProfileCatalog, id: &str, @@ -1857,6 +2974,33 @@ pub(super) fn get_provider_type_profile_for_scope( catalog.get_type_profile_for_scope(id, profile_workspace) } +pub(super) fn provider_profile_endpoints_are_active( + profile: &ProviderTypeProfile, + provider: &Provider, +) -> bool { + if profile.source != "builtin" { + return true; + } + let Some(inference_profile) = openshell_core::inference::profile_for(&profile.id) else { + return true; + }; + if !matches!(inference_profile.provider_type, "openai" | "anthropic") { + return true; + } + + let configured_base_url = inference_profile + .base_url_config_keys + .iter() + .find_map(|key| provider.config.get(*key)) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + configured_base_url.is_none_or(|configured| { + configured + .trim_end_matches('/') + .eq_ignore_ascii_case(inference_profile.default_base_url.trim_end_matches('/')) + }) +} + #[cfg(test)] pub(super) async fn get_provider_type_profile( store: &Store, @@ -2005,13 +3149,112 @@ fn validate_refresh_material( fn provider_type_allows_empty_credentials( catalog: &EffectiveProviderProfileCatalog, provider_type: &str, + profile_workspace: &str, ) -> bool { - let Some(profile) = get_provider_type_profile_with_catalog(catalog, provider_type) else { + let Some(profile) = + get_provider_type_profile_for_scope(catalog, provider_type, profile_workspace) + else { return false; }; profile.allows_empty_provider_credentials() } +fn resolve_provider_create_profile( + catalog: &EffectiveProviderProfileCatalog, + provider: &mut Provider, +) -> Result { + let requested_type = provider.r#type.trim(); + if requested_type.is_empty() { + return Err(Status::invalid_argument("provider.type is required")); + } + let profile = get_provider_type_profile_for_scope( + catalog, + requested_type, + &provider.profile_workspace, + ) + .ok_or_else(|| { + Status::invalid_argument(format!( + "provider profile '{requested_type}' was not found in the requested scope; import a matching profile before creating this provider" + )) + })?; + provider.r#type.clone_from(&profile.id); + Ok(profile) +} + +fn validate_provider_create_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, +) -> Result<(), Status> { + validate_provider_credentials(profile, provider, &HashMap::new()) +} + +fn validate_provider_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, + pending_credentials: &HashMap, +) -> Result<(), Status> { + let declared_keys = profile + .credentials + .iter() + .flat_map(|credential| credential.accepted_stored_keys()) + .collect::>(); + let mut unknown_keys = provider + .credentials + .keys() + .chain(provider.credential_handles.keys()) + .chain(pending_credentials.keys()) + .filter(|key| !declared_keys.contains(key.as_str())) + .cloned() + .collect::>(); + unknown_keys.sort(); + unknown_keys.dedup(); + if !unknown_keys.is_empty() { + return Err(Status::invalid_argument(format!( + "provider credentials are not declared by profile '{}': {}", + profile.id, + unknown_keys.join(", ") + ))); + } + + validate_required_static_credentials(profile, provider, pending_credentials) +} + +fn validate_required_static_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, + pending_credentials: &HashMap, +) -> Result<(), Status> { + let mut missing = Vec::new(); + for credential in profile.required_static_credentials() { + let accepted_keys = credential.accepted_stored_keys(); + let supplied = accepted_keys.iter().any(|key| { + provider + .credentials + .get(*key) + .is_some_and(|value| !value.trim().is_empty()) + || provider.credential_handles.contains_key(*key) + || pending_credentials + .get(*key) + .is_some_and(|value| !value.trim().is_empty()) + }); + if !supplied { + missing.push( + accepted_keys + .first() + .map_or_else(|| credential.name.clone(), |key| (*key).to_string()), + ); + } + } + if !missing.is_empty() { + return Err(Status::invalid_argument(format!( + "provider profile '{}' requires static credentials: {}", + profile.id, + missing.join(", ") + ))); + } + Ok(()) +} + fn normalize_profile_id_request(id: &str) -> Result { if id.trim().is_empty() { return Err(Status::invalid_argument("id is required")); @@ -2041,6 +3284,17 @@ fn profiles_from_import_items( }); continue; }; + for (index, endpoint) in profile.endpoints.iter().enumerate() { + if endpoint.credential_binding.is_some() { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile.id.clone(), + field: format!("endpoints[{index}].credential_binding"), + message: "credential_binding references a concrete sandbox provider and is only valid in sandbox policy".to_string(), + severity: "error".to_string(), + }); + } + } profiles.push((source, ProviderTypeProfile::from_proto(profile))); } (profiles, diagnostics) @@ -2233,6 +3487,7 @@ async fn profile_attached_sandbox_diagnostics( let sandbox_name = sandbox.object_name().to_string(); let sandbox_workspace = sandbox.object_workspace().to_string(); let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); + let base_policy = super::policy::current_base_policy_for_sandbox(store, &sandbox).await?; let mut bindings = Vec::new(); let mut provider_layers = Vec::new(); let mut imported_profiles_used = Vec::<(String, String)>::new(); @@ -2250,8 +3505,15 @@ async fn profile_attached_sandbox_diagnostics( else { continue; }; - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); + let requested_profile_id = normalize_profile_id(&provider.r#type) + .unwrap_or_else(|| provider.r#type.trim().to_string()); + let profile_id = if candidate_profiles.contains_key(&requested_profile_id) { + requested_profile_id + } else { + normalize_provider_type(&provider.r#type) + .filter(|alias| candidate_profiles.contains_key(*alias)) + .map_or(requested_profile_id, str::to_string) + }; let scope_mismatch = (is_platform_scope && !provider.profile_workspace.is_empty()) || (!is_platform_scope && provider.profile_workspace.is_empty()); if scope_mismatch { @@ -2261,7 +3523,7 @@ async fn profile_attached_sandbox_diagnostics( if validate_policy_composition && let Some(profile) = get_provider_type_profile_for_scope( catalog, - profile_id, + &provider.r#type, &provider.profile_workspace, ) { @@ -2273,7 +3535,41 @@ async fn profile_attached_sandbox_diagnostics( } continue; } - if let Some((source, profile)) = candidate_profiles.get(profile_id) { + if let Some((source, profile)) = candidate_profiles.get(&profile_id) { + let has_static_credentials = provider + .credentials + .keys() + .any(|key| !is_non_injectable_provider_credential(&provider, key)); + let has_usable_endpoint = profile.to_proto().endpoints.iter().any(|endpoint| { + !endpoint_ports(endpoint.port, &endpoint.ports).is_empty() + && !endpoint.host.trim().is_empty() + }); + let has_policy_binding = super::policy::policy_has_credential_binding_for_provider( + &base_policy, + provider_name, + ); + if has_static_credentials && !has_usable_endpoint && !has_policy_binding { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile_id.clone(), + field: "endpoints".to_string(), + message: format!( + "{operation} would leave static provider credentials without an authorized endpoint on sandbox '{sandbox_name}'" + ), + severity: "error".to_string(), + }); + } + if has_usable_endpoint && has_policy_binding { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile_id.clone(), + field: "endpoints".to_string(), + message: format!( + "{operation} would give provider '{provider_name}' both profile endpoint bindings and sandbox policy credential bindings on sandbox '{sandbox_name}'" + ), + severity: "error".to_string(), + }); + } bindings.extend(dynamic_token_grant_bindings_for_profile( provider.object_name(), &profile.to_proto(), @@ -2285,7 +3581,7 @@ async fn profile_attached_sandbox_diagnostics( rule_name, }); } - let used = (source.clone(), profile_id.to_string()); + let used = (source.clone(), profile_id.clone()); if !imported_profiles_used.contains(&used) { imported_profiles_used.push(used); } @@ -2296,7 +3592,7 @@ async fn profile_attached_sandbox_diagnostics( if validate_policy_composition && let Some(profile) = get_provider_type_profile_for_scope( catalog, - profile_id, + &provider.r#type, &provider.profile_workspace, ) { @@ -2326,25 +3622,22 @@ async fn profile_attached_sandbox_diagnostics( }); } } - if validate_policy_composition { - let base_policy = - super::policy::current_base_policy_for_sandbox(store, &sandbox).await?; - if let Err(error) = + if validate_policy_composition + && let Err(error) = super::policy::validate_candidate_effective_policy(&base_policy, &provider_layers) - { - for (source, profile_id) in &imported_profiles_used { - diagnostics.push(ProfileValidationDiagnostic { - source: source.clone(), - profile_id: profile_id.clone(), - field: "endpoints".to_string(), - message: format!( - "{operation} would create ambiguous network endpoints on sandbox \ - '{sandbox_name}': {}", - error.message() - ), - severity: "error".to_string(), - }); - } + { + for (source, profile_id) in &imported_profiles_used { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile_id.clone(), + field: "endpoints".to_string(), + message: format!( + "{operation} would create ambiguous network endpoints on sandbox \ + '{sandbox_name}': {}", + error.message() + ), + severity: "error".to_string(), + }); } } } @@ -2395,72 +3688,6 @@ fn has_errors(diagnostics: &[ProfileValidationDiagnostic]) -> bool { .any(|diagnostic| diagnostic.severity == "error") } -async fn sandboxes_using_profile( - store: &Store, - workspace: &str, - profile_id: &str, -) -> Result, Status> { - let is_platform_scope = workspace.is_empty(); - - let candidates = if is_platform_scope { - scan_sandboxes_all(store, |sandbox| { - let has_providers = sandbox - .spec - .as_ref() - .is_some_and(|s| !s.providers.is_empty()); - has_providers.then_some(sandbox) - }) - .await? - } else { - scan_sandboxes(store, workspace, |sandbox| { - let has_providers = sandbox - .spec - .as_ref() - .is_some_and(|s| !s.providers.is_empty()); - has_providers.then_some(sandbox) - }) - .await? - }; - - let mut blocking = Vec::new(); - for sandbox in candidates { - let sandbox_workspace = sandbox.object_workspace().to_string(); - let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); - for provider_name in &spec.providers { - let provider_ws = if is_platform_scope { - &sandbox_workspace - } else { - workspace - }; - let Some(provider) = store - .get_message_by_name::(provider_ws, provider_name) - .await - .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? - else { - continue; - }; - if is_platform_scope && !provider.profile_workspace.is_empty() { - continue; - } - if !is_platform_scope && provider.profile_workspace.is_empty() { - continue; - } - if normalize_profile_id(&provider.r#type).as_deref() == Some(profile_id) { - let label = if is_platform_scope { - format!("{}/{}", sandbox_workspace, sandbox.object_name()) - } else { - sandbox.object_name().to_string() - }; - blocking.push(label); - break; - } - } - } - blocking.sort(); - blocking.dedup(); - Ok(blocking) -} - pub(super) async fn handle_update_provider( state: &Arc, request: Request, @@ -2490,13 +3717,21 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); + if state.credentials.stores_provider_credentials() && !provider.credentials.is_empty() { + state.compute.ensure_workspace(&workspace).await?; + } let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - let result = - update_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, provider) - .await; + let result = update_provider_record_validating( + state.store.as_ref(), + &workspace, + &catalog, + provider, + Some(&state.credentials), + ) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -2519,17 +3754,482 @@ pub(super) async fn handle_update_provider( } } -pub(super) async fn handle_get_provider_refresh_status( +pub(super) async fn handle_exchange_provider_subject_token( state: &Arc, - request: Request, -) -> Result, Status> { - let principal = super::extract_principal(&request)?; - let request = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, - &principal, - &request.workspace, + request: Request, +) -> Result, Status> { + let req = request.get_ref().clone(); + let principal = crate::auth::guard::enforce_sandbox_scope(&request, &req.sandbox_id)?; + crate::auth::guard::ensure_sandbox_principal_scope(&principal, &req.sandbox_id)?; + drop(request); + + if req.provider.trim().is_empty() { + return Err(Status::invalid_argument("provider is required")); + } + if req.credential_key.trim().is_empty() { + return Err(Status::invalid_argument("credential_key is required")); + } + if req.supervisor_jwt_svid.trim().is_empty() { + return Err(Status::invalid_argument("supervisor_jwt_svid is required")); + } + + let sandbox = state + .store + .get_message::(&req.sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); + let spec = sandbox + .spec + .as_ref() + .ok_or_else(|| Status::internal("sandbox has no spec"))?; + if !spec + .providers + .iter() + .any(|provider| provider == &req.provider) + { + return Err(Status::permission_denied( + "provider is not attached to this sandbox", + )); + } + + let provider = state + .store + .get_message_by_name::(&workspace, &req.provider) + .await + .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? + .ok_or_else(|| Status::not_found("provider not found"))?; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let profile = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .ok_or_else(|| Status::failed_precondition("provider profile not found"))?; + let profile_proto = profile.to_proto(); + let credential = profile_proto + .credentials + .iter() + .find(|credential| credential.name == req.credential_key) + .ok_or_else(|| { + Status::failed_precondition("credential not declared by provider profile") + })?; + let token_grant = credential + .token_grant + .as_ref() + .ok_or_else(|| Status::failed_precondition("credential does not declare token_grant"))?; + let grant_type = ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) + .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials); + if grant_type != ProviderCredentialTokenGrantType::TokenExchange { + return Err(Status::failed_precondition( + "credential token_grant is not token_exchange", + )); + } + let subject_token = token_grant + .subject_token + .as_ref() + .ok_or_else(|| Status::failed_precondition("token_exchange subject_token is missing"))?; + if subject_token.source != "provider_credential" { + return Err(Status::failed_precondition( + "unsupported subject_token source", + )); + } + if !profile_proto + .credentials + .iter() + .any(|credential| credential.name == subject_token.credential) + { + return Err(Status::failed_precondition( + "subject token credential not declared by provider profile", + )); + } + let stored_subject_token = + resolve_subject_token_credential(&state.credentials, &provider, &subject_token.credential) + .await?; + + let jwt_svid_audience = + effective_jwt_svid_audience(&token_grant.token_endpoint, &token_grant.jwt_svid_audience); + let gateway_jwt_svid = fetch_gateway_jwt_svid(&jwt_svid_audience).await?; + let gateway_claims = parse_unverified_spiffe_claims(&gateway_jwt_svid)?; + validate_gateway_jwt_svid_claims(&gateway_claims, &jwt_svid_audience)?; + let supervisor_claims = validate_supervisor_jwt_svid( + &req.supervisor_jwt_svid, + &gateway_claims, + &jwt_svid_audience, + ) + .await?; + + let intermediate_cache_key = intermediate_token_cache_key(IntermediateTokenCacheKeyInput { + provider: &provider, + dynamic_credential: &req.credential_key, + subject_credential: &subject_token.credential, + token_endpoint: &token_grant.token_endpoint, + client_assertion_type: effective_client_assertion_type(&token_grant.client_assertion_type), + subject_token_type: effective_token_type(&subject_token.subject_token_type), + audience: &supervisor_claims.sub, + requested_token_type: effective_token_type(&token_grant.requested_token_type), + supervisor_subject: &supervisor_claims.sub, + gateway_subject: &gateway_claims.sub, + }); + if let Some(cached) = INTERMEDIATE_TOKEN_CACHE.get(&intermediate_cache_key) { + return Ok(Response::new(ExchangeProviderSubjectTokenResponse { + access_token: cached.access_token, + expires_in: cached.expires_in, + token_type: cached.token_type, + })); + } + + let token_response = perform_intermediate_token_exchange( + &token_grant.token_endpoint, + &gateway_jwt_svid, + &token_grant.client_assertion_type, + &stored_subject_token, + &subject_token.subject_token_type, + &supervisor_claims.sub, + &token_grant.requested_token_type, + ) + .await + .inspect_err(|status| { + warn!( + sandbox_id = %req.sandbox_id, + provider = %req.provider, + credential_key = %req.credential_key, + subject_credential = %subject_token.credential, + client_assertion_type = %effective_client_assertion_type(&token_grant.client_assertion_type), + gateway_svid_issuer = %gateway_claims.iss, + gateway_svid_subject = %gateway_claims.sub, + gateway_svid_audience = ?gateway_claims.aud, + supervisor_svid_issuer = %supervisor_claims.iss, + supervisor_svid_subject = %supervisor_claims.sub, + supervisor_svid_audience = ?supervisor_claims.aud, + status = ?status.code(), + error = %status.message(), + "intermediate provider token exchange failed" + ); + })?; + let cache_expires_at_ms = intermediate_token_cache_expires_at_ms( + &token_response, + token_grant.cache_ttl_seconds, + provider_credential_expires_at_ms(&provider, &subject_token.credential), + supervisor_claims.exp, + ); + if cache_expires_at_ms > crate::persistence::current_time_ms() { + INTERMEDIATE_TOKEN_CACHE.set(intermediate_cache_key, &token_response, cache_expires_at_ms); + } + + Ok(Response::new(ExchangeProviderSubjectTokenResponse { + access_token: token_response.access_token, + expires_in: token_response.expires_in, + token_type: token_response.token_type, + })) +} + +async fn resolve_subject_token_credential( + credentials: &crate::credentials::CredentialRuntime, + provider: &Provider, + credential_key: &str, +) -> Result { + if let Some(value) = provider + .credentials + .get(credential_key) + .filter(|value| !value.is_empty()) + { + ensure_subject_token_credential_not_expired(provider, credential_key)?; + return Ok(value.clone()); + } + + if provider.credential_handles.contains_key(credential_key) { + let resolved = credentials + .resolve_provider_handles(provider, crate::persistence::current_time_ms()) + .await?; + if let Some(value) = resolved + .values + .get(credential_key) + .filter(|value| !value.is_empty()) + { + return Ok(value.clone()); + } + } + + Err(Status::failed_precondition( + "subject token credential is not configured", + )) +} + +fn ensure_subject_token_credential_not_expired( + provider: &Provider, + credential_key: &str, +) -> Result<(), Status> { + let expires_at_ms = provider_credential_expires_at_ms(provider, credential_key); + if expires_at_ms > 0 && expires_at_ms <= crate::persistence::current_time_ms() { + return Err(Status::failed_precondition( + "subject token credential has expired", + )); + } + Ok(()) +} + +fn provider_credential_expires_at_ms(provider: &Provider, credential_key: &str) -> i64 { + provider + .credential_expires_at_ms + .get(credential_key) + .copied() + .unwrap_or_default() +} + +struct IntermediateTokenCacheKeyInput<'a> { + provider: &'a Provider, + dynamic_credential: &'a str, + subject_credential: &'a str, + token_endpoint: &'a str, + client_assertion_type: &'a str, + subject_token_type: &'a str, + audience: &'a str, + requested_token_type: &'a str, + supervisor_subject: &'a str, + gateway_subject: &'a str, +} + +fn intermediate_token_cache_key(input: IntermediateTokenCacheKeyInput<'_>) -> String { + let provider_id = input + .provider + .metadata + .as_ref() + .map(|metadata| metadata.id.as_str()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| input.provider.object_name()); + let provider_resource_version = input + .provider + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + provider_id, + provider_resource_version, + input.dynamic_credential, + input.subject_credential, + input.token_endpoint, + input.client_assertion_type, + input.subject_token_type, + input.audience, + input.requested_token_type, + input.supervisor_subject, + input.gateway_subject + ) +} + +fn intermediate_token_cache_expires_at_ms( + token: &oauth::OAuthTokenResponse, + cache_ttl_seconds: i64, + subject_token_expires_at_ms: i64, + supervisor_svid_exp_seconds: i64, +) -> i64 { + let now_ms = crate::persistence::current_time_ms(); + let mut ttl_seconds = if token.expires_in > 0 { + token + .expires_in + .min(MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS) + } else { + DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS + }; + if cache_ttl_seconds > 0 { + ttl_seconds = ttl_seconds.min(cache_ttl_seconds); + } + ttl_seconds = ttl_seconds + .saturating_sub(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS) + .max(1); + let mut expires_at_ms = now_ms.saturating_add(ttl_seconds.saturating_mul(1000)); + expires_at_ms = cap_cache_expiry_ms(expires_at_ms, jwt_exp_ms(&token.access_token)); + expires_at_ms = cap_cache_expiry_ms(expires_at_ms, Some(subject_token_expires_at_ms)); + expires_at_ms = cap_cache_expiry_ms( + expires_at_ms, + (supervisor_svid_exp_seconds > 0).then(|| supervisor_svid_exp_seconds.saturating_mul(1000)), + ); + expires_at_ms +} + +fn cap_cache_expiry_ms(current_expires_at_ms: i64, cap_expires_at_ms: Option) -> i64 { + let Some(cap_expires_at_ms) = cap_expires_at_ms.filter(|value| *value > 0) else { + return current_expires_at_ms; + }; + current_expires_at_ms.min( + cap_expires_at_ms + .saturating_sub(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS.saturating_mul(1000)), + ) +} + +fn jwt_exp_ms(token: &str) -> Option { + use base64::Engine as _; + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let claims = serde_json::from_slice::(&decoded).ok()?; + claims + .get("exp")? + .as_i64() + .map(|exp| exp.saturating_mul(1000)) +} + +async fn fetch_gateway_jwt_svid(audience: &str) -> Result { + let socket_path = std::env::var(GATEWAY_SPIFFE_WORKLOAD_API_SOCKET) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + Status::failed_precondition(format!( + "{GATEWAY_SPIFFE_WORKLOAD_API_SOCKET} is required for provider token exchange" + )) + })?; + let endpoint = workload_api_endpoint(std::path::Path::new(&socket_path)); + let client = spiffe::WorkloadApiClient::connect_to(&endpoint) + .await + .map_err(|e| { + Status::failed_precondition(format!("SPIFFE Workload API unavailable: {e}")) + })?; + client + .fetch_jwt_token([audience], None) + .await + .map_err(|e| Status::failed_precondition(format!("failed to fetch gateway JWT-SVID: {e}"))) +} + +fn validate_gateway_jwt_svid_claims( + claims: &SpiffeJwtClaims, + expected_audience: &str, +) -> Result<(), Status> { + if !claims.aud.contains(expected_audience) { + return Err(Status::failed_precondition( + "gateway SVID audience does not match token grant audience", + )); + } + if spiffe_trust_domain(&claims.sub).is_none() { + return Err(Status::failed_precondition( + "gateway SVID subject is not a SPIFFE ID", + )); + } + if claims.exp > 0 && claims.exp.saturating_mul(1000) <= crate::persistence::current_time_ms() { + return Err(Status::failed_precondition("gateway SVID has expired")); + } + Ok(()) +} + +async fn validate_supervisor_jwt_svid( + token: &str, + gateway_claims: &SpiffeJwtClaims, + expected_audience: &str, +) -> Result { + let unverified = parse_unverified_spiffe_claims(token)?; + if unverified.iss != gateway_claims.iss { + return Err(Status::permission_denied( + "supervisor SVID issuer does not match gateway SVID issuer", + )); + } + if !unverified.aud.contains(expected_audience) { + return Err(Status::permission_denied( + "supervisor SVID audience does not match token grant audience", + )); + } + let supervisor_trust_domain = spiffe_trust_domain(&unverified.sub) + .ok_or_else(|| Status::permission_denied("supervisor SVID subject is not a SPIFFE ID"))?; + let gateway_trust_domain = spiffe_trust_domain(&gateway_claims.sub) + .ok_or_else(|| Status::failed_precondition("gateway SVID subject is not a SPIFFE ID"))?; + if supervisor_trust_domain != gateway_trust_domain { + return Err(Status::permission_denied( + "supervisor SVID trust domain does not match gateway SVID trust domain", + )); + } + + let socket_path = std::env::var(GATEWAY_SPIFFE_WORKLOAD_API_SOCKET) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + Status::failed_precondition(format!( + "{GATEWAY_SPIFFE_WORKLOAD_API_SOCKET} is required for supervisor JWT-SVID validation" + )) + })?; + let endpoint = workload_api_endpoint(std::path::Path::new(&socket_path)); + let client = spiffe::WorkloadApiClient::connect_to(&endpoint) + .await + .map_err(|e| { + Status::failed_precondition(format!("SPIFFE Workload API unavailable: {e}")) + })?; + let bundles = client + .fetch_jwt_bundles() + .await + .map_err(|e| Status::internal(format!("SPIFFE JWT bundle fetch failed: {e}")))?; + spiffe::JwtSvid::parse_and_validate(token, &bundles, &[expected_audience]) + .map_err(|e| Status::permission_denied(format!("invalid supervisor JWT-SVID: {e}")))?; + Ok(unverified) +} + +fn parse_unverified_spiffe_claims(token: &str) -> Result { + parse_unverified_jwt_svid_claims(token).map_err(jwt_svid_parse_error_status) +} + +fn jwt_svid_parse_error_status(error: JwtSvidParseError) -> Status { + Status::permission_denied(error.to_string()) +} + +async fn perform_intermediate_token_exchange( + token_endpoint: &str, + gateway_jwt_svid: &str, + client_assertion_type: &str, + subject_token: &str, + subject_token_type: &str, + audience: &str, + requested_token_type: &str, +) -> Result { + let client = token_exchange_http_client()?; + oauth::post_oauth_token_exchange( + client, + token_endpoint, + &TokenExchangeParams { + client_assertion: gateway_jwt_svid, + client_assertion_type, + subject_token, + subject_token_type, + audience, + scopes: &[], + requested_token_type, + }, + ) + .await + .map_err(|e| Status::failed_precondition(e.to_string())) +} + +fn effective_jwt_svid_audience(token_endpoint: &str, jwt_svid_audience: &str) -> String { + if !jwt_svid_audience.trim().is_empty() { + return jwt_svid_audience.to_string(); + } + derive_issuer_from_token_endpoint(token_endpoint) +} + +fn derive_issuer_from_token_endpoint(token_endpoint: &str) -> String { + if let Some(realms_idx) = token_endpoint.find("/realms/") { + let after_realms = &token_endpoint[realms_idx + "/realms/".len()..]; + if let Some(slash_idx) = after_realms.find('/') { + let realm_end = realms_idx + "/realms/".len() + slash_idx; + return token_endpoint[..realm_end].to_string(); + } + } + token_endpoint.to_string() +} + +pub(super) async fn handle_get_provider_refresh_status( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let request = request.into_inner(); + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &request.workspace, MinWorkspaceRole::User, ) .await?; @@ -2613,18 +4313,6 @@ pub(super) async fn handle_configure_provider_refresh( crate::provider_refresh::refresh_strategy_name(strategy as i32) ))); } - if strategy == ProviderCredentialRefreshStrategy::AwsStsAssumeRole { - let global_settings = - crate::grpc::policy::load_global_settings(state.store.as_ref()).await?; - if !crate::grpc::policy::bool_setting_enabled( - &global_settings, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - )? { - return Err(Status::failed_precondition( - "aws_sts_assume_role requires providers_v2_enabled=true", - )); - } - } if request.material.len() > MAX_PROVIDER_CONFIG_ENTRIES { return Err(Status::invalid_argument(format!( "material exceeds maximum entries ({} > {MAX_PROVIDER_CONFIG_ENTRIES})", @@ -2767,6 +4455,32 @@ pub(super) async fn handle_configure_provider_refresh( &additional_output_keys, )?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; + let mut secret_material_keys: HashSet = + request.secret_material_keys.iter().cloned().collect(); + for key in &request.secret_material_keys { + if !request.material.contains_key(key) { + return Err(Status::invalid_argument(format!( + "secret_material_keys entry '{key}' is not present in material" + ))); + } + } + if let Some(refresh) = refresh_defaults.as_ref() { + secret_material_keys.extend( + refresh + .material + .iter() + .filter(|item| item.secret && request.material.contains_key(&item.name)) + .map(|item| item.name.clone()), + ); + } + secret_material_keys.extend( + crate::provider_refresh::strategy_secret_material_keys(strategy) + .iter() + .filter(|key| request.material.contains_key(**key)) + .map(|key| (*key).to_string()), + ); + let mut secret_material_keys: Vec<_> = secret_material_keys.into_iter().collect(); + secret_material_keys.sort(); let material_scopes = crate::provider_refresh::material_scopes(&request.material); let token_url = refresh_defaults .as_ref() @@ -2813,20 +4527,45 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, ) .await?; + if existing_refresh_state.as_ref().is_some_and(|state| { + state + .metadata + .as_ref() + .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + }) { + return Err(Status::failed_precondition( + "provider refresh is being deleted; retry deletion before configuring it again", + )); + } + let existing_refresh_version = existing_refresh_state.as_ref().and_then(|state| { + state + .metadata + .as_ref() + .map(|metadata| metadata.resource_version) + }); let expires_at_ms = request.expires_at_ms.unwrap_or_else(|| { existing_refresh_state .as_ref() .map(|state| state.expires_at_ms) .unwrap_or_default() }); + let mut persisted_material = request.material; + let secret_material: HashMap<_, _> = secret_material_keys + .iter() + .filter_map(|key| { + persisted_material + .remove(key) + .map(|value| (key.clone(), value)) + }) + .collect(); let mut state_record = crate::provider_refresh::new_refresh_state( &provider, &workspace, credential_key, crate::provider_refresh::NewRefreshStateConfig { strategy, - material: request.material, - secret_material_keys: request.secret_material_keys, + material: persisted_material, + secret_material_keys, expires_at_ms, token_url, scopes, @@ -2835,11 +4574,70 @@ pub(super) async fn handle_configure_provider_refresh( additional_output_keys, }, )?; - if let Some(existing) = existing_refresh_state { - state_record.metadata = existing.metadata; + if let Some(existing) = existing_refresh_state.as_ref() { + state_record.metadata.clone_from(&existing.metadata); state_record.last_refresh_at_ms = existing.last_refresh_at_ms; + state_record + .pending_secret_deletions + .extend(existing.pending_secret_deletions.clone()); + for (material_key, handle) in &existing.secret_material_handles { + crate::provider_refresh::enqueue_pending_secret_deletion( + &mut state_record, + material_key, + handle.clone(), + ); + } + } + let material_staging_id = format!( + "{}-refresh-config-{}", + provider.object_id(), + uuid::Uuid::new_v4() + ); + let staged_material_handles = state + .credentials + .store_refresh_material_with_object_id( + crate::provider_refresh::refresh_material_scope(&state_record), + &material_staging_id, + &secret_material, + &HashMap::new(), + ) + .await?; + state_record.secret_material_handles = staged_material_handles.clone(); + let persist_result = if let Some(expected_version) = existing_refresh_version { + match crate::provider_refresh::replace_refresh_state_if_current( + state.store.as_ref(), + &state_record, + expected_version, + ) + .await + { + Ok(true) => Ok(()), + Ok(false) => Err(Status::aborted( + "provider refresh was concurrently modified during configuration", + )), + Err(err) => Err(err), + } + } else { + crate::provider_refresh::create_refresh_state(state.store.as_ref(), &state_record).await + }; + if let Err(err) = persist_result { + if let Err(cleanup_err) = state + .credentials + .delete_refresh_material_handles( + crate::provider_refresh::refresh_material_scope(&state_record), + &staged_material_handles, + ) + .await + { + warn!( + provider_name = %provider.object_name(), + credential_key, + error = %cleanup_err, + "failed to clean up staged refresh material after configuration failure" + ); + } + return Err(err); } - crate::provider_refresh::put_refresh_state(state.store.as_ref(), &state_record).await?; if let Some(expires_at_ms) = request.expires_at_ms { let updated = Provider { @@ -2858,6 +4656,7 @@ pub(super) async fn handle_configure_provider_refresh( config: HashMap::new(), credential_expires_at_ms: HashMap::from([(credential_key.to_string(), expires_at_ms)]), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; update_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, updated) .await?; @@ -2898,6 +4697,8 @@ pub(super) async fn handle_rotate_provider_credential( let refresh_state = crate::provider_refresh::refresh_provider_credential( state.store.as_ref(), &workspace, + &state.credentials, + Some(&state.compute), provider_name, credential_key, ) @@ -2976,8 +4777,9 @@ pub(super) async fn handle_delete_provider_refresh( credential_key, ) .await?; - let deleted_refresh_state = crate::provider_refresh::delete_refresh_state( + let deleted_refresh_state = crate::provider_refresh::delete_refresh_state_with_credentials( state.store.as_ref(), + &state.credentials, &workspace, provider.object_id(), credential_key, @@ -3034,7 +4836,13 @@ pub(super) async fn handle_delete_provider( .name; let name = req.name; let provider_profile = provider_profile_for_name(state.store.as_ref(), &workspace, &name).await; - let result = delete_provider_record(state.store.as_ref(), &workspace, &name).await; + let result = delete_provider_record_with_credentials( + state.store.as_ref(), + &workspace, + &state.credentials, + &name, + ) + .await; match result { Ok(deleted) => { let outcome = TelemetryOutcome::from_success(deleted); @@ -3116,16 +4924,18 @@ mod tests { use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; use openshell_core::proto::{ - ConfigureProviderRefreshRequest, CreateProviderRequest, CreateWorkspaceRequest, - DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, - GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest, - ImportProviderProfilesRequest, L7Allow, L7Rule, LintProviderProfilesRequest, - ListProviderProfilesRequest, ListProvidersRequest, NetworkBinary, NetworkEndpoint, - NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, - ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, - ProviderProfileImportItem, RotateProviderCredentialRequest, Sandbox, SandboxPolicy, - SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, UpdateProviderRequest, + AttachSandboxProviderRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, + CreateWorkspaceRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, + DeleteProviderRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRequest, ImportProviderProfilesRequest, L7Allow, L7Rule, + LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, + ProviderCredentialRefreshMaterial, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantAudienceOverride, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, ProviderProfileImportItem, RotateProviderCredentialRequest, + Sandbox, SandboxPolicy, SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, + UpdateProviderRequest, }; use openshell_core::{ObjectId, ObjectName}; use tonic::{Code, Request}; @@ -3147,6 +4957,40 @@ mod tests { assert!(!is_valid_env_key("X;rm -rf /")); } + #[test] + fn create_validation_accepts_broker_only_credential_by_logical_name() { + let profile = ProviderTypeProfile::from_proto(&ProviderProfile { + id: "token-exchange".to_string(), + credentials: vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }); + let provider = Provider { + credentials: HashMap::from([("subject_token".to_string(), "test-token".to_string())]), + ..Default::default() + }; + + validate_provider_create_credentials(&profile, &provider).unwrap(); + } + #[test] fn telemetry_provider_profile_maps_unknown_to_custom() { assert_eq!( @@ -3163,11 +5007,11 @@ mod tests { ); assert_eq!( telemetry_provider_profile("glab"), - TelemetryProviderProfile::Gitlab + TelemetryProviderProfile::Custom ); assert_eq!( telemetry_provider_profile("outlook"), - TelemetryProviderProfile::Outlook + TelemetryProviderProfile::Custom ); assert_eq!( telemetry_provider_profile("generic"), @@ -3206,13 +5050,16 @@ mod tests { refresh: None, path_template: String::new(), token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, token_endpoint: "http://keycloak.default.svc.cluster.local/realms/openshell/protocol/openid-connect/token".to_string(), audience: "api://default".to_string(), jwt_svid_audience: "http://keycloak.default.svc.cluster.local/realms/openshell" .to_string(), client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".to_string(), + subject_token: None, scopes: vec!["openid".to_string()], + requested_token_type: String::new(), cache_ttl_seconds: 300, audience_overrides: service_audiences .iter() @@ -3319,6 +5166,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -3807,25 +5655,81 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), } } - fn custom_profile(id: &str) -> ProviderProfile { - ProviderProfile { - id: id.to_string(), - resource_version: 0, - annotations: HashMap::new(), - display_name: format!("{id} Profile"), - description: String::new(), - category: ProviderProfileCategory::Other as i32, - credentials: Vec::new(), - endpoints: Vec::new(), - binaries: Vec::new(), - inference_capable: false, - discovery: None, - source: String::new(), - scope: String::new(), - } + fn provider_with_credential_handle( + name: &str, + provider_type: &str, + credential_key: &str, + ) -> Provider { + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + ..Default::default() + }), + r#type: provider_type.to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: std::iter::once(( + credential_key.to_string(), + CredentialHandle { + driver: "test-static".to_string(), + handle: format!("{name}:{credential_key}"), + metadata: HashMap::new(), + }, + )) + .collect(), + } + } + + fn provider_with_credential_value( + name: &str, + provider_type: &str, + credential_key: &str, + value: &str, + ) -> Provider { + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + ..Default::default() + }), + r#type: provider_type.to_string(), + credentials: std::iter::once((credential_key.to_string(), value.to_string())).collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + } + } + + fn custom_profile(id: &str) -> ProviderProfile { + ProviderProfile { + id: id.to_string(), + resource_version: 0, + annotations: HashMap::new(), + display_name: format!("{id} Profile"), + description: String::new(), + category: ProviderProfileCategory::Other as i32, + credentials: Vec::new(), + endpoints: Vec::new(), + binaries: Vec::new(), + inference_capable: false, + discovery: None, + source: String::new(), + scope: String::new(), + } } fn custom_profile_with_invalid_endpoint(id: &str) -> ProviderProfile { @@ -3925,12 +5829,15 @@ mod tests { refresh: None, path_template: String::new(), token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, token_endpoint: "https://auth.example.com/token".to_string(), audience: "api://default".to_string(), jwt_svid_audience: "https://auth.example.com".to_string(), client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" .to_string(), + subject_token: None, scopes: vec!["read".to_string()], + requested_token_type: String::new(), cache_ttl_seconds: 300, audience_overrides: Vec::new(), }), @@ -3960,6 +5867,7 @@ mod tests { assert_eq!( ids, vec![ + "anthropic", "aws", "aws-bedrock", "aws-s3", @@ -3972,6 +5880,7 @@ mod tests { "google-cloud", "google-vertex-ai", "nvidia", + "openai", "pypi" ] ); @@ -4076,20 +5985,6 @@ mod tests { #[tokio::test] async fn profile_update_rejects_fanout_endpoint_ambiguity_without_persisting() { let state = test_server_state().await; - crate::grpc::policy::save_global_settings( - state.store.as_ref(), - &crate::grpc::StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - crate::grpc::StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }, - ) - .await - .unwrap(); let mut initial_profile = custom_profile("fanout-ambiguity"); initial_profile.endpoints.push(NetworkEndpoint { @@ -4344,6 +6239,7 @@ mod tests { .unwrap() .into_inner(); assert!(deleted.deleted); + assert_eq!(state.credentials.stored_credential_count(), Some(0)); } #[tokio::test] @@ -4597,7 +6493,7 @@ mod tests { } #[tokio::test] - async fn delete_provider_profile_rejects_builtin_and_in_use_custom_profiles() { + async fn delete_provider_profile_rejects_builtin_and_provider_referenced_profiles() { let state = test_server_state().await; handle_import_provider_profiles( &state, @@ -4635,7 +6531,7 @@ mod tests { .put_message(&Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-id".to_string(), - name: "sandbox-using-custom".to_string(), + name: "sandbox-custom".to_string(), created_at_ms: 0, labels: HashMap::new(), resource_version: 0, @@ -4644,7 +6540,7 @@ mod tests { deletion_timestamp_ms: 0, }), spec: Some(SandboxSpec { - providers: vec!["custom-provider".to_string()], + providers: Vec::new(), ..Default::default() }), ..Default::default() @@ -4662,7 +6558,57 @@ mod tests { .await .unwrap_err(); assert_eq!(in_use_err.code(), Code::FailedPrecondition); - assert!(in_use_err.message().contains("sandbox-using-custom")); + assert!(in_use_err.message().contains("custom-provider")); + + let attached = super::super::sandbox::handle_attach_sandbox_provider( + &state, + authed_request(AttachSandboxProviderRequest { + sandbox_name: "sandbox-custom".to_string(), + provider_name: "custom-provider".to_string(), + expected_resource_version: 0, + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(attached.attached); + } + + #[tokio::test] + async fn delete_global_provider_profile_checks_providers_in_every_workspace() { + let state = test_server_state().await; + state + .store + .put_message(&stored_provider_profile_for_workspace( + custom_profile("global-custom"), + "", + )) + .await + .unwrap(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let mut provider = provider_with_values("global-provider", "global-custom"); + provider.profile_workspace = String::new(); + create_provider_record_with_catalog(state.store.as_ref(), &catalog, "default", provider) + .await + .unwrap(); + + let err = handle_delete_provider_profile( + &state, + authed_request(DeleteProviderProfileRequest { + id: "global-custom".to_string(), + workspace: String::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("default/global-provider")); } #[tokio::test] @@ -4692,6 +6638,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -4709,7 +6656,10 @@ mod tests { ("client_id".to_string(), "client-id".to_string()), ("client_secret".to_string(), "client-secret".to_string()), ]), - secret_material_keys: vec!["client_secret".to_string()], + // The server derives sensitivity from the authoritative + // profile; direct callers cannot opt a client secret out of + // credential storage by omitting this advisory list. + secret_material_keys: Vec::new(), expires_at_ms: Some(expires_at_ms), workspace: "default".to_string(), }), @@ -4748,6 +6698,71 @@ mod tests { Some(&expires_at_ms) ); + let first_refresh = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .expect("first refresh state"); + assert!(!first_refresh.material.contains_key("client_secret")); + assert!( + first_refresh + .secret_material_handles + .contains_key("client_secret") + ); + assert_eq!( + state + .credentials + .resolve_refresh_material( + RefreshMaterialScope { + provider_name: &first_refresh.provider_name, + workspace: first_refresh.object_workspace(), + provider_id: &first_refresh.provider_id, + credential_key: &first_refresh.credential_key, + }, + &first_refresh.secret_material_handles, + ) + .await + .unwrap() + .get("client_secret"), + Some(&"client-secret".to_string()) + ); + handle_configure_provider_refresh( + &state, + authed_request(ConfigureProviderRefreshRequest { + provider: "msgraph".to_string(), + credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + material: HashMap::from([ + ("tenant_id".to_string(), "tenant".to_string()), + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), "client-secret".to_string()), + ]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: Some(expires_at_ms), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + let second_refresh = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .expect("reconfigured refresh state"); + assert_eq!(first_refresh.object_id(), second_refresh.object_id()); + assert_ne!( + first_refresh.authorization_epoch, second_refresh.authorization_epoch, + "every explicit configuration starts a new authorization epoch" + ); + let deleted = handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { @@ -4760,6 +6775,7 @@ mod tests { .unwrap() .into_inner(); assert!(deleted.deleted); + assert_eq!(state.credentials.stored_credential_count(), Some(0)); let status_after_delete = handle_get_provider_refresh_status( &state, @@ -4787,102 +6803,329 @@ mod tests { ); } - async fn state_with_authoritative_profiles_over_default_grants() -> Arc { + #[tokio::test] + async fn configure_provider_refresh_conflict_cleans_only_staged_material() { let state = test_server_state().await; - let store = state.store.as_ref(); - import_token_grant_profile(&state, "grant-a", "api.example.com", 443, "/v1/**").await; - import_token_grant_profile(&state, "grant-b", "api.example.com", 443, "/v1/**").await; - create_empty_token_grant_provider(store, "provider-a", "grant-a").await; - create_empty_token_grant_provider(store, "provider-b", "grant-b").await; - store - .put_message(&Sandbox { + import_test_graph_refresh_profile(&state).await; + create_provider_record( + state.store.as_ref(), + "default", + Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sandbox-authoritative-profiles-id".to_string(), - name: "sandbox-authoritative-profiles".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), + name: "configure-conflict".to_string(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - spec: Some(SandboxSpec { - providers: vec!["provider-a".to_string(), "provider-b".to_string()], ..Default::default() }), + r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), + profile_workspace: "default".to_string(), ..Default::default() - }) - .await - .unwrap(); - - let mut state = Arc::into_inner(state).expect("test server state should be uniquely owned"); - state.provider_profile_sources = ProviderProfileSources::from_test_profiles(vec![ - custom_profile("grant-a"), - custom_profile("grant-b"), - ]); - Arc::new(state) - } - - #[tokio::test] - async fn configure_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { - let state = state_with_authoritative_profiles_over_default_grants().await; - let expires_at_ms = crate::persistence::current_time_ms() + 60_000; - - handle_configure_provider_refresh( - &state, - authed_request(ConfigureProviderRefreshRequest { - provider: "provider-a".to_string(), - credential_key: "REFRESH_TOKEN".to_string(), - strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, - material: HashMap::new(), - secret_material_keys: Vec::new(), - expires_at_ms: Some(expires_at_ms), - workspace: "default".to_string(), - }), + }, ) .await - .expect("authoritative profiles should govern the provider expiry update"); - + .unwrap(); + let request = |client_secret: &str| ConfigureProviderRefreshRequest { + provider: "configure-conflict".to_string(), + credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + material: HashMap::from([ + ("tenant_id".to_string(), "tenant".to_string()), + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), client_secret.to_string()), + ]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: None, + workspace: "default".to_string(), + }; + handle_configure_provider_refresh(&state, authed_request(request("original-secret"))) + .await + .unwrap(); let provider = state .store - .get_message_by_name::("default", "provider-a") + .get_message_by_name::("default", "configure-conflict") .await .unwrap() - .expect("provider-a"); - assert_eq!( - provider.credential_expires_at_ms.get("REFRESH_TOKEN"), - Some(&expires_at_ms) - ); - } + .unwrap(); + let original = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(state.credentials.stored_credential_count(), Some(1)); + let (store_hit, release_store) = state.credentials.gate_next_store(); - #[tokio::test] - async fn delete_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { - let state = state_with_authoritative_profiles_over_default_grants().await; - let expires_at_ms = crate::persistence::current_time_ms() + 60_000; - let updated = Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "provider-a".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: String::new(), - credentials: HashMap::new(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::from([("REFRESH_TOKEN".to_string(), expires_at_ms)]), - profile_workspace: "default".to_string(), - }; - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref(), "default") + let configure = handle_configure_provider_refresh( + &state, + authed_request(request("replacement-secret")), + ); + let supersede = async { + store_hit.await.unwrap(); + let mut winner = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) .await + .unwrap() .unwrap(); - let provider = - update_provider_record_with_catalog(state.store.as_ref(), &catalog, "default", updated) + winner.last_error = "won-by-concurrent-writer".to_string(); + crate::provider_refresh::put_refresh_state(state.store.as_ref(), &winner) + .await + .unwrap(); + release_store.send(()).unwrap(); + }; + let (result, ()) = tokio::join!(configure, supersede); + + assert_eq!(result.unwrap_err().code(), Code::Aborted); + assert_eq!(state.credentials.stored_credential_count(), Some(1)); + let stored = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.authorization_epoch, original.authorization_epoch); + assert_eq!(stored.last_error, "won-by-concurrent-writer"); + assert!(stored.pending_secret_deletions.is_empty()); + assert_eq!( + state + .credentials + .resolve_refresh_material( + crate::provider_refresh::refresh_material_scope(&stored), + &stored.secret_material_handles, + ) + .await + .unwrap() + .get("client_secret"), + Some(&"original-secret".to_string()) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_first_refresh_configure_keeps_only_winner_material() { + let first_state = test_server_state().await; + import_test_graph_refresh_profile(&first_state).await; + create_provider_record( + first_state.store.as_ref(), + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "configure-create-race".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }, + ) + .await + .unwrap(); + + // Model a second gateway replica: it shares the durable database and + // credential backend, but owns an independent sandbox synchronization + // guard, so process-local serialization cannot hide this race. + let second_state = Arc::new(ServerState::new_with_credentials( + first_state.config.clone(), + Arc::clone(&first_state.store), + crate::compute::new_test_runtime(Arc::clone(&first_state.store)).await, + crate::sandbox_index::SandboxIndex::new(), + crate::sandbox_watch::SandboxWatchBus::new(), + crate::tracing_bus::TracingLogBus::new(), + Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), + None, + first_state.credentials.clone(), + )); + let request = |client_secret: &str| ConfigureProviderRefreshRequest { + provider: "configure-create-race".to_string(), + credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + material: HashMap::from([ + ("tenant_id".to_string(), "tenant".to_string()), + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), client_secret.to_string()), + ]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: None, + workspace: "default".to_string(), + }; + let (first_store_hit, release_first_store) = first_state.credentials.gate_next_store(); + + let first = handle_configure_provider_refresh( + &first_state, + authed_request(request("loser-secret")), + ); + let second = async { + first_store_hit.await.unwrap(); + let result = handle_configure_provider_refresh( + &second_state, + authed_request(request("winner-secret")), + ) + .await; + release_first_store.send(()).unwrap(); + result + }; + let (first_result, second_result) = tokio::join!(first, second); + + assert_eq!(first_result.unwrap_err().code(), Code::Aborted); + second_result.unwrap(); + assert_eq!(first_state.credentials.stored_credential_count(), Some(1)); + + let provider = first_state + .store + .get_message_by_name::("default", "configure-create-race") + .await + .unwrap() + .unwrap(); + let stored = crate::provider_refresh::get_refresh_state( + first_state.store.as_ref(), + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + first_state + .credentials + .resolve_refresh_material( + crate::provider_refresh::refresh_material_scope(&stored), + &stored.secret_material_handles, + ) + .await + .unwrap() + .get("client_secret"), + Some(&"winner-secret".to_string()) + ); + + let physical = first_state + .store + .get_by_name( + StoredProviderCredentialRefreshState::object_type(), + "default", + stored.object_name(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(physical.id, stored.object_id()); + assert_eq!( + crate::provider_refresh::list_refresh_states_for_provider( + first_state.store.as_ref(), + provider.object_id(), + ) + .await + .unwrap() + .len(), + 1 + ); + } + + async fn state_with_authoritative_profiles_over_default_grants() -> Arc { + let state = test_server_state().await; + let store = state.store.as_ref(); + import_token_grant_profile(&state, "grant-a", "api.example.com", 443, "/v1/**").await; + import_token_grant_profile(&state, "grant-b", "api.example.com", 443, "/v1/**").await; + create_empty_token_grant_provider(store, "provider-a", "grant-a").await; + create_empty_token_grant_provider(store, "provider-b", "grant-b").await; + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-authoritative-profiles-id".to_string(), + name: "sandbox-authoritative-profiles".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + spec: Some(SandboxSpec { + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let mut state = Arc::into_inner(state).expect("test server state should be uniquely owned"); + state.provider_profile_sources = ProviderProfileSources::from_test_profiles(vec![ + custom_profile("grant-a"), + custom_profile("grant-b"), + ]); + Arc::new(state) + } + + #[tokio::test] + async fn configure_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { + let state = state_with_authoritative_profiles_over_default_grants().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + + handle_configure_provider_refresh( + &state, + authed_request(ConfigureProviderRefreshRequest { + provider: "provider-a".to_string(), + credential_key: "REFRESH_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms: Some(expires_at_ms), + workspace: "default".to_string(), + }), + ) + .await + .expect("authoritative profiles should govern the provider expiry update"); + + let provider = state + .store + .get_message_by_name::("default", "provider-a") + .await + .unwrap() + .expect("provider-a"); + assert_eq!( + provider.credential_expires_at_ms.get("REFRESH_TOKEN"), + Some(&expires_at_ms) + ); + } + + #[tokio::test] + async fn delete_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { + let state = state_with_authoritative_profiles_over_default_grants().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + let updated = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-a".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::from([("REFRESH_TOKEN".to_string(), expires_at_ms)]), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let provider = + update_provider_record_with_catalog(state.store.as_ref(), &catalog, "default", updated) .await .unwrap(); let refresh_state = crate::provider_refresh::new_refresh_state( @@ -4956,6 +7199,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5025,6 +7269,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5073,6 +7318,7 @@ mod tests { manual_expires_at_ms, )]), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5107,17 +7353,7 @@ mod tests { #[tokio::test] async fn delete_aws_sts_refresh_clears_all_pinned_output_expiries() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -5137,6 +7373,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5187,6 +7424,7 @@ mod tests { ("AWS_SESSION_TOKEN".to_string(), independent_expires_at_ms), ]), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5257,6 +7495,7 @@ mod tests { ("AWS_SESSION_TOKEN".to_string(), concurrently_changed), ]), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; let owned_keys = vec![ "AWS_ACCESS_KEY_ID".to_string(), @@ -5309,6 +7548,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5333,6 +7573,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5411,6 +7652,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5510,6 +7752,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5584,6 +7827,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5699,6 +7943,57 @@ mod tests { assert!(response.deleted); } + #[tokio::test] + async fn create_provider_waits_for_provider_profile_delete_guard() { + let state = test_server_state().await; + handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("guarded-create")), + source: "guarded-create.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let guard = state.compute.sandbox_sync_guard().await; + let task_state = state.clone(); + let task = tokio::spawn(async move { + let mut provider = provider_with_values("guarded-provider", "guarded-create"); + provider.credentials.clear(); + provider.config.clear(); + handle_create_provider( + &task_state, + authed_request(CreateProviderRequest { + provider: Some(provider), + workspace: "default".to_string(), + }), + ) + .await + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!( + !task.is_finished(), + "provider create should wait for provider profile mutation guard" + ); + drop(guard); + + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("create should finish after guard release") + .expect("join create task") + .expect("create should succeed") + .into_inner(); + assert_eq!( + response.provider.expect("provider").object_name(), + "guarded-provider" + ); + } + #[tokio::test] async fn provider_crud_round_trip_and_semantics() { let store = test_store().await; @@ -5752,6 +8047,7 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -5803,248 +8099,908 @@ mod tests { } #[tokio::test] - async fn delete_provider_removes_scoped_refresh_states() { + async fn create_provider_record_stores_credentials_with_runtime() { let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); - let provider = create_provider_record( + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let persisted = create_provider_record_validating( &store, "default", - Provider { - credential_expires_at_ms: HashMap::from([("API_TOKEN".to_string(), 123_456)]), - ..provider_with_values("gitlab-local", "gitlab") - }, + &catalog, + provider_with_credential_value("openai-local", "openai", "OPENAI_API_KEY", "sk-test"), + Some(&credentials), ) .await .unwrap(); - let refresh_state = crate::provider_refresh::new_refresh_state( - &provider, - "default", - "API_TOKEN", - crate::provider_refresh::NewRefreshStateConfig { - additional_output_keys: HashMap::new(), - strategy: ProviderCredentialRefreshStrategy::External, - material: HashMap::from([( - "endpoint".to_string(), - "https://refresh.example.com".to_string(), - )]), - secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: 123_456, - token_url: "https://refresh.example.com/token".to_string(), - scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, - }, - ) - .unwrap(); - crate::provider_refresh::put_refresh_state(&store, &refresh_state) - .await - .unwrap(); - let deleted = delete_provider_record(&store, "default", "gitlab-local") + assert_eq!(persisted.object_name(), "openai-local"); + assert_eq!( + persisted + .credentials + .get("OPENAI_API_KEY") + .map(String::as_str), + Some("REDACTED") + ); + assert!(persisted.credential_handles.is_empty()); + + let stored: Provider = store + .get_message_by_name("default", "openai-local") .await + .unwrap() .unwrap(); - assert!(deleted); + assert!(stored.credentials.is_empty()); + assert_eq!( + stored + .credential_handles + .get("OPENAI_API_KEY") + .map(|handle| handle.driver.as_str()), + Some("test-static") + ); + } - let refresh_states = - crate::provider_refresh::list_refresh_states_for_provider(&store, provider.object_id()) + #[tokio::test] + async fn token_exchange_subject_token_resolves_credential_handle() { + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let mut provider = provider_with_values("exchange-local", "custom"); + provider.credentials.clear(); + provider.metadata.as_mut().expect("provider metadata").id = "provider-id".to_string(); + let handles = credentials + .store_provider_credentials( + provider.object_name(), + provider.object_workspace(), + provider.object_id(), + &HashMap::from([("subject_token".to_string(), "user-token".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + provider.credential_handles = handles; + + let subject_token = + resolve_subject_token_credential(&credentials, &provider, "subject_token") .await .unwrap(); - assert!(refresh_states.is_empty()); + + assert_eq!(subject_token, "user-token"); } #[tokio::test] - async fn delete_provider_rejects_attached_provider() { + async fn update_provider_record_overwrites_credentials_with_runtime() { let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); - create_provider_record( + create_provider_record_validating( &store, "default", - provider_with_values("gitlab-local", "gitlab"), + &catalog, + provider_with_credential_value("openai-local", "openai", "OPENAI_API_KEY", "sk-first"), + Some(&credentials), ) .await .unwrap(); - store - .put_message(&Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sandbox-id".to_string(), - name: "attached-sandbox".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - spec: Some(SandboxSpec { - providers: vec!["gitlab-local".to_string()], - ..Default::default() - }), - ..Default::default() - }) + let stored_first: Provider = store + .get_message_by_name("default", "openai-local") .await + .unwrap() .unwrap(); + let first_handle = stored_first + .credential_handles + .get("OPENAI_API_KEY") + .expect("stored handle") + .handle + .clone(); + + let updated = update_provider_record_validating( + &store, + "default", + &catalog, + provider_with_credential_value("openai-local", "openai", "OPENAI_API_KEY", "sk-second"), + Some(&credentials), + ) + .await + .unwrap(); + assert_eq!( + updated + .credentials + .get("OPENAI_API_KEY") + .map(String::as_str), + Some("REDACTED") + ); + assert!(updated.credential_handles.is_empty()); - let err = delete_provider_record(&store, "default", "gitlab-local") + let stored_second: Provider = store + .get_message_by_name("default", "openai-local") .await - .unwrap_err(); - assert_eq!(err.code(), Code::FailedPrecondition); - assert!( - err.message().contains("attached-sandbox"), - "error should identify blocking sandbox: {}", - err.message() + .unwrap() + .unwrap(); + assert!(stored_second.credentials.is_empty()); + assert_eq!( + stored_second + .credential_handles + .get("OPENAI_API_KEY") + .map(|handle| handle.handle.as_str()), + Some(first_handle.as_str()) ); + + let result = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["openai-local".to_string()], + &credentials, + ) + .await + .unwrap(); + assert_eq!(result.get("OPENAI_API_KEY"), Some(&"sk-second".to_string())); } #[tokio::test] - async fn provider_create_and_update_return_correct_resource_version() { + async fn update_provider_record_with_runtime_preserves_legacy_inline_credentials_on_noop() { let store = test_store().await; - - // Create provider and verify resource_version: 1 in response - let created = provider_with_values("test-provider", "openai"); - let persisted = create_provider_record(&store, "default", created) + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") .await .unwrap(); - assert_eq!( - persisted.metadata.as_ref().unwrap().resource_version, - 1, - "create_provider_record should return resource_version: 1 after insert" - ); - // Update provider and verify resource_version: 2 in response - let updated = update_provider_record( + create_provider_record( + &store, + "default", + provider_with_values("legacy-provider", "legacy-custom"), + ) + .await + .unwrap(); + + let updated = update_provider_record_validating( &store, "default", + &catalog, Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "test-provider".to_string(), - created_at_ms: 1_000_000, + name: "legacy-provider".to_string(), + created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, + ..Default::default() }), - r#type: "openai".to_string(), - credentials: std::iter::once(( - "OPENAI_API_KEY".to_string(), - "updated-key".to_string(), + r#type: String::new(), + credentials: HashMap::new(), + config: std::iter::once(( + "endpoint".to_string(), + "https://updated.example.com".to_string(), )) .collect(), - config: HashMap::new(), credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), }, + Some(&credentials), ) .await .unwrap(); + assert_eq!(updated.credentials.len(), 2); + assert!(updated.credential_handles.is_empty()); + + let stored: Provider = store + .get_message_by_name("default", "legacy-provider") + .await + .unwrap() + .unwrap(); assert_eq!( - updated.metadata.as_ref().unwrap().resource_version, - 2, - "update_provider_record should return resource_version: 2 after first update" + stored.credentials.get("API_TOKEN").map(String::as_str), + Some("token-123") + ); + assert_eq!( + stored.credentials.get("SECONDARY").map(String::as_str), + Some("secondary-token") ); + assert!(stored.credential_handles.is_empty()); + } - // Update again and verify resource_version: 3 - let updated_again = update_provider_record( + #[tokio::test] + async fn update_provider_record_with_runtime_stores_only_updated_legacy_inline_credentials() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + + create_provider_record( + &store, + "default", + provider_with_values("legacy-provider", "legacy-custom"), + ) + .await + .unwrap(); + + update_provider_record_validating( &store, "default", + &catalog, Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "test-provider".to_string(), - created_at_ms: 1_000_000, + name: "legacy-provider".to_string(), + created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, + ..Default::default() }), - r#type: "openai".to_string(), + r#type: String::new(), credentials: std::iter::once(( - "OPENAI_API_KEY".to_string(), - "third-key".to_string(), + "API_TOKEN".to_string(), + "rotated-token".to_string(), )) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), }, + Some(&credentials), ) .await .unwrap(); + + let stored: Provider = store + .get_message_by_name("default", "legacy-provider") + .await + .unwrap() + .unwrap(); + assert!(!stored.credentials.contains_key("API_TOKEN")); assert_eq!( - updated_again.metadata.as_ref().unwrap().resource_version, - 3, - "update_provider_record should return resource_version: 3 after second update" + stored.credentials.get("SECONDARY").map(String::as_str), + Some("secondary-token") + ); + assert_eq!( + stored + .credential_handles + .get("API_TOKEN") + .map(|handle| handle.driver.as_str()), + Some("test-static") + ); + + let result = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["legacy-provider".to_string()], + &credentials, + ) + .await + .unwrap(); + assert_eq!(result.get("API_TOKEN"), Some(&"rotated-token".to_string())); + assert_eq!( + result.get("SECONDARY"), + Some(&"secondary-token".to_string()) ); } #[tokio::test] - async fn provider_validation_errors() { + async fn handle_create_provider_rejects_user_supplied_credential_handles() { let state = test_server_state().await; - let store = state.store.as_ref(); - let create_missing_type = create_provider_record( - store, - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "bad-provider".to_string(), - created_at_ms: 1_000_000, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: String::new(), - credentials: HashMap::new(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, + let err = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_handle( + "openai-ref", + "openai", + "OPENAI_API_KEY", + )), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); - assert_eq!(create_missing_type.code(), Code::InvalidArgument); - let create_missing_credentials = create_provider_record( - store, - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "gitlab-no-creds".to_string(), - created_at_ms: 1_000_000, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "gitlab".to_string(), - credentials: HashMap::new(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("internal gateway state")); + } + + #[tokio::test] + async fn handle_create_provider_rejects_profileless_type() { + let state = test_server_state().await; + let err = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_values("legacy-gitlab", "gitlab")), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); - assert_eq!(create_missing_credentials.code(), Code::InvalidArgument); + assert_eq!(err.code(), Code::InvalidArgument); + assert!( + err.message() + .contains("provider profile 'gitlab' was not found") + ); + } + + #[tokio::test] + async fn handle_create_provider_allows_credentialless_policy_profile() { + let state = test_server_state().await; + let response = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "pypi".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "pypi".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(response.r#type, "pypi"); + assert!(response.credentials.is_empty()); + } + + #[tokio::test] + async fn handle_create_provider_allows_retired_type_with_imported_profile() { + let state = test_server_state().await; handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { - profile: Some(ProviderProfile { - id: "delegated-refresh-api".to_string(), - resource_version: 0, - annotations: HashMap::new(), - display_name: "Delegated Refresh API".to_string(), - description: String::new(), - category: ProviderProfileCategory::Messaging as i32, + profile: Some(custom_profile("gitlab")), + source: "custom-gitlab.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let response = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "private-gitlab".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "gitlab".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(response.r#type, "gitlab"); + } + + #[tokio::test] + async fn handle_create_provider_prefers_exact_imported_alias_profile() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + let mut profile = custom_profile("gh"); + profile.credentials = vec![static_credential("token", "GITHUB_TOKEN", true)]; + profile.endpoints = vec![NetworkEndpoint { + host: "github.enterprise.example".to_string(), + port: 443, + allow_uninspected_credentials: true, + ..Default::default() + }]; + let imported = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "enterprise-github.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "diagnostics: {:?}", imported.diagnostics); + + let provider = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "enterprise-github", + "gh", + "GITHUB_TOKEN", + "test-token", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(provider.r#type, "gh"); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let resolved = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .expect("exact imported profile"); + assert_eq!(resolved.id, "gh"); + assert_eq!(resolved.endpoints[0].host, "github.enterprise.example"); + } + + #[tokio::test] + async fn handle_create_provider_stores_inline_credentials_with_enabled_driver() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + let response = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "openai-local", + "openai", + "OPENAI_API_KEY", + "sk-test", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + + let provider = response.provider.expect("provider"); + assert_eq!( + provider + .credentials + .get("OPENAI_API_KEY") + .map(String::as_str), + Some("REDACTED") + ); + assert!(provider.credential_handles.is_empty()); + + let stored: Provider = state + .store + .get_message_by_name("default", "openai-local") + .await + .unwrap() + .unwrap(); + assert!(stored.credentials.is_empty()); + assert!(stored.credential_handles.contains_key("OPENAI_API_KEY")); + + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let result = resolve_provider_environment_with_credentials( + state.store.as_ref(), + &catalog, + "default", + &["openai-local".to_string()], + &state.credentials, + ) + .await + .unwrap(); + assert_eq!(result.get("OPENAI_API_KEY"), Some(&"sk-test".to_string())); + } + + #[tokio::test] + async fn handle_create_provider_accepts_broker_only_subject_token() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + let mut profile = custom_profile("spiffe-token-exchange-demo"); + profile.credentials = vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + description: "Broker-only subject token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + required: false, + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://issuer.example.com/token".to_string(), + jwt_svid_audience: "https://issuer.example.com".to_string(), + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + .to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ]; + profile.endpoints = vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + allow_uninspected_credentials: true, + ..Default::default() + }]; + let imported = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "provider-profile.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "diagnostics: {:?}", imported.diagnostics); + + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "exchange", + "spiffe-token-exchange-demo", + "subject_token", + "test-token", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let stored: Provider = state + .store + .get_message_by_name("default", "exchange") + .await + .unwrap() + .unwrap(); + assert!(stored.credentials.is_empty()); + assert!(stored.credential_handles.contains_key("subject_token")); + } + + #[tokio::test] + async fn handle_update_provider_rejects_user_supplied_credential_handles() { + let state = test_server_state().await; + create_provider_record( + state.store.as_ref(), + "default", + provider_with_values("openai-local", "openai"), + ) + .await + .unwrap(); + + let err = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(provider_with_credential_handle( + "openai-local", + "openai", + "OPENAI_API_KEY", + )), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("internal gateway state")); + } + + #[tokio::test] + async fn delete_provider_removes_scoped_refresh_states() { + let store = test_store().await; + + let provider = create_provider_record( + &store, + "default", + Provider { + credential_expires_at_ms: HashMap::from([("API_TOKEN".to_string(), 123_456)]), + ..provider_with_values("gitlab-local", "gitlab") + }, + ) + .await + .unwrap(); + let refresh_state = crate::provider_refresh::new_refresh_state( + &provider, + "default", + "API_TOKEN", + crate::provider_refresh::NewRefreshStateConfig { + additional_output_keys: HashMap::new(), + strategy: ProviderCredentialRefreshStrategy::External, + material: HashMap::from([( + "endpoint".to_string(), + "https://refresh.example.com".to_string(), + )]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: 123_456, + token_url: "https://refresh.example.com/token".to_string(), + scopes: Vec::new(), + refresh_before_seconds: 300, + max_lifetime_seconds: 3600, + }, + ) + .unwrap(); + crate::provider_refresh::put_refresh_state(&store, &refresh_state) + .await + .unwrap(); + + let deleted = delete_provider_record(&store, "default", "gitlab-local") + .await + .unwrap(); + assert!(deleted); + + let refresh_states = + crate::provider_refresh::list_refresh_states_for_provider(&store, provider.object_id()) + .await + .unwrap(); + assert!(refresh_states.is_empty()); + } + + #[tokio::test] + async fn delete_provider_rejects_attached_provider() { + let store = test_store().await; + + create_provider_record( + &store, + "default", + provider_with_values("gitlab-local", "gitlab"), + ) + .await + .unwrap(); + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-id".to_string(), + name: "attached-sandbox".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + spec: Some(SandboxSpec { + providers: vec!["gitlab-local".to_string()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let err = delete_provider_record(&store, "default", "gitlab-local") + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("attached-sandbox"), + "error should identify blocking sandbox: {}", + err.message() + ); + } + + #[tokio::test] + async fn provider_create_and_update_return_correct_resource_version() { + let store = test_store().await; + + // Create provider and verify resource_version: 1 in response + let created = provider_with_values("test-provider", "legacy-custom"); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); + assert_eq!( + persisted.metadata.as_ref().unwrap().resource_version, + 1, + "create_provider_record should return resource_version: 1 after insert" + ); + + // Update provider and verify resource_version: 2 in response + let updated = update_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "test-provider".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "legacy-custom".to_string(), + credentials: std::iter::once(( + "OPENAI_API_KEY".to_string(), + "updated-key".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + assert_eq!( + updated.metadata.as_ref().unwrap().resource_version, + 2, + "update_provider_record should return resource_version: 2 after first update" + ); + + // Update again and verify resource_version: 3 + let updated_again = update_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "test-provider".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "legacy-custom".to_string(), + credentials: std::iter::once(( + "OPENAI_API_KEY".to_string(), + "third-key".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + assert_eq!( + updated_again.metadata.as_ref().unwrap().resource_version, + 3, + "update_provider_record should return resource_version: 3 after second update" + ); + } + + #[tokio::test] + async fn provider_validation_errors() { + let state = test_server_state().await; + let store = state.store.as_ref(); + + let create_missing_type = create_provider_record( + store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "bad-provider".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap_err(); + assert_eq!(create_missing_type.code(), Code::InvalidArgument); + + let create_missing_credentials = create_provider_record( + store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "gitlab-no-creds".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "gitlab".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap_err(); + assert_eq!(create_missing_credentials.code(), Code::InvalidArgument); + + handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(ProviderProfile { + id: "delegated-refresh-api".to_string(), + resource_version: 0, + annotations: HashMap::new(), + display_name: "Delegated Refresh API".to_string(), + description: String::new(), + category: ProviderProfileCategory::Messaging as i32, credentials: vec![ProviderProfileCredential { name: "access_token".to_string(), description: String::new(), @@ -6112,6 +9068,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -6154,6 +9111,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -6183,66 +9141,294 @@ mod tests { Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "optional-static-no-token-yet".to_string(), - created_at_ms: 1_000_000, + name: "optional-static-no-token-yet".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "optional-static-api".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + assert!(optional_static_empty.credentials.is_empty()); + + let vertex_empty = create_provider_record( + store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "vertex-no-token-yet".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "google-vertex-ai".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + assert!(vertex_empty.credentials.is_empty()); + + let get_err = get_provider_record(store, "default", "").await.unwrap_err(); + assert_eq!(get_err.code(), Code::InvalidArgument); + + let delete_err = delete_provider_record(store, "default", "") + .await + .unwrap_err(); + assert_eq!(delete_err.code(), Code::InvalidArgument); + + let update_missing_err = update_provider_record( + store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "missing".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap_err(); + assert_eq!(update_missing_err.code(), Code::NotFound); + } + + #[tokio::test] + async fn update_provider_empty_maps_is_noop() { + let store = test_store().await; + + let created = provider_with_values("noop-test", "legacy-custom"); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); + + let updated = update_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "noop-test".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + + assert_eq!(updated.object_id(), persisted.object_id()); + assert_eq!(updated.r#type, "legacy-custom"); + assert_eq!(updated.credentials.len(), 2); + assert_eq!( + updated.credentials.get("API_TOKEN"), + Some(&"REDACTED".to_string()) + ); + assert_eq!(updated.config.len(), 2); + assert_eq!( + updated.config.get("endpoint"), + Some(&"https://example.com".to_string()) + ); + assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); + let stored: Provider = store + .get_message_by_name("default", "noop-test") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.credentials.len(), 2); + } + + #[tokio::test] + async fn update_provider_empty_value_deletes_key() { + let store = test_store().await; + + let created = provider_with_values("delete-key-test", "legacy-custom"); + create_provider_record(&store, "default", created) + .await + .unwrap(); + + let updated = update_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "delete-key-test".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), + config: std::iter::once(("region".to_string(), String::new())).collect(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + + assert_eq!(updated.credentials.len(), 1); + assert_eq!( + updated.credentials.get("API_TOKEN"), + Some(&"REDACTED".to_string()) + ); + assert!(!updated.credentials.contains_key("SECONDARY")); + assert_eq!(updated.config.len(), 1); + assert_eq!( + updated.config.get("endpoint"), + Some(&"https://example.com".to_string()) + ); + assert!(!updated.config.contains_key("region")); + let stored: Provider = store + .get_message_by_name("default", "delete-key-test") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.credentials.len(), 1); + assert_eq!( + stored.credentials.get("API_TOKEN"), + Some(&"token-123".to_string()) + ); + assert!(!stored.credentials.contains_key("SECONDARY")); + } + + #[tokio::test] + async fn update_provider_empty_type_preserves_existing() { + let store = test_store().await; + + let created = provider_with_values("type-preserve-test", "legacy-custom"); + create_provider_record(&store, "default", created) + .await + .unwrap(); + + let updated = update_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "type-preserve-test".to_string(), + created_at_ms: 0, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - r#type: "optional-static-api".to_string(), + r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await .unwrap(); - assert!(optional_static_empty.credentials.is_empty()); - let vertex_empty = create_provider_record( - store, + assert_eq!(updated.r#type, "legacy-custom"); + } + + #[tokio::test] + async fn update_provider_rejects_type_change() { + let store = test_store().await; + + let created = provider_with_values("type-change-test", "nvidia"); + create_provider_record(&store, "default", created) + .await + .unwrap(); + + let err = update_provider_record( + &store, "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "vertex-no-token-yet".to_string(), - created_at_ms: 1_000_000, + name: "type-change-test".to_string(), + created_at_ms: 0, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - r#type: "google-vertex-ai".to_string(), + r#type: "openai".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await - .unwrap(); - assert!(vertex_empty.credentials.is_empty()); + .unwrap_err(); - let get_err = get_provider_record(store, "default", "").await.unwrap_err(); - assert_eq!(get_err.code(), Code::InvalidArgument); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("type cannot be changed")); + } - let delete_err = delete_provider_record(store, "default", "") + #[tokio::test] + async fn update_provider_validates_merged_result() { + let store = test_store().await; + + let created = provider_with_values("validate-merge-test", "gitlab"); + create_provider_record(&store, "default", created) .await - .unwrap_err(); - assert_eq!(delete_err.code(), Code::InvalidArgument); + .unwrap(); - let update_missing_err = update_provider_record( - store, + let oversized_key = "K".repeat(MAX_MAP_KEY_LEN + 1); + let err = update_provider_record( + &store, "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "missing".to_string(), - created_at_ms: 1_000_000, + name: "validate-merge-test".to_string(), + created_at_ms: 0, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), @@ -6250,25 +9436,46 @@ mod tests { deletion_timestamp_ms: 0, }), r#type: String::new(), - credentials: HashMap::new(), + credentials: std::iter::once((oversized_key, "value".to_string())).collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await .unwrap_err(); - assert_eq!(update_missing_err.code(), Code::NotFound); + + assert_eq!(err.code(), Code::InvalidArgument); } + /// Regression for #1347: a credential rotation must not fail just because the + /// existing record's `type` field exceeds the current `MAX_PROVIDER_TYPE_LEN`. + /// The caller never touches `type`; re-validating it strands legacy records. #[tokio::test] - async fn update_provider_empty_maps_is_noop() { + async fn update_provider_allows_credential_rotation_on_legacy_oversized_type() { let store = test_store().await; - let created = provider_with_values("noop-test", "nvidia"); - let persisted = create_provider_record(&store, "default", created) - .await - .unwrap(); + let oversized_type = "x".repeat(MAX_PROVIDER_TYPE_LEN + 15); + let legacy = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: "legacy-oversized-type".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: oversized_type.clone(), + credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }; + store.put_message(&legacy).await.unwrap(); let updated = update_provider_record( &store, @@ -6276,8 +9483,8 @@ mod tests { Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "noop-test".to_string(), - created_at_ms: 1_000_000, + name: "legacy-oversized-type".to_string(), + created_at_ms: 0, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), @@ -6285,307 +9492,821 @@ mod tests { deletion_timestamp_ms: 0, }), r#type: String::new(), - credentials: HashMap::new(), + credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) + .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await .unwrap(); - assert_eq!(updated.object_id(), persisted.object_id()); - assert_eq!(updated.r#type, "nvidia"); - assert_eq!(updated.credentials.len(), 2); - assert_eq!( - updated.credentials.get("API_TOKEN"), - Some(&"REDACTED".to_string()) + assert_eq!(updated.r#type, oversized_type); + } + + #[tokio::test] + async fn resolve_provider_env_empty_list_returns_empty() { + let store = test_store().await; + let result = resolve_provider_environment(&store, "default", &[]) + .await + .unwrap(); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn resolve_provider_env_injects_credentials() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "claude-local".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "claude".to_string(), + credentials: [ + ("ANTHROPIC_API_KEY".to_string(), "sk-abc".to_string()), + ("CLAUDE_API_KEY".to_string(), "sk-abc".to_string()), + ] + .into_iter() + .collect(), + config: std::iter::once(( + "endpoint".to_string(), + "https://api.anthropic.com".to_string(), + )) + .collect(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }; + create_provider_record(&store, "default", provider) + .await + .unwrap(); + + let result = resolve_provider_environment(&store, "default", &["claude-local".to_string()]) + .await + .unwrap(); + assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); + assert_eq!(result.get("CLAUDE_API_KEY"), Some(&"sk-abc".to_string())); + assert!(!result.contains_key("endpoint")); + assert!( + result + .static_credential_bindings + .get("ANTHROPIC_API_KEY") + .is_some_and(|binding| !binding.endpoints.is_empty()) + ); + assert!( + result + .static_credential_bindings + .get("CLAUDE_API_KEY") + .is_some_and(|binding| !binding.endpoints.is_empty()) + ); + } + + #[test] + fn workload_handle_derivation_is_order_independent_and_boundary_bound() { + let endpoints = vec![ + StaticCredentialEndpointBinding { + host: "API.EXAMPLE.COM.".to_string(), + port: 443, + path: "**".to_string(), + }, + StaticCredentialEndpointBinding { + host: "files.example.com".to_string(), + port: 8443, + path: "/v1/**".to_string(), + }, + ]; + let mut reordered = endpoints.clone(); + reordered.reverse(); + let first = derive_workload_credential_handle( + "sandbox-a", + "provider-a", + "ACCESS_TOKEN", + "epoch-a", + &endpoints, ); - assert_eq!(updated.config.len(), 2); assert_eq!( - updated.config.get("endpoint"), - Some(&"https://example.com".to_string()) + first, + derive_workload_credential_handle( + "sandbox-a", + "provider-a", + "ACCESS_TOKEN", + "epoch-a", + &reordered, + ) ); - assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); - let stored: Provider = store - .get_message_by_name("default", "noop-test") + for changed in [ + derive_workload_credential_handle( + "sandbox-b", + "provider-a", + "ACCESS_TOKEN", + "epoch-a", + &endpoints, + ), + derive_workload_credential_handle( + "sandbox-a", + "provider-b", + "ACCESS_TOKEN", + "epoch-a", + &endpoints, + ), + derive_workload_credential_handle( + "sandbox-a", + "provider-a", + "OTHER_TOKEN", + "epoch-a", + &endpoints, + ), + derive_workload_credential_handle( + "sandbox-a", + "provider-a", + "ACCESS_TOKEN", + "epoch-b", + &endpoints, + ), + derive_workload_credential_handle( + "sandbox-a", + "provider-a", + "ACCESS_TOKEN", + "epoch-a", + &[StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 443, + path: "/v2/**".to_string(), + }], + ), + ] { + assert_ne!(first, changed); + } + assert_eq!(first.len(), 64); + assert!( + first + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + ); + } + + #[tokio::test] + async fn refresh_managed_handle_rejects_manual_replacement_survives_rotation_and_reconstruction_then_reconfigure_revokes() + { + let state = test_server_state().await; + let mut profile = custom_profile("stable-refresh-provider"); + profile.credentials = vec![refreshable_credential("access_token", "ACCESS_TOKEN")]; + profile.endpoints = vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/v1/**".to_string(), + protocol: "rest".to_string(), + access: "full".to_string(), + ..Default::default() + }]; + handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "stable-refresh-provider.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + let provider = create_provider_record( + state.store.as_ref(), + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "stable-refresh".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "stable-refresh-provider".to_string(), + credentials: HashMap::from([("ACCESS_TOKEN".to_string(), "token-1".to_string())]), + profile_workspace: "default".to_string(), + ..Default::default() + }, + ) + .await + .unwrap(); + let configure = || ConfigureProviderRefreshRequest { + provider: "stable-refresh".to_string(), + credential_key: "ACCESS_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), "client-secret".to_string()), + ]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: None, + workspace: "default".to_string(), + }; + handle_configure_provider_refresh(&state, authed_request(configure())) + .await + .unwrap(); + + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let names = vec!["stable-refresh".to_string()]; + let records = load_provider_environment_records(state.store.as_ref(), "default", &names) + .await + .unwrap(); + let revision_1 = crate::grpc::policy::compute_provider_env_revision_with_catalog( + state.store.as_ref(), + &catalog, + "default", + &names, + ) + .await + .unwrap(); + let first = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &catalog, + &records, + &HashMap::new(), + &state.credentials, + Some("sandbox-id"), + ) + .await + .unwrap(); + let first_handle = first.static_credential_bindings["ACCESS_TOKEN"] + .workload_credential_handle + .clone(); + assert!(!first_handle.is_empty()); + let credential_state = + openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( + revision_1, + first.environment.clone(), + first.credential_expires_at_ms.clone(), + first.dynamic_credentials.clone(), + first.static_credential_bindings.clone(), + Vec::new(), + ) + .unwrap(); + let original_placeholder = credential_state.snapshot().child_env["ACCESS_TOKEN"].clone(); + + for value in ["token-2", ""] { + let err = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "stable-refresh".to_string(), + ..Default::default() + }), + credentials: HashMap::from([( + "ACCESS_TOKEN".to_string(), + value.to_string(), + )]), + ..Default::default() + }), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("provider refresh")); + assert!(err.message().contains("ACCESS_TOKEN")); + } + + let unchanged = state + .store + .get_message_by_name::("default", "stable-refresh") .await .unwrap() .unwrap(); - assert_eq!(stored.credentials.len(), 2); - } - - #[tokio::test] - async fn update_provider_empty_value_deletes_key() { - let store = test_store().await; - - let created = provider_with_values("delete-key-test", "openai"); - create_provider_record(&store, "default", created) + assert_eq!(unchanged.credentials["ACCESS_TOKEN"], "token-1"); + let unchanged_records = + load_provider_environment_records(state.store.as_ref(), "default", &names) + .await + .unwrap(); + let unchanged_environment = + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &catalog, + &unchanged_records, + &HashMap::new(), + &state.credentials, + Some("sandbox-id"), + ) .await .unwrap(); + credential_state + .install_bound_environment( + revision_1, + unchanged_environment.environment, + unchanged_environment.credential_expires_at_ms, + unchanged_environment.dynamic_credentials, + unchanged_environment.static_credential_bindings, + Vec::new(), + ) + .unwrap(); + assert_eq!( + credential_state + .resolver_for_endpoint("api.example.com", 443, "/v1/messages") + .unwrap() + .resolve_placeholder(&original_placeholder), + Some("token-1") + ); - let updated = update_provider_record( - &store, + // The gateway refresh path owns credential rotation and writes the + // minted value with an internal CAS, preserving the authorization + // epoch and therefore the workload handle. + state + .store + .update_message_cas::(provider.object_id(), 0, |current| { + current + .credentials + .insert("ACCESS_TOKEN".to_string(), "token-2".to_string()); + }) + .await + .unwrap(); + let records = load_provider_environment_records(state.store.as_ref(), "default", &names) + .await + .unwrap(); + let revision_2 = crate::grpc::policy::compute_provider_env_revision_with_catalog( + state.store.as_ref(), + &catalog, "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "delete-key-test".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: String::new(), - credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), - config: std::iter::once(("region".to_string(), String::new())).collect(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, + &names, ) .await .unwrap(); - - assert_eq!(updated.credentials.len(), 1); + let second = + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &catalog, + &records, + &HashMap::new(), + &state.credentials, + Some("sandbox-id"), + ) + .await + .unwrap(); assert_eq!( - updated.credentials.get("API_TOKEN"), - Some(&"REDACTED".to_string()) - ); - assert!(!updated.credentials.contains_key("SECONDARY")); - assert_eq!(updated.config.len(), 1); + second.static_credential_bindings["ACCESS_TOKEN"].workload_credential_handle, + first_handle + ); + credential_state + .install_bound_environment( + revision_2, + second.environment.clone(), + second.credential_expires_at_ms.clone(), + second.dynamic_credentials.clone(), + second.static_credential_bindings.clone(), + Vec::new(), + ) + .unwrap(); assert_eq!( - updated.config.get("endpoint"), - Some(&"https://example.com".to_string()) + credential_state + .resolver_for_endpoint("api.example.com", 443, "/v1/messages") + .unwrap() + .resolve_placeholder(&original_placeholder), + Some("token-2") + ); + let reconstructed = + openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( + revision_2, + second.environment.clone(), + second.credential_expires_at_ms.clone(), + second.dynamic_credentials.clone(), + second.static_credential_bindings.clone(), + Vec::new(), + ) + .unwrap(); + assert_eq!( + reconstructed + .resolver_for_endpoint("api.example.com", 443, "/v1/messages") + .unwrap() + .resolve_placeholder(&original_placeholder), + Some("token-2") ); - assert!(!updated.config.contains_key("region")); - let stored: Provider = store - .get_message_by_name("default", "delete-key-test") + + handle_configure_provider_refresh(&state, authed_request(configure())) .await - .unwrap() .unwrap(); - assert_eq!(stored.credentials.len(), 1); + let revision_3 = crate::grpc::policy::compute_provider_env_revision_with_catalog( + state.store.as_ref(), + &catalog, + "default", + &names, + ) + .await + .unwrap(); + assert_ne!(revision_2, revision_3); + let records = load_provider_environment_records(state.store.as_ref(), "default", &names) + .await + .unwrap(); + let third = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &catalog, + &records, + &HashMap::new(), + &state.credentials, + Some("sandbox-id"), + ) + .await + .unwrap(); + assert_ne!( + third.static_credential_bindings["ACCESS_TOKEN"].workload_credential_handle, + first_handle + ); + credential_state + .install_bound_environment( + revision_3, + third.environment, + third.credential_expires_at_ms, + third.dynamic_credentials, + third.static_credential_bindings, + Vec::new(), + ) + .unwrap(); assert_eq!( - stored.credentials.get("API_TOKEN"), - Some(&"token-123".to_string()) + credential_state + .resolver_for_endpoint("api.example.com", 443, "/v1/messages") + .unwrap() + .resolve_placeholder(&original_placeholder), + None ); - assert!(!stored.credentials.contains_key("SECONDARY")); + assert_eq!(provider.object_name(), "stable-refresh"); } #[tokio::test] - async fn update_provider_empty_type_preserves_existing() { + async fn resolve_provider_env_withholds_endpointless_profile_credentials_independently() { let store = test_store().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + let mut google_cloud = google_cloud_provider(HashMap::from([( + "project_id".to_string(), + "sandbox-project".to_string(), + )])); + google_cloud.credentials = HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )]); + google_cloud.credential_expires_at_ms = + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), expires_at_ms)]); + create_provider_record(&store, "default", google_cloud) + .await + .unwrap(); - let created = provider_with_values("type-preserve-test", "anthropic"); - create_provider_record(&store, "default", created) + let mut github = provider_with_values("bound-github", "github"); + github.credentials = + HashMap::from([("GITHUB_TOKEN".to_string(), "github-token".to_string())]); + github.config.clear(); + create_provider_record(&store, "default", github) .await .unwrap(); - let updated = update_provider_record( + let result = resolve_provider_environment( &store, "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "type-preserve-test".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: String::new(), - credentials: HashMap::new(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, + &["my-google-cloud".to_string(), "bound-github".to_string()], ) .await .unwrap(); - assert_eq!(updated.r#type, "anthropic"); + assert!( + !result.contains_key("GCP_ADC_ACCESS_TOKEN"), + "endpointless static credentials must be withheld" + ); + assert!( + !result + .credential_expires_at_ms + .contains_key("GCP_ADC_ACCESS_TOKEN"), + "withheld static credentials must not retain expiry metadata" + ); + assert!( + !result + .static_credential_keys + .contains("GCP_ADC_ACCESS_TOKEN"), + "withheld static credentials must not be classified as static" + ); + assert!( + !result + .static_credential_bindings + .contains_key("GCP_ADC_ACCESS_TOKEN"), + "endpointless static credentials must not emit an invalid binding" + ); + assert!( + result.contains_key("GCE_METADATA_HOST"), + "provider-generated non-secret GCP configuration must be retained" + ); + + assert_eq!( + result.get("GITHUB_TOKEN"), + Some(&"github-token".to_string()), + "a valid provider must not be suppressed by an endpointless provider" + ); + assert!( + result.static_credential_keys.contains("GITHUB_TOKEN"), + "the valid credential must retain its static classification" + ); + assert!( + result + .static_credential_bindings + .get("GITHUB_TOKEN") + .is_some_and(|binding| !binding.endpoints.is_empty()), + "the valid credential must retain its endpoint binding" + ); } #[tokio::test] - async fn update_provider_rejects_type_change() { + async fn resolve_provider_env_withholds_undeclared_legacy_credentials() { let store = test_store().await; - - let created = provider_with_values("type-change-test", "nvidia"); - create_provider_record(&store, "default", created) + let mut legacy = provider_with_values("legacy-openai", "openai"); + legacy.credentials = HashMap::from([ + ("OPENAI_API_KEY".to_string(), "openai-key".to_string()), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + "unrelated-secret".to_string(), + ), + ]); + create_provider_record(&store, "default", legacy) .await .unwrap(); - let err = update_provider_record( - &store, - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "type-change-test".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "openai".to_string(), - credentials: HashMap::new(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, - ) - .await - .unwrap_err(); + let result = + resolve_provider_environment(&store, "default", &["legacy-openai".to_string()]) + .await + .unwrap(); - assert_eq!(err.code(), Code::InvalidArgument); - assert!(err.message().contains("type cannot be changed")); + assert_eq!( + result.get("OPENAI_API_KEY"), + Some(&"openai-key".to_string()) + ); + assert!( + result + .static_credential_bindings + .contains_key("OPENAI_API_KEY") + ); + assert!(!result.contains_key("AWS_SECRET_ACCESS_KEY")); + assert!( + !result + .static_credential_bindings + .contains_key("AWS_SECRET_ACCESS_KEY") + ); } #[tokio::test] - async fn update_provider_validates_merged_result() { + async fn resolve_provider_env_withholds_undeclared_legacy_credential_handles() { let store = test_store().await; - - let created = provider_with_values("validate-merge-test", "gitlab"); - create_provider_record(&store, "default", created) + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") .await .unwrap(); + create_provider_record_validating( + &store, + "default", + &catalog, + provider_with_credential_value( + "legacy-openai-handle", + "openai", + "AWS_SECRET_ACCESS_KEY", + "unrelated-secret", + ), + Some(&credentials), + ) + .await + .unwrap(); - let oversized_key = "K".repeat(MAX_MAP_KEY_LEN + 1); - let err = update_provider_record( + let records = load_provider_environment_records( &store, "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "validate-merge-test".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: String::new(), - credentials: std::iter::once((oversized_key, "value".to_string())).collect(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, + &["legacy-openai-handle".to_string()], ) .await - .unwrap_err(); + .unwrap(); + assert!(records[0].provider.credentials.is_empty()); + assert!( + records[0] + .provider + .credential_handles + .contains_key("AWS_SECRET_ACCESS_KEY") + ); - assert_eq!(err.code(), Code::InvalidArgument); + let result = + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + &store, + &catalog, + &records, + &HashMap::new(), + &credentials, + None, + ) + .await + .unwrap(); + assert!(!result.contains_key("AWS_SECRET_ACCESS_KEY")); + assert!( + !result + .static_credential_bindings + .contains_key("AWS_SECRET_ACCESS_KEY") + ); } - /// Regression for #1347: a credential rotation must not fail just because the - /// existing record's `type` field exceeds the current `MAX_PROVIDER_TYPE_LEN`. - /// The caller never touches `type`; re-validating it strands legacy records. #[tokio::test] - async fn update_provider_allows_credential_rotation_on_legacy_oversized_type() { + async fn resolve_provider_env_does_not_bind_alternate_upstream_keys_to_public_vendors() { let store = test_store().await; + let cases = [ + ( + "alternate-openai", + "openai", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + ), + ( + "alternate-anthropic", + "anthropic", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + ), + ]; + for (name, provider_type, credential_key, base_url_key) in cases { + let mut provider = + provider_with_credential_value(name, provider_type, credential_key, "private-key"); + provider.config.insert( + base_url_key.to_string(), + "https://api.example.com/v1".to_string(), + ); + create_provider_record(&store, "default", provider) + .await + .unwrap(); + } - let oversized_type = "x".repeat(MAX_PROVIDER_TYPE_LEN + 15); - let legacy = Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: uuid::Uuid::new_v4().to_string(), - name: "legacy-oversized-type".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: oversized_type.clone(), - credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }; - store.put_message(&legacy).await.unwrap(); - - let updated = update_provider_record( + let result = resolve_provider_environment( &store, "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "legacy-oversized-type".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: String::new(), - credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) - .collect(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, + &[ + "alternate-openai".to_string(), + "alternate-anthropic".to_string(), + ], ) .await .unwrap(); - assert_eq!(updated.r#type, oversized_type); + for credential_key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] { + assert!(!result.contains_key(credential_key)); + assert!( + !result + .static_credential_bindings + .contains_key(credential_key) + ); + } } #[tokio::test] - async fn resolve_provider_env_empty_list_returns_empty() { + async fn resolve_provider_env_binds_endpointless_profile_from_policy() { let store = test_store().await; - let result = resolve_provider_environment(&store, "default", &[]) + let mut google_cloud = google_cloud_provider(HashMap::from([( + "project_id".to_string(), + "sandbox-project".to_string(), + )])); + google_cloud.credentials = HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )]); + create_provider_record(&store, "default", google_cloud) .await .unwrap(); - assert!(result.is_empty()); + + let provider_names = ["my-google-cloud".to_string()]; + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let records = load_provider_environment_records(&store, "default", &provider_names) + .await + .unwrap(); + let policy_bindings = HashMap::from([( + "my-google-cloud".to_string(), + vec![StaticCredentialEndpointBinding { + host: "storage.googleapis.com".to_string(), + port: 443, + path: "/**".to_string(), + }], + )]); + + let result = resolve_provider_environment_from_records_with_policy_bindings( + &store, + &catalog, + &records, + &policy_bindings, + ) + .await + .unwrap(); + + assert_eq!( + result.get("GCP_ADC_ACCESS_TOKEN"), + Some(&"google-token".to_string()) + ); + assert_eq!( + result + .static_credential_bindings + .get("GCP_ADC_ACCESS_TOKEN") + .map(|binding| binding.endpoints.as_slice()), + Some(policy_bindings["my-google-cloud"].as_slice()) + ); } #[tokio::test] - async fn resolve_provider_env_injects_credentials() { + async fn resolve_provider_env_binds_endpointless_credential_handle_only_from_policy() { let store = test_store().await; - let provider = Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "claude-local".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "claude".to_string(), - credentials: [ - ("ANTHROPIC_API_KEY".to_string(), "sk-abc".to_string()), - ("CLAUDE_API_KEY".to_string(), "sk-abc".to_string()), - ] - .into_iter() - .collect(), - config: std::iter::once(( - "endpoint".to_string(), - "https://api.anthropic.com".to_string(), - )) - .collect(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }; - create_provider_record(&store, "default", provider) + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let mut google_cloud = google_cloud_provider(HashMap::from([( + "project_id".to_string(), + "sandbox-project".to_string(), + )])); + google_cloud.credentials = HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )]); + create_provider_record_validating( + &store, + "default", + &catalog, + google_cloud, + Some(&credentials), + ) + .await + .unwrap(); + + let provider_names = ["my-google-cloud".to_string()]; + let records = load_provider_environment_records(&store, "default", &provider_names) .await .unwrap(); + assert!(records[0].provider.credentials.is_empty()); + assert!( + records[0] + .provider + .credential_handles + .contains_key("GCP_ADC_ACCESS_TOKEN") + ); - let result = resolve_provider_environment(&store, "default", &["claude-local".to_string()]) + let unbound = + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + &store, + &catalog, + &records, + &HashMap::new(), + &credentials, + None, + ) .await .unwrap(); - assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); - assert_eq!(result.get("CLAUDE_API_KEY"), Some(&"sk-abc".to_string())); - assert!(!result.contains_key("endpoint")); + assert!(!unbound.contains_key("GCP_ADC_ACCESS_TOKEN")); + assert!( + !unbound + .static_credential_bindings + .contains_key("GCP_ADC_ACCESS_TOKEN") + ); + + let policy_bindings = HashMap::from([( + "my-google-cloud".to_string(), + vec![StaticCredentialEndpointBinding { + host: "storage.googleapis.com".to_string(), + port: 443, + path: "/**".to_string(), + }], + )]); + let bound = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + &store, + &catalog, + &records, + &policy_bindings, + &credentials, + None, + ) + .await + .unwrap(); + assert_eq!( + bound.get("GCP_ADC_ACCESS_TOKEN"), + Some(&"google-token".to_string()) + ); + assert_eq!( + bound + .static_credential_bindings + .get("GCP_ADC_ACCESS_TOKEN") + .map(|binding| binding.endpoints.as_slice()), + Some(policy_bindings["my-google-cloud"].as_slice()) + ); } #[tokio::test] @@ -6606,6 +10327,94 @@ mod tests { assert_eq!(result.get("API_TOKEN"), Some(&"token-123".to_string())); assert!(result.dynamic_credentials.is_empty()); + assert!(!result.static_credential_bindings.contains_key("API_TOKEN")); + } + + #[tokio::test] + async fn resolve_provider_env_rejects_unresolvable_credential_handle() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + create_provider_record_validating( + &store, + "default", + &catalog, + provider_with_credential_value( + "github-local", + "github", + "GITHUB_TOKEN", + "github-token", + ), + Some(&credentials), + ) + .await + .unwrap(); + let other_credentials = + crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + + let err = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["github-local".to_string()], + &other_credentials, + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::NotFound); + assert!(err.message().contains("credential handle")); + } + + #[tokio::test] + async fn resolve_provider_env_resolves_credential_handles_with_runtime() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + create_provider_record_validating( + &store, + "default", + &catalog, + provider_with_credential_value( + "github-local", + "github", + "GITHUB_TOKEN", + "github-token", + ), + Some(&credentials), + ) + .await + .unwrap(); + + let result = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["github-local".to_string()], + &credentials, + ) + .await + .unwrap(); + + assert_eq!( + result.get("GITHUB_TOKEN"), + Some(&"github-token".to_string()) + ); + assert!(result.static_credential_keys.contains("GITHUB_TOKEN")); + assert!( + result + .static_credential_bindings + .get("GITHUB_TOKEN") + .is_some_and(|binding| !binding.endpoints.is_empty()) + ); } #[tokio::test] @@ -6638,6 +10447,7 @@ mod tests { .into_iter() .collect(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; create_provider_record(&store, "default", provider) .await @@ -6690,6 +10500,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; create_provider_record(&store, "default", provider) .await @@ -6730,6 +10541,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -6753,25 +10565,152 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + + let result = resolve_provider_environment( + &store, + "default", + &["claude-local".to_string(), "gitlab-local".to_string()], + ) + .await + .unwrap(); + assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); + assert_eq!(result.get("GITLAB_TOKEN"), Some(&"glpat-xyz".to_string())); + } + + #[tokio::test] + async fn resolve_provider_env_rejects_duplicate_credential_keys() { + let store = test_store().await; + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-a".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "claude".to_string(), + credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-b".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "gitlab".to_string(), + credentials: std::iter::once(( + "SHARED_KEY".to_string(), + "second-value".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + + let err = resolve_provider_environment( + &store, + "default", + &["provider-a".to_string(), "provider-b".to_string()], + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("SHARED_KEY")); + assert!(err.message().contains("provider-a")); + assert!(err.message().contains("provider-b")); + } + + #[tokio::test] + async fn validate_provider_environment_keys_unique_includes_credential_handles() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-a".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + ..Default::default() + }), + r#type: "claude".to_string(), + credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), }, ) .await .unwrap(); - - let result = resolve_provider_environment( + create_provider_record_validating( &store, "default", - &["claude-local".to_string(), "gitlab-local".to_string()], + &catalog, + provider_with_credential_value("provider-b", "gitlab", "SHARED_KEY", "second-value"), + Some(&credentials), ) .await .unwrap(); - assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); - assert_eq!(result.get("GITLAB_TOKEN"), Some(&"glpat-xyz".to_string())); + + let err = validate_provider_environment_keys_unique( + &store, + "default", + &["provider-a".to_string(), "provider-b".to_string()], + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("SHARED_KEY")); + assert!(err.message().contains("provider-a")); + assert!(err.message().contains("provider-b")); } #[tokio::test] - async fn resolve_provider_env_rejects_duplicate_credential_keys() { + async fn provider_environment_rejects_plugin_config_credential_collision_in_both_orders() { let store = test_store().await; create_provider_record( &store, @@ -6779,7 +10718,7 @@ mod tests { Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "provider-a".to_string(), + name: "google-config".to_string(), created_at_ms: 0, labels: HashMap::new(), resource_version: 0, @@ -6787,12 +10726,17 @@ mod tests { workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - r#type: "claude".to_string(), - credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) + r#type: "google-cloud".to_string(), + credentials: std::iter::once(( + "GCP_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )) + .collect(), + config: std::iter::once(("project_id".to_string(), "config-project".to_string())) .collect(), - config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -6803,7 +10747,7 @@ mod tests { Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "provider-b".to_string(), + name: "static-credential".to_string(), created_at_ms: 0, labels: HashMap::new(), resource_version: 0, @@ -6813,29 +10757,45 @@ mod tests { }), r#type: "gitlab".to_string(), credentials: std::iter::once(( - "SHARED_KEY".to_string(), - "second-value".to_string(), + "GCP_PROJECT_ID".to_string(), + "credential-value".to_string(), )) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await .unwrap(); - let err = resolve_provider_environment( - &store, - "default", - &["provider-a".to_string(), "provider-b".to_string()], - ) - .await - .unwrap_err(); - assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("SHARED_KEY")); - assert!(err.message().contains("provider-a")); - assert!(err.message().contains("provider-b")); + let attachment_orders = [ + vec!["google-config".to_string(), "static-credential".to_string()], + vec!["static-credential".to_string(), "google-config".to_string()], + ]; + let mut messages = Vec::new(); + for providers in attachment_orders { + let validation_error = + validate_provider_environment_keys_unique(&store, "default", &providers) + .await + .unwrap_err(); + assert_eq!(validation_error.code(), Code::FailedPrecondition); + + let resolution_error = resolve_provider_environment(&store, "default", &providers) + .await + .unwrap_err(); + assert_eq!(resolution_error.code(), Code::FailedPrecondition); + assert_eq!(validation_error.message(), resolution_error.message()); + assert!(resolution_error.message().contains("GCP_PROJECT_ID")); + assert!(resolution_error.message().contains("static-credential")); + assert!(resolution_error.message().contains("google-config")); + messages.push(resolution_error.message().to_string()); + } + assert_eq!( + messages[0], messages[1], + "collision rejection must not depend on attachment order" + ); } #[tokio::test] @@ -6872,6 +10832,7 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -6950,6 +10911,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -6993,6 +10955,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7057,6 +11020,7 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7067,10 +11031,11 @@ mod tests { .await .unwrap(); - // Credential value wins over the injected static value. + // Profile-authoritative runtime projection withholds the undeclared + // credential, so the provider plugin's declared config wins. assert_eq!( result.get("GOOSE_PROVIDER"), - Some(&"custom-value".to_string()) + Some(&"gcp_vertex_ai".to_string()) ); } @@ -7097,6 +11062,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7143,6 +11109,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7170,6 +11137,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7216,6 +11184,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7226,6 +11195,125 @@ mod tests { assert!(err.message().contains("MS_GRAPH_ACCESS_TOKEN")); } + #[tokio::test] + async fn update_provider_rejects_plugin_config_credential_collision() { + let store = test_store().await; + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "google-config".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "google-cloud".to_string(), + credentials: std::iter::once(( + "GCP_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )) + .collect(), + config: std::iter::once(("project_id".to_string(), "config-project".to_string())) + .collect(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "credential-provider".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "gitlab".to_string(), + credentials: std::iter::once(( + "GITLAB_TOKEN".to_string(), + "gitlab-token".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap(); + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-plugin-config-collision".to_string(), + name: "plugin-config-collision".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + spec: Some(SandboxSpec { + providers: vec![ + "google-config".to_string(), + "credential-provider".to_string(), + ], + ..SandboxSpec::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let err = update_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "credential-provider".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: std::iter::once(( + "GCP_PROJECT_ID".to_string(), + "credential-value".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), + }, + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("GCP_PROJECT_ID")); + assert!(err.message().contains("credential-provider")); + assert!(err.message().contains("google-config")); + } + #[tokio::test] async fn handler_flow_resolves_credentials_from_sandbox_providers() { use openshell_core::proto::{Sandbox, SandboxPhase, SandboxSpec}; @@ -7255,6 +11343,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7276,6 +11365,7 @@ mod tests { ..SandboxSpec::default() }), status: None, + ..Sandbox::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); store.put_message(&sandbox).await.unwrap(); @@ -7312,6 +11402,7 @@ mod tests { }), spec: Some(SandboxSpec::default()), status: None, + ..Sandbox::default() }; sandbox.set_phase(SandboxPhase::Ready as i32); store.put_message(&sandbox).await.unwrap(); @@ -7365,6 +11456,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; // Attempt to update with an oversized credential key (exceeds MAX_MAP_KEY_LEN) @@ -7481,12 +11573,104 @@ mod tests { // ---- CAS (Client-driven optimistic concurrency) tests for UpdateProvider ---- + #[tokio::test] + async fn update_provider_rejects_undeclared_profile_credential() { + let state = test_server_state().await; + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "profile-backed-openai", + "openai", + "OPENAI_API_KEY", + "sk-test", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let current = state + .store + .get_message_by_name::("default", "profile-backed-openai") + .await + .unwrap() + .unwrap(); + let mut update = current; + update.credential_handles.clear(); + update.credentials.insert( + "AWS_SECRET_ACCESS_KEY".to_string(), + "unrelated-secret".to_string(), + ); + + let error = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(update), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("AWS_SECRET_ACCESS_KEY")); + } + + #[tokio::test] + async fn update_provider_rejects_deleting_required_profile_credential() { + let state = test_server_state().await; + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "required-openai", + "openai", + "OPENAI_API_KEY", + "sk-test", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let current = state + .store + .get_message_by_name::("default", "required-openai") + .await + .unwrap() + .unwrap(); + let mut update = current; + update.credential_handles.clear(); + update + .credentials + .insert("OPENAI_API_KEY".to_string(), String::new()); + + let error = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(update), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("requires static credentials")); + } + #[tokio::test] async fn update_provider_client_driven_cas_succeeds_with_correct_version() { let state = test_server_state().await; // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -7509,9 +11693,10 @@ mod tests { // Prepare an update with the correct resource_version let mut updated_provider = current.clone(); + updated_provider.credential_handles.clear(); updated_provider - .credentials - .insert("NEW_KEY".to_string(), "new-value".to_string()); + .config + .insert("NEW_CONFIG".to_string(), "new-value".to_string()); updated_provider.metadata.as_mut().unwrap().resource_version = current_version; // Update should succeed @@ -7542,13 +11727,7 @@ mod tests { .resource_version, current_version + 1 ); - assert!( - response - .provider - .unwrap() - .credentials - .contains_key("NEW_KEY") - ); + assert!(response.provider.unwrap().config.contains_key("NEW_CONFIG")); } #[tokio::test] @@ -7556,7 +11735,8 @@ mod tests { let state = test_server_state().await; // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -7579,9 +11759,10 @@ mod tests { // Prepare an update with a stale resource_version let mut stale_provider = current.clone(); + stale_provider.credential_handles.clear(); stale_provider - .credentials - .insert("NEW_KEY".to_string(), "new-value".to_string()); + .config + .insert("NEW_CONFIG".to_string(), "new-value".to_string()); stale_provider.metadata.as_mut().unwrap().resource_version = 99; // stale version // Update should fail with ABORTED @@ -7609,13 +11790,84 @@ mod tests { .store .get_message_by_name::("default", "test-provider") .await - .unwrap() + .unwrap() + .unwrap(); + assert_eq!( + unchanged.metadata.as_ref().unwrap().resource_version, + current_version + ); + assert!(!unchanged.config.contains_key("NEW_CONFIG")); + } + + #[tokio::test] + async fn update_provider_stale_version_does_not_overwrite_stored_credential() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "openai-local", + "openai", + "OPENAI_API_KEY", + "sk-first", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let current = state + .store + .get_message_by_name::("default", "openai-local") + .await + .unwrap() + .unwrap(); + let mut stale_provider = current.clone(); + stale_provider.credential_handles.clear(); + stale_provider + .credentials + .insert("OPENAI_API_KEY".to_string(), "sk-stale".to_string()); + stale_provider.metadata.as_mut().unwrap().resource_version = 99; + + let err = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(stale_provider), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::Aborted); + + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(state.store.as_ref(), "default") + .await .unwrap(); + let resolved = resolve_provider_environment_with_credentials( + state.store.as_ref(), + &catalog, + "default", + &["openai-local".to_string()], + &state.credentials, + ) + .await + .unwrap(); assert_eq!( - unchanged.metadata.as_ref().unwrap().resource_version, - current_version + resolved.get("OPENAI_API_KEY"), + Some(&"sk-first".to_string()) ); - assert!(!unchanged.credentials.contains_key("NEW_KEY")); } #[tokio::test] @@ -7625,7 +11877,8 @@ mod tests { let state = Arc::new(test_server_state().await); // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -7651,8 +11904,9 @@ mod tests { for i in 0..3 { let state_clone = Arc::clone(&state); let mut updated = initial.clone(); + updated.credential_handles.clear(); updated - .credentials + .config .insert(format!("KEY_{i}"), format!("value-{i}")); updated.metadata.as_mut().unwrap().resource_version = initial_version; @@ -7704,87 +11958,17 @@ mod tests { initial_version + 1 ); - // Exactly one of KEY_0, KEY_1, or KEY_2 should be present + // Exactly one of KEY_0, KEY_1, or KEY_2 should be present. let new_keys_count = (0..3) - .filter(|i| final_provider.credentials.contains_key(&format!("KEY_{i}"))) + .filter(|i| final_provider.config.contains_key(&format!("KEY_{i}"))) .count(); assert_eq!(new_keys_count, 1); } #[tokio::test] - async fn configure_aws_sts_requires_v2_enabled() { - let state = test_server_state().await; - create_provider_record( - state.store.as_ref(), - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "my-aws".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "aws".to_string(), - credentials: std::iter::once(( - "AWS_ACCESS_KEY_ID".to_string(), - "placeholder".to_string(), - )) - .collect(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - }, - ) - .await - .unwrap(); - - let err = handle_configure_provider_refresh( - &state, - authed_request(ConfigureProviderRefreshRequest { - provider: "my-aws".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, - material: HashMap::from([( - "role_arn".to_string(), - "arn:aws:iam::123456789012:role/Test".to_string(), - )]), - secret_material_keys: Vec::new(), - expires_at_ms: None, - workspace: "default".to_string(), - }), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("providers_v2_enabled")); - } - - #[tokio::test] - async fn configure_aws_sts_succeeds_with_v2_enabled() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - + async fn configure_aws_sts_succeeds_without_feature_gate() { let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -7808,6 +11992,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7843,24 +12028,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_endpoint_override_material() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -7880,6 +12048,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -7934,24 +12103,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_partial_source_credentials() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -7971,6 +12123,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -8005,17 +12158,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_session_token_without_pair() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -8035,6 +12178,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -8070,24 +12214,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_persists_resolved_additional_output_keys() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -8107,6 +12234,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -8157,59 +12285,43 @@ mod tests { } #[tokio::test] - async fn configure_aws_sts_requires_profile_declaring_the_refresh() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - + async fn update_provider_rejects_gateway_refresh_primary_and_additional_output_keys() { let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - - // A generic provider has no profile, so it declares no STS refresh - // binding. STS must not be configurable against it. - create_provider_record( + let original_credentials = HashMap::from([ + ( + "AWS_ACCESS_KEY_ID".to_string(), + "original-access-key".to_string(), + ), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + "original-secret-key".to_string(), + ), + ( + "AWS_SESSION_TOKEN".to_string(), + "original-session-token".to_string(), + ), + ]); + let provider = create_provider_record( state.store.as_ref(), "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "generic-no-profile".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), + name: "aws-update-guard".to_string(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + ..Default::default() }), - r#type: "generic".to_string(), - credentials: std::iter::once(( - "AWS_ACCESS_KEY_ID".to_string(), - "placeholder".to_string(), - )) - .collect(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), + r#type: "aws".to_string(), + credentials: original_credentials.clone(), + ..Default::default() }, ) .await .unwrap(); - let err = handle_configure_provider_refresh( + handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { - provider: "generic-no-profile".to_string(), + provider: "aws-update-guard".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, material: HashMap::from([( @@ -8222,39 +12334,58 @@ mod tests { }), ) .await - .unwrap_err(); - - assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("provider profile")); - } + .unwrap(); - #[tokio::test] - async fn configure_aws_sts_rejects_non_canonical_credential_key() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; + for key in [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + ] { + for value in ["replacement", ""] { + let err = handle_update_provider( + &state, + authed_request(UpdateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "aws-update-guard".to_string(), + ..Default::default() + }), + credentials: HashMap::from([(key.to_string(), value.to_string())]), + ..Default::default() + }), + credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("provider refresh")); + assert!(err.message().contains(key)); + } + } - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) + let unchanged = state + .store + .get_message::(provider.object_id()) .await + .unwrap() .unwrap(); + assert_eq!(unchanged.credentials, original_credentials); + } + #[tokio::test] + async fn configure_aws_sts_requires_profile_declaring_the_refresh() { + let state = test_server_state().await; + // A generic provider has no profile, so it declares no STS refresh + // binding. STS must not be configurable against it. create_provider_record( state.store.as_ref(), "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "aws-wrong-key".to_string(), + name: "generic-no-profile".to_string(), created_at_ms: 0, labels: HashMap::new(), resource_version: 0, @@ -8262,23 +12393,26 @@ mod tests { workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - r#type: "aws".to_string(), - credentials: HashMap::new(), + r#type: "generic".to_string(), + credentials: std::iter::once(( + "AWS_ACCESS_KEY_ID".to_string(), + "placeholder".to_string(), + )) + .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await .unwrap(); - // AWS_SECRET_ACCESS_KEY is a co-managed output, not the STS primary. The - // profile declares no refresh on it, so STS cannot be pinned there. let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { - provider: "aws-wrong-key".to_string(), - credential_key: "AWS_SECRET_ACCESS_KEY".to_string(), + provider: "generic-no-profile".to_string(), + credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, material: HashMap::from([( "role_arn".to_string(), @@ -8293,30 +12427,19 @@ mod tests { .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("AWS_ACCESS_KEY_ID")); + assert!(err.message().contains("provider profile")); } #[tokio::test] - async fn rotate_aws_sts_blocked_after_providers_v2_disabled() { - use crate::grpc::policy::set_global_bool_setting_for_test; - + async fn configure_aws_sts_rejects_non_canonical_credential_key() { let state = test_server_state().await; - - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), - name: "aws-gate".to_string(), + name: "aws-wrong-key".to_string(), created_at_ms: 0, labels: HashMap::new(), resource_version: 0, @@ -8329,16 +12452,19 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await .unwrap(); - handle_configure_provider_refresh( + // AWS_SECRET_ACCESS_KEY is a co-managed output, not the STS primary. The + // profile declares no refresh on it, so STS cannot be pinned there. + let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { - provider: "aws-gate".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), + provider: "aws-wrong-key".to_string(), + credential_key: "AWS_SECRET_ACCESS_KEY".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, material: HashMap::from([( "role_arn".to_string(), @@ -8350,50 +12476,10 @@ mod tests { }), ) .await - .unwrap(); - - // Disable the gate after the refresh is already configured. - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - false, - ) - .await - .unwrap(); - - let err = handle_rotate_provider_credential( - &state, - authed_request(RotateProviderCredentialRequest { - provider: "aws-gate".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - workspace: "default".to_string(), - }), - ) - .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("providers_v2_enabled")); - - // The gate rejection is recorded on the refresh state, and no credential - // was minted into the provider. - let provider = state - .store - .get_message_by_name::("default", "aws-gate") - .await - .unwrap() - .unwrap(); - assert!(!provider.credentials.contains_key("AWS_ACCESS_KEY_ID")); - let refresh_state = crate::provider_refresh::get_refresh_state( - state.store.as_ref(), - "default", - provider.object_id(), - "AWS_ACCESS_KEY_ID", - ) - .await - .unwrap() - .expect("refresh state should exist"); - assert_eq!(refresh_state.status, "error"); + assert!(err.message().contains("AWS_ACCESS_KEY_ID")); } #[tokio::test] @@ -8418,6 +12504,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -8458,7 +12545,11 @@ mod tests { .await .unwrap(); - let keys = active_provider_environment_keys(state.store.as_ref(), &provider, 0) + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let keys = active_provider_environment_keys(state.store.as_ref(), &catalog, &provider, 0) .await .unwrap(); assert!(keys.contains(&"AWS_ACCESS_KEY_ID".to_string())); @@ -8468,25 +12559,8 @@ mod tests { #[tokio::test] async fn configure_aws_sts_validates_additional_credential_key_collision() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - let mut existing_provider = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -8503,6 +12577,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; existing_provider.credentials.insert( "AWS_SECRET_ACCESS_KEY".to_string(), @@ -8532,6 +12607,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; create_provider_record(state.store.as_ref(), "default", new_provider) .await @@ -8586,17 +12662,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_configure_aws_sts_reserves_key_exactly_once() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - for name in ["aws-a", "aws-b"] { create_provider_record( state.store.as_ref(), @@ -8617,6 +12683,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await @@ -8701,6 +12768,7 @@ mod tests { config, credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), } } @@ -8807,6 +12875,7 @@ mod tests { config: HashMap::from([("project_id".to_string(), "should-be-ignored".to_string())]), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; let mut env = HashMap::new(); openshell_providers::ProviderRegistry::new().inject_env(&provider, &mut env); @@ -8839,11 +12908,12 @@ mod tests { // Create same-named provider in each workspace via handlers. let make_provider = || Provider { metadata: None, - r#type: "custom".to_string(), - credentials: HashMap::from([("TOKEN".to_string(), "secret".to_string())]), + r#type: "pypi".to_string(), + credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; let created_default = handle_create_provider( @@ -9175,6 +13245,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "other-workspace".to_string(), + credential_handles: HashMap::new(), }; let err = create_provider_record(&store, "default", provider) .await @@ -9202,6 +13273,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; let created = create_provider_record(&store, "default", provider) .await @@ -9228,6 +13300,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; let created = create_provider_record(&store, "default", provider) .await @@ -9254,6 +13327,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }; create_provider_record(&store, "default", provider) .await @@ -9275,6 +13349,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "other".to_string(), + credential_handles: HashMap::new(), }; let err = update_provider_record(&store, "default", update) .await @@ -9357,6 +13432,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), }, ) .await diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index d3cdddf41c..b941502c62 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -15,23 +15,30 @@ use crate::auth::workspace_authz::{ }; use crate::persistence::{ObjectLabels, ObjectType, WriteCondition, generate_name}; use futures::future; +use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, - CreateSshSessionRequest, CreateSshSessionResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, ExecSandboxExit, - ExecSandboxInput, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout, GetSandboxRequest, - ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, - ListSandboxesResponse, Provider, RevokeSshSessionRequest, RevokeSshSessionResponse, - SandboxResponse, SandboxStreamEvent, SshRelayTarget, TcpForwardFrame, TcpForwardInit, - TcpRelayTarget, WatchSandboxRequest, relay_open, tcp_forward_init, + CreateSandboxTemplateRequest, CreateSshSessionRequest, CreateSshSessionResponse, + DeleteSandboxRequest, DeleteSandboxResponse, DeleteSandboxTemplateRequest, + DeleteSandboxTemplateResponse, DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExecSandboxEvent, ExecSandboxExit, ExecSandboxInput, ExecSandboxRequest, ExecSandboxStderr, + ExecSandboxStdout, GetSandboxRequest, GetSandboxTemplateRequest, ListSandboxProvidersRequest, + ListSandboxProvidersResponse, ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, + ListSandboxesRequest, ListSandboxesResponse, Provider, ResourceRequirements, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResources, SandboxResponse, + SandboxSpec, SandboxStreamEvent, SandboxTemplateResponse, SandboxWorkloadTemplate, + SandboxWorkloadTemplateProvenance, SshRelayTarget, StartSandboxRequest, StopSandboxRequest, + TcpForwardFrame, TcpForwardInit, TcpRelayTarget, WatchSandboxRequest, relay_open, + tcp_forward_init, }; use openshell_core::proto::{Sandbox, SandboxPhase, SandboxTemplate, SshSession}; use openshell_core::telemetry::{ - LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryComputeDriver, - TelemetryOutcome, + LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryOutcome, }; -use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; +use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; +use prost_types::{Struct, Value, value::Kind}; use std::collections::HashMap; use std::net::IpAddr; use std::pin::Pin; @@ -48,17 +55,19 @@ use russh::ChannelMsg; use russh::client::AuthResult; use super::provider::{ - get_provider_record, is_valid_env_key, validate_provider_environment_keys_unique, + get_provider_record, is_valid_env_key, validate_provider_environment_keys_unique_with_catalog, }; use super::validation::{ - level_matches, normalize_process_identity_for_driver, source_matches, - validate_exec_request_fields, validate_no_reserved_provider_policy_keys, - validate_policy_safety, validate_sandbox_spec, + level_matches, source_matches, validate_dns1123_label, validate_exec_request_fields, + validate_no_reserved_provider_policy_keys, validate_policy_safety, + validate_sandbox_governance_spec, validate_sandbox_spec, }; use super::{MAX_PAGE_SIZE, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, clamp_limit}; use crate::persistence::current_time_ms; const TCP_FORWARD_CHUNK_SIZE: usize = 64 * 1024; +const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); +const MAX_TEMPLATES_PER_WORKSPACE: u32 = 1000; #[derive(Debug)] pub struct WatchSandboxStream { @@ -157,30 +166,69 @@ pub(super) async fn handle_create_sandbox( ) -> Result, Status> { let create_request = request.get_ref().clone(); let result = handle_create_sandbox_inner(state, request).await; + let created_sandbox = result + .as_ref() + .ok() + .and_then(|response| response.get_ref().sandbox.as_ref()); emit_sandbox_create_telemetry( state, &create_request, + created_sandbox, TelemetryOutcome::from_success(result.is_ok()), ); result } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SandboxCreateTelemetryAttrs { + requested_gpu: bool, + provider_count: u64, + has_custom_policy: bool, + template_source: SandboxTemplateSource, +} + fn emit_sandbox_create_telemetry( state: &Arc, request: &CreateSandboxRequest, + created_sandbox: Option<&Sandbox>, outcome: TelemetryOutcome, ) { - let compute_driver = telemetry_compute_driver(state.compute.driver_kind()); + let compute_driver = state.compute.telemetry_compute_driver(); + let attrs = sandbox_create_telemetry_attrs(request, created_sandbox); + openshell_core::telemetry::emit_sandbox_create( + outcome, + attrs.requested_gpu, + attrs.provider_count, + attrs.has_custom_policy, + attrs.template_source, + compute_driver, + ); +} + +fn sandbox_create_telemetry_attrs( + request: &CreateSandboxRequest, + created_sandbox: Option<&Sandbox>, +) -> SandboxCreateTelemetryAttrs { + if !request.workload_template_name.trim().is_empty() { + let spec = created_sandbox + .and_then(|sandbox| sandbox.spec.as_ref()) + .or(request.spec.as_ref()); + return SandboxCreateTelemetryAttrs { + requested_gpu: spec.is_some_and(|spec| { + openshell_core::gpu::sandbox_gpu_requested(spec.resource_requirements.as_ref()) + }), + provider_count: spec.map_or(0, |spec| spec.providers.len() as u64), + has_custom_policy: spec.is_some_and(|spec| spec.policy.is_some()), + template_source: SandboxTemplateSource::WorkloadTemplate, + }; + } let Some(spec) = request.spec.as_ref() else { - openshell_core::telemetry::emit_sandbox_create( - outcome, - false, - 0, - false, - SandboxTemplateSource::Undefined, - compute_driver, - ); - return; + return SandboxCreateTelemetryAttrs { + requested_gpu: false, + provider_count: 0, + has_custom_policy: false, + template_source: SandboxTemplateSource::Undefined, + }; }; let template_source = if spec .template @@ -193,20 +241,12 @@ fn emit_sandbox_create_telemetry( }; let gpu_requested = openshell_core::gpu::sandbox_gpu_requested(spec.resource_requirements.as_ref()); - openshell_core::telemetry::emit_sandbox_create( - outcome, - gpu_requested, - spec.providers.len() as u64, - spec.policy.is_some(), + SandboxCreateTelemetryAttrs { + requested_gpu: gpu_requested, + provider_count: spec.providers.len() as u64, + has_custom_policy: spec.policy.is_some(), template_source, - compute_driver, - ); -} - -fn telemetry_compute_driver( - driver_kind: Option, -) -> TelemetryComputeDriver { - TelemetryComputeDriver::from_driver_kind(driver_kind) + } } async fn handle_create_sandbox_inner( @@ -215,19 +255,10 @@ async fn handle_create_sandbox_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let spec = request - .spec - .ok_or_else(|| Status::invalid_argument("spec is required"))?; - - // Validate field sizes before any I/O (fail fast on oversized payloads). - validate_sandbox_spec(&request.name, &spec)?; + let await_main_process_attachment = request.await_main_process_attachment; + let workload_template_name = request.workload_template_name.trim().to_string(); - // Validate labels (keys and values must meet Kubernetes requirements). - for (key, value) in &request.labels { - crate::grpc::validation::validate_label_key(key)?; - crate::grpc::validation::validate_label_value(value)?; - } - crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; + validate_create_sandbox_request_pre_io(&request, &workload_template_name)?; let authz = authorize_workspace( &state.store, @@ -241,6 +272,43 @@ async fn handle_create_sandbox_inner( .await? .ensure_active()?; + let (mut spec, created_from_workload_template) = if workload_template_name.is_empty() { + let spec = request + .spec + .ok_or_else(|| Status::invalid_argument("spec is required"))?; + (spec, None) + } else { + let governance_spec = request.spec.unwrap_or_default(); + let template = state + .store + .get_message_by_name::(&workspace, &workload_template_name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox template failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox template not found"))?; + let provenance = SandboxWorkloadTemplateProvenance { + name: template.object_name().to_string(), + resource_version: template.get_resource_version().to_string(), + }; + let mut resolved = sandbox_spec_from_stored_workload_template(&template)?; + resolved.policy = governance_spec.policy; + resolved.providers = governance_spec.providers; + resolved.command = governance_spec.command; + resolved.tty = governance_spec.tty; + (resolved, Some(provenance)) + }; + + // Leave an omitted command empty rather than persisting a concrete shell: + // the supervisor resolves the default login shell against the sandbox image + // (bash when present, otherwise /bin/sh on minimal images like Alpine), + // which the gateway cannot do since it does not see the sandbox filesystem. + // The default is an interactive login shell, so request a TTY. + if spec.command.is_empty() { + spec.tty = true; + } + + // Validate field sizes before any create-side effects. + validate_sandbox_spec(&request.name, &spec)?; + let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { @@ -256,24 +324,37 @@ async fn handle_create_sandbox_inner( .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; } - validate_provider_environment_keys_unique(state.store.as_ref(), &workspace, &spec.providers) + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) .await?; + validate_provider_environment_keys_unique_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &workspace, + &spec.providers, + ) + .await?; // Ensure the template always carries the resolved image. - let mut spec = spec; let template = spec.template.get_or_insert_with(SandboxTemplate::default); - if template.image.is_empty() { + if template.image.trim().is_empty() { template.image = state.compute.default_image().to_string(); } - // Docker and Podman preserve omitted identity fields for OCI USER - // fallback. Other drivers retain the legacy persisted sandbox defaults. if let Some(ref mut policy) = spec.policy { - normalize_process_identity_for_driver(policy, state.compute.driver_kind()); + super::policy::clear_provider_credentialed_markers(policy); validate_no_reserved_provider_policy_keys(policy)?; validate_policy_safety(policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), policy).await?; } + super::policy::validate_candidate_sandbox_credential_policy( + state, + &workspace, + &spec.providers, + spec.policy.as_ref(), + ) + .await?; let id = uuid::Uuid::new_v4().to_string(); let name = if request.name.is_empty() { @@ -285,7 +366,7 @@ async fn handle_create_sandbox_inner( let now_ms = current_time_ms(); let mut sandbox = Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + metadata: Some(ObjectMeta { id: id.clone(), name: name.clone(), created_at_ms: now_ms, @@ -297,11 +378,23 @@ async fn handle_create_sandbox_inner( }), spec: Some(spec), status: None, + created_from_workload_template, }; sandbox.set_phase(SandboxPhase::Provisioning as i32); // Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata) super::validation::validate_object_metadata(sandbox.metadata.as_ref(), "sandbox")?; + super::policy::validate_candidate_provider_attachments( + state, + sandbox.object_workspace(), + &sandbox, + sandbox + .spec + .as_ref() + .map(|spec| spec.providers.as_slice()) + .unwrap_or_default(), + ) + .await?; state .compute @@ -312,11 +405,8 @@ async fn handle_create_sandbox_inner( status })?; - // Mint the gateway JWT for singleplayer drivers. K8s sandboxes skip - // this mint and bootstrap via `IssueSandboxToken` at supervisor - // startup; identifying "is this K8s?" lives in the compute layer, so - // we mint unconditionally here when the issuer is configured and let - // the K8s driver simply ignore the field. + // Mint a gateway JWT whenever the issuer is configured. Compute runtimes + // that bootstrap through another authentication mechanism may ignore it. let sandbox_token = state.sandbox_jwt_issuer.as_ref().map(|issuer| { issuer.mint(&id).map(|minted| { tracing::info!( @@ -332,7 +422,10 @@ async fn handle_create_sandbox_inner( None => None, }; - let sandbox = state.compute.create_sandbox(sandbox, sandbox_token).await?; + let sandbox = state + .compute + .create_sandbox(sandbox, sandbox_token, await_main_process_attachment) + .await?; info!( sandbox_id = %id, @@ -344,6 +437,135 @@ async fn handle_create_sandbox_inner( })) } +fn validate_create_sandbox_request_pre_io( + request: &CreateSandboxRequest, + workload_template_name: &str, +) -> Result<(), Status> { + // Validate labels (keys and values must meet Kubernetes requirements). + for (key, value) in &request.labels { + crate::grpc::validation::validate_label_key(key)?; + crate::grpc::validation::validate_label_value(value)?; + } + crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; + + if workload_template_name.is_empty() { + let spec = request + .spec + .as_ref() + .ok_or_else(|| Status::invalid_argument("spec is required"))?; + return validate_sandbox_spec(&request.name, spec); + } + + validate_dns1123_label(workload_template_name, "workload_template_name")?; + if let Some(spec) = request.spec.as_ref() { + validate_template_create_governance_spec(spec)?; + validate_sandbox_governance_spec(&request.name, spec)?; + } else { + validate_sandbox_governance_spec(&request.name, &SandboxSpec::default())?; + } + Ok(()) +} + +fn validate_template_create_governance_spec(spec: &SandboxSpec) -> Result<(), Status> { + if !spec.log_level.is_empty() { + return Err(Status::invalid_argument( + "spec.log_level cannot be set when workload_template_name is set", + )); + } + if !spec.environment.is_empty() { + return Err(Status::invalid_argument( + "spec.environment cannot be set when workload_template_name is set", + )); + } + if spec.template.is_some() { + return Err(Status::invalid_argument( + "spec.template cannot be set when workload_template_name is set", + )); + } + if spec.resource_requirements.is_some() { + return Err(Status::invalid_argument( + "spec.resource_requirements cannot be set when workload_template_name is set", + )); + } + Ok(()) +} + +fn sandbox_spec_from_stored_workload_template( + template: &SandboxWorkloadTemplate, +) -> Result { + sandbox_spec_from_workload_template(template, tonic::Code::Internal) +} + +fn sandbox_spec_from_user_workload_template( + template: &SandboxWorkloadTemplate, +) -> Result { + sandbox_spec_from_workload_template(template, tonic::Code::InvalidArgument) +} + +fn sandbox_spec_from_workload_template( + template: &SandboxWorkloadTemplate, + missing_field_code: tonic::Code, +) -> Result { + let spec = template + .spec + .as_ref() + .ok_or_else(|| Status::new(missing_field_code, "sandbox template spec is required"))?; + let workload = spec + .workload + .as_ref() + .ok_or_else(|| Status::new(missing_field_code, "sandbox template workload is required"))?; + let resources = workload.resources.as_ref(); + Ok(SandboxSpec { + environment: workload.environment.clone(), + template: Some(SandboxTemplate { + image: workload.image.clone(), + resources: resources.and_then(template_resource_struct), + driver_config: spec.driver_config.clone(), + ..SandboxTemplate::default() + }), + resource_requirements: resources.and_then(template_gpu_requirements), + ..SandboxSpec::default() + }) +} + +fn template_gpu_requirements(resources: &SandboxResources) -> Option { + Some(ResourceRequirements { + gpu: Some(resources.gpu?), + }) +} + +fn template_resource_struct(resources: &SandboxResources) -> Option { + let mut limits = std::collections::BTreeMap::new(); + if !resources.cpu.is_empty() { + limits.insert( + "cpu".to_string(), + Value { + kind: Some(Kind::StringValue(resources.cpu.clone())), + }, + ); + } + if !resources.memory.is_empty() { + limits.insert( + "memory".to_string(), + Value { + kind: Some(Kind::StringValue(resources.memory.clone())), + }, + ); + } + if limits.is_empty() { + None + } else { + let mut fields = std::collections::BTreeMap::new(); + fields.insert( + "limits".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { fields: limits })), + }, + ); + Some(Struct { fields }) + } +} + pub(super) async fn handle_get_sandbox( state: &Arc, request: Request, @@ -444,126 +666,418 @@ pub(super) async fn handle_list_sandboxes( Ok(Response::new(ListSandboxesResponse { sandboxes })) } -pub(super) async fn handle_list_sandbox_providers( +pub(super) async fn handle_create_sandbox_template( state: &Arc, - request: Request, -) -> Result, Status> { + request: Request, +) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); + let template = req + .template + .ok_or_else(|| Status::invalid_argument("template is required"))?; + let metadata = template.metadata.clone().unwrap_or_default(); let authz = authorize_workspace( &state.store, &state.admin_role, &principal, &req.workspace, - MinWorkspaceRole::User, + MinWorkspaceRole::Admin, ) .await?; let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? - .name; - let sandbox = sandbox_by_name(state, &workspace, &req.sandbox_name).await?; - let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; - Ok(Response::new(ListSandboxProvidersResponse { providers })) + .ensure_active()?; + if !metadata.workspace.is_empty() && metadata.workspace != workspace { + return Err(Status::invalid_argument( + "template.metadata.workspace must match request workspace", + )); + } + if metadata.name.is_empty() { + return Err(Status::invalid_argument( + "template.metadata.name is required", + )); + } + + let mut resolved = template; + resolved.metadata = Some(ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: metadata.name, + created_at_ms: current_time_ms(), + labels: metadata.labels, + resource_version: 0, + annotations: metadata.annotations, + workspace: workspace.clone(), + deletion_timestamp_ms: 0, + }); + validate_sandbox_workload_template(&resolved)?; + + let labels_map = resolved.object_labels(); + let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { + None + } else { + Some( + serde_json::to_string(&labels_map) + .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, + ) + }; + let write = state + .store + .create_if_workspace_count_below( + SandboxWorkloadTemplate::object_type(), + resolved.object_id(), + resolved.object_name(), + &workspace, + &resolved.encode_to_vec(), + labels_json.as_deref(), + u64::from(MAX_TEMPLATES_PER_WORKSPACE), + ) + .await; + let write = match write { + Ok(Some(write)) => write, + Ok(None) => { + return Err(Status::resource_exhausted(format!( + "workspace has reached the maximum of {MAX_TEMPLATES_PER_WORKSPACE} sandbox templates" + ))); + } + Err(crate::persistence::PersistenceError::UniqueViolation { .. }) => { + return Err(Status::already_exists("sandbox template already exists")); + } + Err(err) => { + return Err(Status::internal(format!( + "persist sandbox template failed: {err}" + ))); + } + }; + if let Some(metadata) = resolved.metadata.as_mut() { + metadata.resource_version = write.resource_version; + } + + Ok(Response::new(SandboxTemplateResponse { + template: Some(resolved), + })) } -pub(super) async fn handle_attach_sandbox_provider( +pub(super) async fn handle_get_sandbox_template( state: &Arc, - request: Request, -) -> Result, Status> { + request: Request, +) -> Result, Status> { let principal = super::extract_principal(&request)?; - let request = request.into_inner(); + let req = request.into_inner(); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } let authz = authorize_workspace( &state.store, &state.admin_role, &principal, - &request.workspace, + &req.workspace, MinWorkspaceRole::User, ) .await?; let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? - .ensure_active()?; - if request.provider_name.is_empty() { - return Err(Status::invalid_argument("provider_name is required")); - } - - // Validate provider name would not violate sandbox spec constraints if added - // (pre-validation ensures CAS mutations preserve invariants) - if request.provider_name.len() > super::MAX_NAME_LEN { - return Err(Status::invalid_argument(format!( - "provider_name exceeds maximum length ({} > {})", - request.provider_name.len(), - super::MAX_NAME_LEN - ))); - } - - get_provider_record(state.store.as_ref(), &workspace, &request.provider_name) + .name; + let template = state + .store + .get_message_by_name::(&workspace, &req.name) .await - .map_err(|err| { - if err.code() == tonic::Code::NotFound { - Status::failed_precondition(format!( - "provider '{}' not found", - request.provider_name - )) - } else { - err - } - })?; - - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; - let sandbox_id = sandbox - .metadata - .as_ref() - .ok_or_else(|| Status::internal("sandbox metadata is missing"))? - .id - .clone(); - - // Pre-check: fail fast if sandbox spec is missing (invariant violation) - let spec = sandbox - .spec - .as_ref() - .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + .map_err(|e| Status::internal(format!("fetch sandbox template failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox template not found"))?; + Ok(Response::new(SandboxTemplateResponse { + template: Some(template), + })) +} - // Pre-check: fail fast if already at MAX_PROVIDERS limit (avoid spurious CAS conflicts) - // Note: This is an optimization; the CAS closure rechecks after dedupe in case of races - if spec.providers.len() >= MAX_PROVIDERS - && !spec - .providers - .iter() - .any(|name| name == &request.provider_name) - { - return Err(Status::invalid_argument(format!( - "providers list exceeds maximum ({MAX_PROVIDERS})" - ))); - } - let mut candidate_spec = spec.clone(); - dedupe_provider_names(&mut candidate_spec.providers); - if !candidate_spec - .providers - .iter() - .any(|name| name == &request.provider_name) - { - candidate_spec.providers.push(request.provider_name.clone()); +pub(super) async fn handle_list_sandbox_templates( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); } - validate_sandbox_spec(&request.sandbox_name, &candidate_spec)?; - validate_provider_environment_keys_unique( - state.store.as_ref(), - &workspace, - &candidate_spec.providers, - ) - .await?; - super::policy::validate_candidate_provider_attachments( - state, - &workspace, - &sandbox, - &candidate_spec.providers, - ) - .await?; - - let provider_name = request.provider_name.clone(); - let attached = Arc::new(AtomicBool::new(false)); - let attached_clone = attached.clone(); + let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); + let templates = if request.all_workspaces { + require_platform_admin(&state.admin_role, &principal)?; + if request.label_selector.is_empty() { + state + .store + .list_all_messages::(limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandbox templates failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_all_messages_with_selector::( + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| Status::internal(format!("list sandbox templates failed: {e}")))? + } + } else { + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &request.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; + if request.label_selector.is_empty() { + state + .store + .list_messages::(&workspace, limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandbox templates failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_messages_with_selector::( + &workspace, + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| { + Status::internal(format!("list sandbox templates with selector failed: {e}")) + })? + } + }; + Ok(Response::new(ListSandboxTemplatesResponse { templates })) +} + +pub(super) async fn handle_delete_sandbox_template( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let req = request.into_inner(); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &req.workspace, + MinWorkspaceRole::Admin, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; + let deleted = state + .store + .delete_by_name( + SandboxWorkloadTemplate::object_type(), + &workspace, + &req.name, + ) + .await + .map_err(|e| Status::internal(format!("delete sandbox template failed: {e}")))?; + Ok(Response::new(DeleteSandboxTemplateResponse { deleted })) +} + +fn validate_sandbox_workload_template(template: &SandboxWorkloadTemplate) -> Result<(), Status> { + super::validation::validate_object_metadata(template.metadata.as_ref(), "sandbox_template")?; + let name = template.object_name().to_string(); + validate_dns1123_label(&name, "template.metadata.name")?; + validate_sandbox_workload_template_service_level(template)?; + let spec = sandbox_spec_from_user_workload_template(template)?; + validate_sandbox_spec(&name, &spec)?; + Ok(()) +} + +fn validate_sandbox_workload_template_service_level( + template: &SandboxWorkloadTemplate, +) -> Result<(), Status> { + let Some(startup) = template + .spec + .as_ref() + .and_then(|spec| spec.desired_service_level.as_ref()) + .and_then(|service_level| service_level.startup.as_ref()) + else { + return Ok(()); + }; + if let Some(ready_within) = &startup.ready_within { + validate_positive_normalized_duration( + ready_within, + "template.spec.desired_service_level.startup.ready_within", + )?; + } + Ok(()) +} + +fn validate_positive_normalized_duration( + duration: &prost_types::Duration, + field: &str, +) -> Result<(), Status> { + const MAX_DURATION_SECONDS: u64 = 315_576_000_000; + if duration.seconds.unsigned_abs() > MAX_DURATION_SECONDS + || duration.nanos.unsigned_abs() >= 1_000_000_000 + { + return Err(Status::invalid_argument(format!( + "{field} must be a valid protobuf Duration" + ))); + } + if (duration.seconds > 0 && duration.nanos < 0) || (duration.seconds < 0 && duration.nanos > 0) + { + return Err(Status::invalid_argument(format!( + "{field} must be a normalized protobuf Duration" + ))); + } + if duration.seconds < 0 || duration.nanos < 0 || (duration.seconds == 0 && duration.nanos == 0) + { + return Err(Status::invalid_argument(format!( + "{field} must be greater than zero" + ))); + } + Ok(()) +} + +pub(super) async fn handle_list_sandbox_providers( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let req = request.into_inner(); + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &req.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; + let sandbox = sandbox_by_name(state, &workspace, &req.sandbox_name).await?; + let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; + Ok(Response::new(ListSandboxProvidersResponse { providers })) +} + +pub(super) async fn handle_attach_sandbox_provider( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let request = request.into_inner(); + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &request.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .ensure_active()?; + if request.provider_name.is_empty() { + return Err(Status::invalid_argument("provider_name is required")); + } + + // Validate provider name would not violate sandbox spec constraints if added + // (pre-validation ensures CAS mutations preserve invariants) + if request.provider_name.len() > super::MAX_NAME_LEN { + return Err(Status::invalid_argument(format!( + "provider_name exceeds maximum length ({} > {})", + request.provider_name.len(), + super::MAX_NAME_LEN + ))); + } + + get_provider_record(state.store.as_ref(), &workspace, &request.provider_name) + .await + .map_err(|err| { + if err.code() == tonic::Code::NotFound { + Status::failed_precondition(format!( + "provider '{}' not found", + request.provider_name + )) + } else { + err + } + })?; + + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; + let sandbox_id = sandbox + .metadata + .as_ref() + .ok_or_else(|| Status::internal("sandbox metadata is missing"))? + .id + .clone(); + + // Pre-check: fail fast if sandbox spec is missing (invariant violation) + let spec = sandbox + .spec + .as_ref() + .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + + // Pre-check: fail fast if already at MAX_PROVIDERS limit (avoid spurious CAS conflicts) + // Note: This is an optimization; the CAS closure rechecks after dedupe in case of races + if spec.providers.len() >= MAX_PROVIDERS + && !spec + .providers + .iter() + .any(|name| name == &request.provider_name) + { + return Err(Status::invalid_argument(format!( + "providers list exceeds maximum ({MAX_PROVIDERS})" + ))); + } + let mut candidate_spec = spec.clone(); + dedupe_provider_names(&mut candidate_spec.providers); + if !candidate_spec + .providers + .iter() + .any(|name| name == &request.provider_name) + { + candidate_spec.providers.push(request.provider_name.clone()); + } + validate_sandbox_spec(&request.sandbox_name, &candidate_spec)?; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + validate_provider_environment_keys_unique_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &workspace, + &candidate_spec.providers, + ) + .await?; + super::policy::validate_candidate_provider_attachments( + state, + &workspace, + &sandbox, + &candidate_spec.providers, + ) + .await?; + super::policy::validate_candidate_sandbox_credential_policy( + state, + &workspace, + &candidate_spec.providers, + candidate_spec.policy.as_ref(), + ) + .await?; + + let provider_name = request.provider_name.clone(); + let attached = Arc::new(AtomicBool::new(false)); + let attached_clone = attached.clone(); let sandbox = state .store @@ -643,10 +1157,22 @@ pub(super) async fn handle_detach_sandbox_provider( .clone(); // Pre-check: fail fast if sandbox spec is missing (invariant violation) - let _spec = sandbox + let spec = sandbox .spec .as_ref() .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + let mut candidate_spec = spec.clone(); + candidate_spec + .providers + .retain(|name| name != &request.provider_name); + dedupe_provider_names(&mut candidate_spec.providers); + super::policy::validate_candidate_provider_attachments( + state, + &workspace, + &sandbox, + &candidate_spec.providers, + ) + .await?; let provider_name = request.provider_name.clone(); let detached = Arc::new(AtomicBool::new(false)); @@ -739,26 +1265,114 @@ async fn handle_delete_sandbox_inner( })) } -async fn sandbox_by_name( +pub(super) async fn handle_stop_sandbox( state: &Arc, - workspace: &str, - name: &str, -) -> Result { - if name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); - } - - state - .store - .get_message_by_name::(workspace, name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found")) + request: Request, +) -> Result, Status> { + let result = handle_stop_sandbox_inner(state, request).await; + openshell_core::telemetry::emit_lifecycle( + LifecycleResource::Sandbox, + LifecycleOperation::Stop, + if result.is_ok() { + TelemetryOutcome::Success + } else { + TelemetryOutcome::Failure + }, + ); + result } -async fn providers_for_sandbox( +async fn handle_stop_sandbox_inner( state: &Arc, - sandbox: &Sandbox, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let req = request.into_inner(); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &req.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; + let sandbox = state.compute.stop_sandbox(&workspace, &req.name).await?; + info!(sandbox_name = %req.name, "StopSandbox request completed successfully"); + Ok(Response::new(SandboxResponse { + sandbox: Some(sandbox), + })) +} + +pub(super) async fn handle_start_sandbox( + state: &Arc, + request: Request, +) -> Result, Status> { + let result = handle_start_sandbox_inner(state, request).await; + openshell_core::telemetry::emit_lifecycle( + LifecycleResource::Sandbox, + LifecycleOperation::Start, + if result.is_ok() { + TelemetryOutcome::Success + } else { + TelemetryOutcome::Failure + }, + ); + result +} + +async fn handle_start_sandbox_inner( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + let req = request.into_inner(); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &req.workspace, + MinWorkspaceRole::User, + ) + .await?; + let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; + let sandbox = state.compute.start_sandbox(&workspace, &req.name).await?; + info!(sandbox_name = %req.name, "StartSandbox request completed successfully"); + Ok(Response::new(SandboxResponse { + sandbox: Some(sandbox), + })) +} + +async fn sandbox_by_name( + state: &Arc, + workspace: &str, + name: &str, +) -> Result { + if name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + + state + .store + .get_message_by_name::(workspace, name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found")) +} + +async fn providers_for_sandbox( + state: &Arc, + sandbox: &Sandbox, workspace: &str, ) -> Result, Status> { let provider_names = sandbox @@ -887,7 +1501,7 @@ pub(super) async fn handle_watch_sandbox( if stop_on_terminal { let phase = SandboxPhase::try_from(sandbox.phase()) .unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Ready { + if is_watch_terminal(phase) { return; } } @@ -961,7 +1575,7 @@ pub(super) async fn handle_watch_sandbox( } if stop_on_terminal { let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Ready { + if is_watch_terminal(phase) { return; } } @@ -1034,10 +1648,27 @@ pub(super) async fn handle_watch_sandbox( Ok(Response::new(WatchSandboxStream::new(rx, producer))) } +fn is_watch_terminal(phase: SandboxPhase) -> bool { + matches!( + phase, + SandboxPhase::Ready | SandboxPhase::Completed | SandboxPhase::Stopped | SandboxPhase::Error + ) +} + // --------------------------------------------------------------------------- // Exec handler // --------------------------------------------------------------------------- +const DEFAULT_PTY_COLS: u32 = 80; +const DEFAULT_PTY_ROWS: u32 = 24; + +fn pty_dimensions(cols: u32, rows: u32) -> (u32, u32) { + ( + if cols == 0 { DEFAULT_PTY_COLS } else { cols }, + if rows == 0 { DEFAULT_PTY_ROWS } else { rows }, + ) +} + pub(super) async fn handle_exec_sandbox( state: &Arc, request: Request, @@ -1079,9 +1710,12 @@ pub(super) async fn handle_exec_sandbox( let stdin_payload = req.stdin; let timeout_seconds = req.timeout_seconds; let request_tty = req.tty; + let (cols, rows) = pty_dimensions(req.cols, req.rows); let sandbox_id = sandbox.object_id().to_string(); + let no_login_shell = req.no_login_shell; + let (tx, rx) = mpsc::channel::>(256); tokio::spawn(async move { // Wait for the supervisor's reverse CONNECT to deliver the relay stream. @@ -1100,6 +1734,9 @@ pub(super) async fn handle_exec_sandbox( stdin_payload, timeout_seconds, request_tty, + no_login_shell, + cols, + rows, ) .await { @@ -1171,7 +1808,10 @@ pub(super) async fn handle_forward_tcp( let sandbox = fetch_and_authorize_sandbox(state, &principal, &init.sandbox_id).await?; - if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { + // The main process may finish between minting the SSH token and opening + // its transport. Keep the relay reachable until terminal delivery is + // finalized so fast commands can attach without a readiness race. + if !sandbox_relay_reachable(state, &sandbox) { return Err(Status::failed_precondition("sandbox is not ready")); } @@ -1248,7 +1888,7 @@ async fn acquire_forward_connection_guard( Ok(ForwardConnectionGuard { state: state.clone(), token: Some(token.to_string()), - sandbox_id, + sandbox_id: sandbox_id.clone(), }) } @@ -1506,9 +2146,9 @@ pub(super) async fn handle_exec_sandbox_interactive( let command_str = build_remote_exec_command(&req) .map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?; let request_tty = req.tty; + let no_login_shell = req.no_login_shell; let timeout_seconds = req.timeout_seconds; - let cols = if req.cols == 0 { 80 } else { req.cols }; - let rows = if req.rows == 0 { 24 } else { req.rows }; + let (cols, rows) = pty_dimensions(req.cols, req.rows); let sandbox_id = sandbox.object_id().to_string(); @@ -1534,6 +2174,7 @@ pub(super) async fn handle_exec_sandbox_interactive( &command_str, input_stream, request_tty, + no_login_shell, timeout_seconds, cols, rows, @@ -1552,6 +2193,16 @@ pub(super) async fn handle_exec_sandbox_interactive( // SSH session handlers // --------------------------------------------------------------------------- +fn sandbox_relay_reachable(state: &ServerState, sandbox: &Sandbox) -> bool { + let phase = SandboxPhase::try_from(sandbox.phase()).ok(); + matches!(phase, Some(SandboxPhase::Ready)) + || (matches!(phase, Some(SandboxPhase::Completed | SandboxPhase::Error)) + && state.supervisor_sessions.has_session(sandbox.object_id()) + && !state + .supervisor_sessions + .terminal_delivery_finalized(sandbox.object_id())) +} + pub(super) async fn handle_create_ssh_session( state: &Arc, request: Request, @@ -1564,7 +2215,7 @@ pub(super) async fn handle_create_ssh_session( let sandbox = fetch_and_authorize_sandbox(state, &principal, &req.sandbox_id).await?; - if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { + if !sandbox_relay_reachable(state, &sandbox) { return Err(Status::failed_precondition("sandbox is not ready")); } @@ -1576,7 +2227,7 @@ pub(super) async fn handle_create_ssh_session( 0 }; let session = SshSession { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + metadata: Some(ObjectMeta { id: token.clone(), name: generate_name(), created_at_ms: now_ms, @@ -1751,6 +2402,16 @@ const EXEC_KEEPALIVE_MAX: usize = 4; /// Max wait for a trailing `Close` after `ExitStatus`. const EXEC_POST_EXIT_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); +/// Supervisor SSH banner software token that signals no-login-shell support. +const OPENSHELL_SSHID_PREFIX: &[u8] = b"SSH-2.0-OpenShell_"; + +/// A supervisor honors `OPENSHELL_NO_LOGIN_SHELL` only if it identifies as +/// `OpenShell`. Older sandboxes present russh's default banner and silently +/// ignore the env request, so gate the opt-out on the `OpenShell` identity. +fn supervisor_supports_no_login_shell(remote_sshid: &[u8]) -> bool { + remote_sshid.starts_with(OPENSHELL_SSHID_PREFIX) +} + /// russh client config for exec relays. fn exec_ssh_client_config() -> russh::client::Config { russh::client::Config { @@ -1811,6 +2472,9 @@ async fn stream_exec_over_relay( stdin_payload: Vec, timeout_seconds: u32, request_tty: bool, + no_login_shell: bool, + cols: u32, + rows: u32, ) -> Result<(), Status> { let command_preview: String = command .chars() @@ -1835,6 +2499,8 @@ async fn stream_exec_over_relay( command, stdin_payload, request_tty, + no_login_shell, + (cols, rows), tx.clone(), ); @@ -1889,6 +2555,7 @@ async fn stream_interactive_exec_over_relay( command: &str, input_stream: tonic::Streaming, request_tty: bool, + no_login_shell: bool, timeout_seconds: u32, cols: u32, rows: u32, @@ -1915,6 +2582,7 @@ async fn stream_interactive_exec_over_relay( command, input_stream, request_tty, + no_login_shell, cols, rows, tx.clone(), @@ -1962,11 +2630,13 @@ async fn stream_interactive_exec_over_relay( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn run_interactive_exec_with_russh( local_proxy_port: u16, command: &str, mut input_stream: tonic::Streaming, request_tty: bool, + no_login_shell: bool, cols: u32, rows: u32, tx: mpsc::Sender>, @@ -1988,9 +2658,16 @@ async fn run_interactive_exec_with_russh( let stream = TcpStream::connect(("127.0.0.1", local_proxy_port)) .await .map_err(|e| Status::internal(format!("failed to connect to ssh proxy: {e}")))?; + // russh client end of the loopback exec bridge — disable Nagle so keystroke + // and PTY tinygrams don't stall on delayed ACKs. + set_tcp_nodelay_best_effort(&stream); let config = Arc::new(exec_ssh_client_config()); - let mut client = russh::client::connect_stream(config, stream, SandboxSshClientHandler) + let remote_sshid = Arc::new(std::sync::Mutex::new(None)); + let handler = SandboxSshClientHandler { + remote_sshid: remote_sshid.clone(), + }; + let mut client = russh::client::connect_stream(config, stream, handler) .await .map_err(|e| Status::internal(format!("failed to establish ssh transport: {e}")))?; @@ -2019,6 +2696,19 @@ async fn run_interactive_exec_with_russh( .map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?; } + if no_login_shell { + let banner = remote_sshid.lock().unwrap().clone().unwrap_or_default(); + if !supervisor_supports_no_login_shell(&banner) { + return Err(Status::failed_precondition( + "sandbox supervisor is too old to honor --no-login-shell; recreate the sandbox on a current gateway", + )); + } + channel + .set_env(false, NO_LOGIN_SHELL_ENV.0, NO_LOGIN_SHELL_ENV.1) + .await + .map_err(|e| Status::internal(format!("failed to set login-shell env: {e}")))?; + } + channel .exec(true, command.as_bytes()) .await @@ -2121,14 +2811,19 @@ async fn start_single_use_ssh_proxy_over_relay( warn!("SSH relay proxy: failed to accept local connection"); return; }; + // Loopback bridge for interactive SSH exec (keystrokes, line-buffered + // PTY output) — disable Nagle so tinygrams don't stall on delayed ACKs. + set_tcp_nodelay_best_effort(&client_conn); let _ = tokio::io::copy_bidirectional(&mut client_conn, &mut relay_stream).await; }); Ok((port, task)) } -#[derive(Debug, Clone, Copy)] -struct SandboxSshClientHandler; +#[derive(Debug, Clone)] +struct SandboxSshClientHandler { + remote_sshid: Arc>>>, +} impl russh::client::Handler for SandboxSshClientHandler { type Error = russh::Error; @@ -2139,6 +2834,16 @@ impl russh::client::Handler for SandboxSshClientHandler { ) -> Result { Ok(true) } + + async fn kex_done( + &mut self, + _shared_secret: Option<&[u8]>, + _names: &russh::Names, + session: &mut russh::client::Session, + ) -> Result<(), Self::Error> { + *self.remote_sshid.lock().unwrap() = Some(session.remote_sshid().to_vec()); + Ok(()) + } } async fn run_exec_with_russh( @@ -2146,8 +2851,12 @@ async fn run_exec_with_russh( command: &str, stdin_payload: Vec, request_tty: bool, + no_shell_login: bool, + pty_size: (u32, u32), tx: mpsc::Sender>, ) -> Result { + let (cols, rows) = pty_size; + // Defense-in-depth: validate command at the transport boundary. if command.as_bytes().contains(&0) { return Err(Status::invalid_argument( @@ -2163,9 +2872,16 @@ async fn run_exec_with_russh( let stream = TcpStream::connect(("127.0.0.1", local_proxy_port)) .await .map_err(|e| Status::internal(format!("failed to connect to ssh proxy: {e}")))?; + // russh client end of the loopback exec bridge — disable Nagle so keystroke + // and PTY tinygrams don't stall on delayed ACKs. + set_tcp_nodelay_best_effort(&stream); let config = Arc::new(exec_ssh_client_config()); - let mut client = russh::client::connect_stream(config, stream, SandboxSshClientHandler) + let remote_sshid = Arc::new(std::sync::Mutex::new(None)); + let handler = SandboxSshClientHandler { + remote_sshid: remote_sshid.clone(), + }; + let mut client = russh::client::connect_stream(config, stream, handler) .await .map_err(|e| Status::internal(format!("failed to establish ssh transport: {e}")))?; @@ -2189,11 +2905,24 @@ async fn run_exec_with_russh( if request_tty { channel - .request_pty(false, "xterm-256color", 0, 0, 0, 0, &[]) + .request_pty(false, "xterm-256color", cols, rows, 0, 0, &[]) .await .map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?; } + if no_shell_login { + let banner = remote_sshid.lock().unwrap().clone().unwrap_or_default(); + if !supervisor_supports_no_login_shell(&banner) { + return Err(Status::failed_precondition( + "sandbox supervisor is too old to honor --no-login-shell; recreate the sandbox on a current gateway", + )); + } + channel + .set_env(false, NO_LOGIN_SHELL_ENV.0, NO_LOGIN_SHELL_ENV.1) + .await + .map_err(|e| Status::internal(format!("failed to set login-shell env: {e}")))?; + } + channel .exec(true, command.as_bytes()) .await @@ -2275,31 +3004,124 @@ mod tests { use crate::grpc::test_support::{ authed_request, test_server_state, test_server_state_with_driver, }; + use crate::provider_profile_sources::ProviderProfileSources; + use openshell_core::GatewayProviderProfileSourceConfig; + use openshell_core::proto::GpuResourceRequirements; use openshell_core::proto::datamodel::v1::ObjectMeta; + async fn test_server_state_with_user_only_github_profile() -> Arc { + let mut state = test_server_state().await; + Arc::get_mut(&mut state) + .expect("test server state should be uniquely owned") + .provider_profile_sources = + ProviderProfileSources::from_config(&[GatewayProviderProfileSourceConfig::User], None) + .expect("user-only provider profile source configuration should be valid"); + + let github_profile = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == "github") + .expect("github builtin profile") + .to_proto(); + state + .store + .put_message(&crate::provider_profile_sources::stored_provider_profile( + github_profile, + )) + .await + .expect("store user-managed github profile"); + state + } + // ---- shell_escape ---- #[test] - fn telemetry_compute_driver_uses_resolved_driver_kind() { - assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Docker)), - TelemetryComputeDriver::Docker - ); - assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Kubernetes)), - TelemetryComputeDriver::Kubernetes - ); - assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Podman)), - TelemetryComputeDriver::Podman - ); + fn pty_dimensions_default_zero_values_without_overwriting_explicit_values() { + assert_eq!(pty_dimensions(0, 0), (80, 24)); + assert_eq!(pty_dimensions(120, 40), (120, 40)); + assert_eq!(pty_dimensions(0, 40), (80, 40)); + } + + #[test] + fn watch_terminal_phases_include_command_results_and_errors() { + for phase in [ + SandboxPhase::Ready, + SandboxPhase::Completed, + SandboxPhase::Stopped, + SandboxPhase::Error, + ] { + assert!(is_watch_terminal(phase), "{phase:?} should stop the watch"); + } + for phase in [ + SandboxPhase::Provisioning, + SandboxPhase::Starting, + SandboxPhase::Stopping, + SandboxPhase::Deleting, + SandboxPhase::Unknown, + ] { + assert!( + !is_watch_terminal(phase), + "{phase:?} should keep the watch open" + ); + } + } + + #[test] + fn sandbox_create_telemetry_uses_resolved_template_gpu_request() { + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + policy: Some(openshell_core::proto::SandboxPolicy::default()), + ..SandboxSpec::default() + }), + workload_template_name: "gpu-kata".to_string(), + ..CreateSandboxRequest::default() + }; + let created = Sandbox { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + policy: Some(openshell_core::proto::SandboxPolicy::default()), + resource_requirements: Some(ResourceRequirements { + gpu: Some(GpuResourceRequirements { count: Some(1) }), + }), + ..SandboxSpec::default() + }), + created_from_workload_template: Some(SandboxWorkloadTemplateProvenance { + name: "gpu-kata".to_string(), + resource_version: "7".to_string(), + }), + ..Sandbox::default() + }; + assert_eq!( - telemetry_compute_driver(Some(openshell_core::ComputeDriverKind::Vm)), - TelemetryComputeDriver::Vm + sandbox_create_telemetry_attrs(&request, Some(&created)), + SandboxCreateTelemetryAttrs { + requested_gpu: true, + provider_count: 1, + has_custom_policy: true, + template_source: SandboxTemplateSource::WorkloadTemplate, + } ); + } + + #[test] + fn sandbox_create_telemetry_falls_back_to_request_for_unresolved_template() { + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + ..SandboxSpec::default() + }), + workload_template_name: "missing-template".to_string(), + ..CreateSandboxRequest::default() + }; + assert_eq!( - telemetry_compute_driver(None), - TelemetryComputeDriver::Unknown + sandbox_create_telemetry_attrs(&request, None), + SandboxCreateTelemetryAttrs { + requested_gpu: false, + provider_count: 1, + has_custom_policy: false, + template_source: SandboxTemplateSource::WorkloadTemplate, + } ); } @@ -2596,6 +3418,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), } } @@ -2611,7 +3434,7 @@ mod tests { workspace: "default".to_string(), deletion_timestamp_ms: 0, }), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { log_level: "debug".to_string(), policy: Some(openshell_core::proto::SandboxPolicy::default()), providers, @@ -2624,7 +3447,43 @@ mod tests { sandbox } + fn test_workload_template(name: &str) -> SandboxWorkloadTemplate { + SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::from([("team".to_string(), "runtime".to_string())]), + resource_version: 0, + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, + }), + spec: Some(openshell_core::proto::SandboxWorkloadTemplateSpec { + workload: Some(openshell_core::proto::SandboxWorkloadConfig { + image: "registry.example.com/agent:latest".to_string(), + environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), + resources: Some(SandboxResources { + cpu: "2".to_string(), + memory: "4Gi".to_string(), + gpu: Some(GpuResourceRequirements { count: Some(1) }), + }), + }), + driver_config: None, + desired_service_level: None, + }), + } + } + + fn proto_string_value(value: &Value) -> Option<&str> { + match value.kind.as_ref() { + Some(Kind::StringValue(value)) => Some(value.as_str()), + _ => None, + } + } + #[tokio::test] + #[ignore = "flaky under concurrent test execution"] async fn watch_producer_releases_request_span_when_client_disconnects() { use crate::otel_tracing::test_exporter; use tokio_stream::StreamExt as _; @@ -2636,16 +3495,21 @@ mod tests { let traced = test_exporter::install_traced(); let request_span = tracing::info_span!("disconnected_watch_request"); - let response = handle_watch_sandbox( - &state, - authed_request(WatchSandboxRequest { - id: sandbox.object_id().to_string(), - ..Default::default() - }), - ) - .instrument(request_span.clone()) - .await - .unwrap(); + let mut handler = Box::pin( + handle_watch_sandbox( + &state, + authed_request(WatchSandboxRequest { + id: sandbox.object_id().to_string(), + ..Default::default() + }), + ) + .instrument(request_span.clone()), + ); + let response = handler.as_mut().await.unwrap(); + // A completed instrumented future can retain its span until the future + // itself is dropped. Release the handler's clone so this test isolates + // whether the spawned watch producer retains the request span. + drop(handler); let mut stream = response.into_inner(); stream .next() @@ -2685,7 +3549,7 @@ mod tests { .await }); tokio::time::timeout(std::time::Duration::from_secs(5), async { - while state.compute.delete_gate_entry_count() == 0 { + while state.compute.lifecycle_gate_entry_count() == 0 { tokio::time::sleep(std::time::Duration::from_millis(10)).await; } }) @@ -2759,6 +3623,41 @@ mod tests { assert_eq!(spec.log_level, "debug"); } + #[tokio::test] + async fn attach_sandbox_provider_uses_configured_provider_profile_sources() { + let state = test_server_state_with_user_only_github_profile().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox("work", Vec::new())) + .await + .unwrap(); + + let response = handle_attach_sandbox_provider( + &state, + authed_request(AttachSandboxProviderRequest { + sandbox_name: "work".to_string(), + provider_name: "work-github".to_string(), + expected_resource_version: 0, + workspace: String::new(), + }), + ) + .await + .expect("user-only profile catalog should not include the builtin github profile") + .into_inner(); + + assert!(response.attached); + let sandbox = response.sandbox.expect("updated sandbox"); + assert_eq!( + sandbox.spec.expect("sandbox spec").providers, + vec!["work-github"] + ); + } + #[tokio::test] async fn attach_sandbox_provider_is_idempotent_and_avoids_duplicates() { let state = test_server_state().await; @@ -2805,6 +3704,15 @@ mod tests { #[tokio::test] async fn detach_sandbox_provider_is_idempotent_and_removes_all_matches() { let state = test_server_state().await; + state + .store + .put_message(&test_provider_with_credential_key( + "other", + "github", + "GITHUB_TOKEN", + )) + .await + .unwrap(); state .store .put_message(&test_sandbox( @@ -2859,36 +3767,94 @@ mod tests { } #[tokio::test] - async fn list_sandbox_providers_returns_attached_provider_records() { + async fn detach_rejects_provider_referenced_by_policy_credential_binding() { let state = test_server_state().await; state .store - .put_message(&test_provider("work-github", "github")) + .put_message(&test_provider("work-gcp", "google-cloud")) .await .unwrap(); - state - .store - .put_message(&test_sandbox("work", vec!["work-github".to_string()])) - .await + + let mut sandbox = test_sandbox("work", vec!["work-gcp".to_string()]); + let policy = sandbox + .spec + .as_mut() + .and_then(|spec| spec.policy.as_mut()) .unwrap(); + policy.network_policies.insert( + "gcp_storage".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "gcp_storage".to_string(), + endpoints: vec![openshell_core::proto::NetworkEndpoint { + host: "storage.googleapis.com".to_string(), + port: 443, + credential_binding: Some(openshell_core::proto::NetworkCredentialBinding { + provider: "work-gcp".to_string(), + }), + ..Default::default() + }], + ..Default::default() + }, + ); + state.store.put_message(&sandbox).await.unwrap(); - let response = handle_list_sandbox_providers( + let error = handle_detach_sandbox_provider( &state, - authed_request(ListSandboxProvidersRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), + provider_name: "work-gcp".to_string(), + expected_resource_version: 0, workspace: String::new(), }), ) .await - .unwrap() - .into_inner(); + .expect_err("a referenced provider must remain attached"); - assert_eq!(response.providers.len(), 1); - assert_eq!(response.providers[0].r#type, "github"); - assert_eq!( - response.providers[0].credentials.get("TOKEN"), - Some(&"REDACTED".to_string()) - ); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("not attached")); + let providers = state + .store + .get_message_by_name::("default", "work") + .await + .unwrap() + .unwrap() + .spec + .unwrap() + .providers; + assert_eq!(providers, vec!["work-gcp"]); + } + + #[tokio::test] + async fn list_sandbox_providers_returns_attached_provider_records() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox("work", vec!["work-github".to_string()])) + .await + .unwrap(); + + let response = handle_list_sandbox_providers( + &state, + authed_request(ListSandboxProvidersRequest { + sandbox_name: "work".to_string(), + workspace: String::new(), + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.providers.len(), 1); + assert_eq!(response.providers[0].r#type, "github"); + assert_eq!( + response.providers[0].credentials.get("TOKEN"), + Some(&"REDACTED".to_string()) + ); } #[tokio::test] @@ -3075,13 +4041,15 @@ mod tests { &state, authed_request(CreateSandboxRequest { name: "collision".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { + spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], ..Default::default() }), labels: HashMap::new(), annotations: HashMap::new(), workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), }), ) .await @@ -3093,6 +4061,32 @@ mod tests { assert!(err.message().contains("provider-b")); } + #[tokio::test] + async fn create_sandbox_uses_configured_provider_profile_sources() { + let state = test_server_state_with_user_only_github_profile().await; + + let response = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "user-catalog".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + .expect("user-only profile catalog should not include the builtin github profile") + .into_inner(); + + assert_eq!( + response.sandbox.expect("created sandbox").object_name(), + "user-catalog" + ); + } + #[tokio::test] async fn create_sandbox_rejects_reserved_provider_policy_key() { let state = test_server_state().await; @@ -3105,250 +4099,1068 @@ mod tests { }, ); - let err = handle_create_sandbox( + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "reserved-policy-key".to_string(), + spec: Some(SandboxSpec { + policy: Some(policy), + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("_provider_work_github")); + assert!(err.message().contains("reserved '_provider_' prefix")); + } + + #[tokio::test] + async fn create_sandbox_persists_long_metadata_annotations() { + let state = test_server_state().await; + let annotation_key = "openshell.nvidia.com/policy-signature".to_string(); + let annotation_value = "x".repeat(512); + + let response = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "annotated".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + .expect("long annotations should be accepted") + .into_inner(); + + let created = response.sandbox.expect("created sandbox"); + assert_eq!( + created + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(&annotation_key)), + Some(&annotation_value) + ); + + let fetched = handle_get_sandbox( + &state, + authed_request(GetSandboxRequest { + name: "annotated".to_string(), + workspace: String::new(), + }), + ) + .await + .expect("created sandbox should be fetchable") + .into_inner() + .sandbox + .expect("fetched sandbox"); + assert_eq!( + fetched + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(&annotation_key)), + Some(&annotation_value) + ); + } + + #[tokio::test] + async fn create_and_get_preserve_partial_process_identity() { + let state = test_server_state_with_driver("docker").await; + let policy = openshell_core::proto::SandboxPolicy { + version: 1, + process: Some(openshell_core::proto::ProcessPolicy { + run_as_user: String::new(), + run_as_group: "1234".to_string(), + }), + ..Default::default() + }; + + let response = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "partial-id".to_string(), + spec: Some(SandboxSpec { + policy: Some(policy), + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + .expect("partial process identity should be accepted") + .into_inner(); + + let created_process = response + .sandbox + .unwrap() + .spec + .unwrap() + .policy + .unwrap() + .process + .unwrap(); + assert!(created_process.run_as_user.is_empty()); + assert_eq!(created_process.run_as_group, "1234"); + + let fetched_process = handle_get_sandbox( + &state, + authed_request(GetSandboxRequest { + name: "partial-id".to_string(), + workspace: String::new(), + }), + ) + .await + .unwrap() + .into_inner() + .sandbox + .unwrap() + .spec + .unwrap() + .policy + .unwrap() + .process + .unwrap(); + assert!(fetched_process.run_as_user.is_empty()); + assert_eq!(fetched_process.run_as_group, "1234"); + } + + #[tokio::test] + async fn create_and_get_preserve_partial_process_identity_for_kubernetes() { + let state = test_server_state_with_driver("kubernetes").await; + let policy = openshell_core::proto::SandboxPolicy { + version: 1, + process: Some(openshell_core::proto::ProcessPolicy { + run_as_user: String::new(), + run_as_group: "1234".to_string(), + }), + ..Default::default() + }; + + let response = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "kube-partial-id".to_string(), + spec: Some(SandboxSpec { + policy: Some(policy), + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + .expect("partial Kubernetes process identity should be accepted") + .into_inner(); + + let process = response + .sandbox + .unwrap() + .spec + .unwrap() + .policy + .unwrap() + .process + .unwrap(); + assert!(process.run_as_user.is_empty()); + assert_eq!(process.run_as_group, "1234"); + } + + #[tokio::test] + async fn create_sandbox_still_rejects_long_label_values() { + let state = test_server_state().await; + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "bad-label".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::from([("team".to_string(), "x".repeat(512))]), + annotations: HashMap::new(), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("label value exceeds")); + } + + #[tokio::test] + async fn create_sandbox_with_providers_waits_for_sandbox_sync_guard() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + + let guard = state.compute.sandbox_sync_guard().await; + let task_state = state.clone(); + let task = tokio::spawn(async move { + handle_create_sandbox( + &task_state, + authed_request(CreateSandboxRequest { + name: "guarded-create".to_string(), + spec: Some(SandboxSpec { + providers: vec!["work-github".to_string()], + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!( + !task.is_finished(), + "sandbox create with initial providers should wait for sandbox sync guard" + ); + drop(guard); + + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("create should finish after guard release") + .expect("join create task") + .expect("create should succeed") + .into_inner(); + assert_eq!( + response.sandbox.unwrap().spec.unwrap().providers, + vec!["work-github".to_string()] + ); + } + + #[tokio::test] + async fn sandbox_template_handlers_create_get_list_and_delete_workspace_resource() { + let state = test_server_state().await; + + let created = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("gpu-kata")), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed") + .into_inner() + .template + .expect("template response"); + + let metadata = created.metadata.as_ref().expect("metadata"); + assert_eq!(metadata.name, "gpu-kata"); + assert_eq!(metadata.workspace, "default"); + assert!(!metadata.id.is_empty()); + assert_ne!(metadata.resource_version, 0); + + let fetched = handle_get_sandbox_template( + &state, + authed_request(GetSandboxTemplateRequest { + name: "gpu-kata".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect("template get should succeed") + .into_inner() + .template + .expect("fetched template"); + assert_eq!(fetched.object_name(), "gpu-kata"); + assert_eq!(fetched.object_workspace(), "default"); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + label_selector: String::new(), + }), + ) + .await + .expect("template list should succeed") + .into_inner() + .templates; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].object_name(), "gpu-kata"); + + let deleted = handle_delete_sandbox_template( + &state, + authed_request(DeleteSandboxTemplateRequest { + name: "gpu-kata".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect("template delete should succeed") + .into_inner(); + assert!(deleted.deleted); + + let missing = handle_get_sandbox_template( + &state, + authed_request(GetSandboxTemplateRequest { + name: "gpu-kata".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("deleted template should not be fetchable"); + assert_eq!(missing.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn sandbox_template_list_filters_by_label_selector() { + let state = test_server_state().await; + + let mut gpu = test_workload_template("gpu-kata"); + gpu.metadata + .as_mut() + .expect("metadata") + .labels + .insert("team".to_string(), "runtime".to_string()); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(gpu), + workspace: "default".to_string(), + }), + ) + .await + .expect("gpu template create should succeed"); + + let mut cpu = test_workload_template("cpu-base"); + cpu.metadata + .as_mut() + .expect("metadata") + .labels + .insert("team".to_string(), "batch".to_string()); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(cpu), + workspace: "default".to_string(), + }), + ) + .await + .expect("cpu template create should succeed"); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + label_selector: "team=runtime".to_string(), + }), + ) + .await + .expect("template list with label selector should succeed") + .into_inner() + .templates; + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].object_name(), "gpu-kata"); + } + + #[tokio::test] + async fn sandbox_template_create_rejects_whitespace_name() { + let state = test_server_state().await; + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template(" gpu-kata ")), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("template names must be canonical DNS-1123 labels"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("template.metadata.name")); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + label_selector: String::new(), + }), + ) + .await + .expect("template list should succeed") + .into_inner() + .templates; + assert!(listed.is_empty()); + } + + #[tokio::test] + async fn sandbox_template_create_empty_workspace_ignores_metadata_workspace() { + use openshell_core::proto::CreateWorkspaceRequest; + + let state = test_server_state().await; + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .expect("beta workspace should be created"); + + let mut template = test_workload_template("copied-template"); + template.metadata.as_mut().unwrap().workspace = "beta".to_string(); + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: String::new(), + }), + ) + .await + .expect_err("empty request workspace must default to default, not metadata workspace"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("template.metadata.workspace")); + + let listed = handle_list_sandbox_templates( + &state, + authed_request(ListSandboxTemplatesRequest { + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + label_selector: String::new(), + }), + ) + .await + .expect("template list should succeed") + .into_inner() + .templates; + assert!(listed.is_empty()); + } + + #[tokio::test] + async fn sandbox_template_create_rejects_missing_spec_as_invalid_argument() { + let state = test_server_state().await; + let mut template = test_workload_template("missing-spec"); + template.spec = None; + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("template create should reject missing spec"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("sandbox template spec")); + } + + #[test] + fn sandbox_template_validation_allows_positive_ready_within() { + let mut template = test_workload_template("gpu-kata"); + template.metadata.as_mut().unwrap().id = "template-gpu-kata".to_string(); + template.spec.as_mut().unwrap().desired_service_level = + Some(openshell_core::proto::SandboxServiceLevel { + startup: Some(openshell_core::proto::SandboxStartup { + ready_within: Some(prost_types::Duration { + seconds: 1, + nanos: 0, + }), + max_burst: 1, + }), + }); + + validate_sandbox_workload_template(&template).expect("positive ready_within should pass"); + } + + #[test] + fn sandbox_template_validation_rejects_non_positive_ready_within() { + for (duration, expected) in [ + ( + prost_types::Duration { + seconds: 0, + nanos: 0, + }, + "greater than zero", + ), + ( + prost_types::Duration { + seconds: -1, + nanos: 0, + }, + "greater than zero", + ), + ( + prost_types::Duration { + seconds: 0, + nanos: -1, + }, + "greater than zero", + ), + ] { + let mut template = test_workload_template("gpu-kata"); + template.metadata.as_mut().unwrap().id = "template-gpu-kata".to_string(); + template.spec.as_mut().unwrap().desired_service_level = + Some(openshell_core::proto::SandboxServiceLevel { + startup: Some(openshell_core::proto::SandboxStartup { + ready_within: Some(duration), + max_burst: 1, + }), + }); + + let err = validate_sandbox_workload_template(&template) + .expect_err("non-positive ready_within should be rejected"); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains(expected), "{err:?}"); + assert!( + err.message() + .contains("template.spec.desired_service_level.startup.ready_within"), + "{err:?}" + ); + } + } + + #[test] + fn sandbox_template_validation_rejects_malformed_ready_within() { + for (duration, expected) in [ + ( + prost_types::Duration { + seconds: 1, + nanos: -1, + }, + "normalized", + ), + ( + prost_types::Duration { + seconds: 0, + nanos: 1_000_000_000, + }, + "valid protobuf Duration", + ), + ( + prost_types::Duration { + seconds: 315_576_000_001, + nanos: 0, + }, + "valid protobuf Duration", + ), + ] { + let mut template = test_workload_template("gpu-kata"); + template.metadata.as_mut().unwrap().id = "template-gpu-kata".to_string(); + template.spec.as_mut().unwrap().desired_service_level = + Some(openshell_core::proto::SandboxServiceLevel { + startup: Some(openshell_core::proto::SandboxStartup { + ready_within: Some(duration), + max_burst: 1, + }), + }); + + let err = validate_sandbox_workload_template(&template) + .expect_err("malformed ready_within should be rejected"); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains(expected), "{err:?}"); + assert!( + err.message() + .contains("template.spec.desired_service_level.startup.ready_within"), + "{err:?}" + ); + } + } + + #[tokio::test] + async fn sandbox_template_create_rejects_workspace_quota() { + let state = test_server_state().await; + for index in 0..MAX_TEMPLATES_PER_WORKSPACE { + let mut template = test_workload_template(&format!("tmpl-{index}")); + let metadata = template.metadata.as_mut().expect("metadata"); + metadata.id = format!("template-{index}"); + metadata.workspace = "default".to_string(); + state.store.put_message(&template).await.unwrap(); + } + + let err = handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("overflow")), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("template create must reject a full workspace"); + + assert_eq!(err.code(), tonic::Code::ResourceExhausted); + assert!(err.message().contains("1000 sandbox templates")); + } + + #[tokio::test] + async fn sandbox_template_create_enforces_workspace_quota_concurrently() { + let state = test_server_state().await; + for index in 0..(MAX_TEMPLATES_PER_WORKSPACE - 1) { + let mut template = test_workload_template(&format!("tmpl-{index}")); + let metadata = template.metadata.as_mut().expect("metadata"); + metadata.id = format!("template-{index}"); + metadata.workspace = "default".to_string(); + state.store.put_message(&template).await.unwrap(); + } + + let mut handles = vec![]; + for index in 0..8 { + let state = Arc::clone(&state); + let handle = tokio::spawn(async move { + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template(&format!("overflow-{index}"))), + workspace: "default".to_string(), + }), + ) + .await + }); + handles.push(handle); + } + + let results: Vec<_> = future::join_all(handles) + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + let successes = results.iter().filter(|r| r.is_ok()).count(); + let exhausted = results + .iter() + .filter(|r| { + r.as_ref() + .err() + .is_some_and(|e| e.code() == tonic::Code::ResourceExhausted) + }) + .count(); + + assert_eq!(successes, 1); + assert_eq!(exhausted, 7); + let count = state + .store + .count_in_workspace(SandboxWorkloadTemplate::object_type(), "default") + .await + .unwrap(); + assert_eq!(count, u64::from(MAX_TEMPLATES_PER_WORKSPACE)); + } + + #[test] + fn template_create_sandbox_spec_field_policy_is_exhaustive() { + assert_proto_fields_classified( + "openshell.v1.SandboxSpec", + &["policy", "providers", "command", "tty"], + &[ + "log_level", + "environment", + "template", + "resource_requirements", + ], + ); + } + + fn assert_proto_fields_classified( + message_name: &str, + copied_from_create_request: &[&str], + rejected_template_workload_overrides: &[&str], + ) { + let pool = prost_reflect::DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor set"); + let message = pool + .get_message_by_name(message_name) + .expect("message descriptor"); + let classified: std::collections::HashSet<&str> = copied_from_create_request + .iter() + .chain(rejected_template_workload_overrides.iter()) + .copied() + .collect(); + let actual: std::collections::HashSet = message + .fields() + .map(|field| field.name().to_string()) + .collect(); + + for field in &actual { + assert!( + classified.contains(field.as_str()), + "{message_name}.{field} is not classified for template-backed sandbox creates. \ + Add it to copied_from_create_request when callers own the create-time value, \ + or to rejected_template_workload_overrides when the workload template owns it." + ); + } + + for field in classified { + assert!( + actual.contains(field), + "{message_name}.{field} is classified for template-backed sandbox creates, \ + but the proto field no longer exists" + ); + } + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_resolves_workload_and_preserves_governance() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("gpu-kata")), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed"); + + let mut policy = openshell_core::proto::SandboxPolicy { + version: 1, + ..Default::default() + }; + policy.network_policies.insert( + "example".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "example".to_string(), + ..Default::default() + }, + ); + + let created = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "from-template".to_string(), + spec: Some(SandboxSpec { + providers: vec!["work-github".to_string()], + policy: Some(policy), + command: vec!["echo".to_string(), "template-create".to_string()], + tty: false, + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "gpu-kata".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect("sandbox create from template should succeed") + .into_inner() + .sandbox + .expect("created sandbox"); + + let provenance = created + .created_from_workload_template + .expect("template provenance"); + assert_eq!(provenance.name, "gpu-kata"); + assert!(!provenance.resource_version.is_empty()); + + let spec = created.spec.expect("resolved sandbox spec"); + assert_eq!(spec.providers, vec!["work-github".to_string()]); + assert!(spec.policy.is_some()); + assert_eq!( + spec.command, + vec!["echo".to_string(), "template-create".to_string()] + ); + assert!(!spec.tty); + assert_eq!( + spec.environment.get("FEATURE_FLAG"), + Some(&"on".to_string()) + ); + + let template = spec.template.expect("resolved inline template"); + assert_eq!(template.image, "registry.example.com/agent:latest"); + let limits = template + .resources + .as_ref() + .and_then(|resources| resources.fields.get("limits")) + .and_then(|limits| limits.kind.as_ref()) + .and_then(|kind| match kind { + Kind::StructValue(value) => Some(&value.fields), + _ => None, + }) + .expect("resource limits"); + assert_eq!(limits.get("cpu").and_then(proto_string_value), Some("2")); + assert_eq!( + limits.get("memory").and_then(proto_string_value), + Some("4Gi") + ); + assert_eq!( + spec.resource_requirements + .and_then(|requirements| requirements.gpu) + .and_then(|gpu| gpu.count), + Some(1) + ); + } + + #[tokio::test] + async fn create_sandbox_from_workload_template_defaults_whitespace_image() { + let state = test_server_state().await; + let mut template = test_workload_template("default-image"); + template + .spec + .as_mut() + .and_then(|spec| spec.workload.as_mut()) + .expect("test template workload") + .image = " ".to_string(); + handle_create_sandbox_template( + &state, + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: "default".to_string(), + }), + ) + .await + .expect("template create should succeed"); + + let created = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { - name: "reserved-policy-key".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { - policy: Some(policy), - ..Default::default() - }), + name: "from-template".to_string(), + spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace: "default".to_string(), + workload_template_name: "default-image".to_string(), + await_main_process_attachment: false, }), ) .await - .unwrap_err(); + .expect("sandbox create from template should succeed") + .into_inner() + .sandbox + .expect("created sandbox"); - assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert!(err.message().contains("_provider_work_github")); - assert!(err.message().contains("reserved '_provider_' prefix")); + let image = created + .spec + .and_then(|spec| spec.template) + .map(|template| template.image) + .expect("resolved template image"); + assert_eq!(image, state.compute.default_image()); } #[tokio::test] - async fn create_sandbox_persists_long_metadata_annotations() { + async fn create_sandbox_from_workload_template_preserves_default_gpu_request() { let state = test_server_state().await; - let annotation_key = "openshell.nvidia.com/policy-signature".to_string(); - let annotation_value = "x".repeat(512); - - let response = handle_create_sandbox( + let mut template = test_workload_template("default-gpu"); + template + .spec + .as_mut() + .and_then(|spec| spec.workload.as_mut()) + .and_then(|workload| workload.resources.as_mut()) + .expect("test template resources") + .gpu = Some(GpuResourceRequirements { count: None }); + handle_create_sandbox_template( &state, - authed_request(CreateSandboxRequest { - name: "annotated".to_string(), - spec: Some(openshell_core::proto::SandboxSpec::default()), - labels: HashMap::new(), - annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), - workspace: String::new(), + authed_request(CreateSandboxTemplateRequest { + template: Some(template), + workspace: "default".to_string(), }), ) .await - .expect("long annotations should be accepted") - .into_inner(); - - let created = response.sandbox.expect("created sandbox"); - assert_eq!( - created - .metadata - .as_ref() - .and_then(|metadata| metadata.annotations.get(&annotation_key)), - Some(&annotation_value) - ); + .expect("template create should succeed"); - let fetched = handle_get_sandbox( + let created = handle_create_sandbox( &state, - authed_request(GetSandboxRequest { - name: "annotated".to_string(), - workspace: String::new(), + authed_request(CreateSandboxRequest { + name: "from-template".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "default-gpu".to_string(), + await_main_process_attachment: false, }), ) .await - .expect("created sandbox should be fetchable") + .expect("sandbox create from template should succeed") .into_inner() .sandbox - .expect("fetched sandbox"); - assert_eq!( - fetched - .metadata - .as_ref() - .and_then(|metadata| metadata.annotations.get(&annotation_key)), - Some(&annotation_value) - ); + .expect("created sandbox"); + + let gpu = created + .spec + .as_ref() + .and_then(|spec| spec.resource_requirements.as_ref()) + .and_then(|requirements| requirements.gpu.as_ref()) + .expect("default GPU request should be preserved"); + assert_eq!(gpu.count, None); } #[tokio::test] - async fn create_and_get_preserve_partial_process_identity() { - let state = - test_server_state_with_driver(openshell_core::ComputeDriverKind::Docker.as_str()).await; - let policy = openshell_core::proto::SandboxPolicy { - version: 1, - process: Some(openshell_core::proto::ProcessPolicy { - run_as_user: String::new(), - run_as_group: "1234".to_string(), - }), - ..Default::default() - }; + async fn create_sandbox_from_corrupted_workload_template_returns_internal() { + let state = test_server_state().await; + let mut template = test_workload_template("corrupt-template"); + let metadata = template.metadata.as_mut().expect("metadata"); + metadata.id = "template-corrupt-template".to_string(); + metadata.workspace = "default".to_string(); + template.spec = None; + state.store.put_message(&template).await.unwrap(); - let response = handle_create_sandbox( + let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { - name: "partial-id".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { - policy: Some(policy), - ..Default::default() - }), + name: "from-corrupt".to_string(), + spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace: "default".to_string(), + workload_template_name: "corrupt-template".to_string(), + await_main_process_attachment: false, }), ) .await - .expect("partial process identity should be accepted") - .into_inner(); + .expect_err("corrupted stored template should fail as server data corruption"); - let created_process = response - .sandbox - .unwrap() - .spec - .unwrap() - .policy - .unwrap() - .process - .unwrap(); - assert!(created_process.run_as_user.is_empty()); - assert_eq!(created_process.run_as_group, "1234"); + assert_eq!(err.code(), tonic::Code::Internal, "{}", err.message()); + assert!(err.message().contains("sandbox template spec")); + } - let fetched_process = handle_get_sandbox( + #[tokio::test] + async fn create_sandbox_from_workload_template_rejects_inline_workload_overrides() { + let state = test_server_state().await; + handle_create_sandbox_template( &state, - authed_request(GetSandboxRequest { - name: "partial-id".to_string(), - workspace: String::new(), + authed_request(CreateSandboxTemplateRequest { + template: Some(test_workload_template("gpu-kata")), + workspace: "default".to_string(), }), ) .await - .unwrap() - .into_inner() - .sandbox - .unwrap() - .spec - .unwrap() - .policy - .unwrap() - .process - .unwrap(); - assert!(fetched_process.run_as_user.is_empty()); - assert_eq!(fetched_process.run_as_group, "1234"); - } - - #[tokio::test] - async fn create_and_get_restore_legacy_identity_defaults_for_non_local_driver() { - let state = - test_server_state_with_driver(openshell_core::ComputeDriverKind::Kubernetes.as_str()) - .await; - let policy = openshell_core::proto::SandboxPolicy { - version: 1, - process: Some(openshell_core::proto::ProcessPolicy { - run_as_user: String::new(), - run_as_group: "1234".to_string(), - }), - ..Default::default() - }; + .expect("template create should succeed"); - let response = handle_create_sandbox( + let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { - name: "kube-partial-id".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { - policy: Some(policy), + name: "bad-template-create".to_string(), + spec: Some(SandboxSpec { + environment: HashMap::from([("INLINE".to_string(), "blocked".to_string())]), ..Default::default() }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace: "default".to_string(), + workload_template_name: "gpu-kata".to_string(), + await_main_process_attachment: false, }), ) .await - .expect("Kubernetes identity defaults should be accepted") - .into_inner(); + .expect_err("inline workload overrides should be rejected"); - let process = response - .sandbox - .unwrap() - .spec - .unwrap() - .policy - .unwrap() - .process - .unwrap(); - assert_eq!(process.run_as_user, "sandbox"); - assert_eq!(process.run_as_group, "1234"); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("spec.environment")); } #[tokio::test] - async fn create_sandbox_still_rejects_long_label_values() { + async fn create_sandbox_from_workload_template_rejects_malformed_template_name() { let state = test_server_state().await; + let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { - name: "bad-label".to_string(), - spec: Some(openshell_core::proto::SandboxSpec::default()), - labels: HashMap::from([("team".to_string(), "x".repeat(512))]), + name: "bad-template-create".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace: "default".to_string(), + workload_template_name: "Invalid_Template_Name".to_string(), + await_main_process_attachment: false, }), ) .await - .unwrap_err(); + .expect_err("malformed template name should be rejected before lookup"); assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert!(err.message().contains("label value exceeds")); + assert!(err.message().contains("workload_template_name")); } #[tokio::test] - async fn create_sandbox_with_providers_waits_for_sandbox_sync_guard() { + async fn create_sandbox_from_workload_template_rejects_oversized_governance_before_lookup() { let state = test_server_state().await; - state - .store - .put_message(&test_provider("work-github", "github")) - .await - .unwrap(); - let guard = state.compute.sandbox_sync_guard().await; - let task_state = state.clone(); - let task = tokio::spawn(async move { - handle_create_sandbox( - &task_state, - authed_request(CreateSandboxRequest { - name: "guarded-create".to_string(), - spec: Some(openshell_core::proto::SandboxSpec { - providers: vec!["work-github".to_string()], - ..Default::default() - }), - labels: HashMap::new(), - annotations: HashMap::new(), - workspace: String::new(), + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "bad-template-create".to_string(), + spec: Some(SandboxSpec { + providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), + ..Default::default() }), - ) - .await - }); + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "default".to_string(), + workload_template_name: "missing-template".to_string(), + await_main_process_attachment: false, + }), + ) + .await + .expect_err("oversized governance spec should be rejected before template lookup"); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - assert!( - !task.is_finished(), - "sandbox create with initial providers should wait for sandbox sync guard" - ); - drop(guard); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("providers")); + } - let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) - .await - .expect("create should finish after guard release") - .expect("join create task") - .expect("create should succeed") - .into_inner(); - assert_eq!( - response.sandbox.unwrap().spec.unwrap().providers, - vec!["work-github".to_string()] - ); + #[tokio::test] + async fn create_sandbox_rejects_oversized_direct_spec_before_workspace_lookup() { + let state = test_server_state().await; + + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "bad-direct-create".to_string(), + spec: Some(SandboxSpec { + providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), + ..Default::default() + }), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace: "missing-workspace".to_string(), + workload_template_name: String::new(), + await_main_process_attachment: false, + }), + ) + .await + .expect_err("oversized direct spec should be rejected before workspace lookup"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("providers")); } #[tokio::test] @@ -3623,6 +5435,40 @@ mod tests { assert!(session2.is_some()); } + #[tokio::test] + async fn create_ssh_session_allows_terminal_sandbox_while_supervisor_is_reachable() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("work", Vec::new()); + sandbox.set_phase(SandboxPhase::Completed as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let (tx, _rx) = mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = oneshot::channel(); + let _ = state.supervisor_sessions.register( + "sandbox-work".to_string(), + "session-1".to_string(), + tx, + shutdown_tx, + ); + + let response = handle_create_ssh_session( + &state, + authed_request(CreateSshSessionRequest { + sandbox_id: "sandbox-work".to_string(), + }), + ) + .await; + + assert!(response.is_ok()); + + assert!( + state + .supervisor_sessions + .finalize_main_process_exit("sandbox-work") + ); + assert!(!sandbox_relay_reachable(&state, &sandbox)); + } + #[tokio::test] async fn concurrent_revoke_ssh_session_handles_cas_properly() { let state = test_server_state().await; @@ -4219,7 +6065,7 @@ mod tests { &state, non_member_request(CreateSandboxRequest { workspace: "no-such-ws".into(), - spec: Some(openshell_core::proto::SandboxSpec::default()), + spec: Some(SandboxSpec::default()), ..Default::default() }), ) @@ -4328,6 +6174,31 @@ mod tests { Code::PermissionDenied, "handle_delete_sandbox should reject non-members with PermissionDenied" ); + + for result in [ + handle_stop_sandbox( + &state, + non_member_request(StopSandboxRequest { + workspace: "no-such-ws".into(), + name: "any".into(), + }), + ) + .await, + handle_start_sandbox( + &state, + non_member_request(StartSandboxRequest { + workspace: "no-such-ws".into(), + name: "any".into(), + }), + ) + .await, + ] { + assert_eq!( + result.unwrap_err().code(), + Code::PermissionDenied, + "lifecycle handlers should reject non-members" + ); + } } /// ID-based data-plane handlers must return `NOT_FOUND` — never @@ -4429,4 +6300,42 @@ mod tests { assert!(session.revoked); assert_eq!(session.object_workspace(), "default"); } + + // ---- supervisor_supports_no_login_shell ---- + + /// A current supervisor identifies itself with the `OpenShell` banner, so the + /// gateway may forward the login-shell opt-out. + #[test] + fn no_login_shell_gate_accepts_openshell_banner() { + assert!(supervisor_supports_no_login_shell( + b"SSH-2.0-OpenShell_0.0.4-dev.3+g2bf9969" + )); + assert!(supervisor_supports_no_login_shell( + b"SSH-2.0-OpenShell_0.1.0" + )); + } + + /// A supervisor predating this feature presents russh's default banner and + /// silently ignores the env request, so the gate must reject the opt-out. + #[test] + fn no_login_shell_gate_rejects_pre_feature_banner() { + assert!(!supervisor_supports_no_login_shell(b"SSH-2.0-Russh_0.62.5")); + assert!(!supervisor_supports_no_login_shell(b"SSH-2.0-OpenSSH_9.6")); + } + + /// A missing banner (kex callback never populated the slot) must fail + /// closed rather than forwarding the opt-out to an unknown supervisor. + #[test] + fn no_login_shell_gate_rejects_empty_banner() { + assert!(!supervisor_supports_no_login_shell(b"")); + } + + /// The banner prefix must match at the start; an `OpenShell` token appearing + /// only in the comment tail does not signal support. + #[test] + fn no_login_shell_gate_requires_prefix_position() { + assert!(!supervisor_supports_no_login_shell( + b"SSH-2.0-Russh_0.62.5 OpenShell_0.1.0" + )); + } } diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 1f1dfd257e..c20dad7780 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -8,9 +8,9 @@ #![allow(clippy::result_large_err)] // Validation returns Result<_, Status> -use openshell_core::ComputeDriverKind; use openshell_core::proto::{ - ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate, + CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, + SandboxSpec, SandboxTemplate, }; use prost::Message; use tonic::Status; @@ -27,29 +27,16 @@ use super::{ // Exec request validation // --------------------------------------------------------------------------- -/// Preserve process-identity omission only for the local OCI-aware drivers. -/// -/// Kubernetes, VM, and unknown/remote drivers retain the legacy persisted -/// `sandbox:sandbox` defaults so existing policy hashes and live-update -/// workflows do not change. -pub(super) fn normalize_process_identity_for_driver( - policy: &mut ProtoSandboxPolicy, - driver_kind: Option, -) { - if !matches!( - driver_kind, - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman) - ) { - openshell_policy::ensure_sandbox_process_identity(policy); - } -} - /// Maximum number of arguments in the command array. pub(super) const MAX_EXEC_COMMAND_ARGS: usize = 1024; /// Maximum length of a single command argument or environment value (bytes). pub(super) const MAX_EXEC_ARG_LEN: usize = 32 * 1024; // 32 KiB /// Maximum length of the workdir field (bytes). pub(super) const MAX_EXEC_WORKDIR_LEN: usize = 4096; +/// Maximum number of entries in the canonical main-process argv. +pub(super) const MAX_MAIN_PROCESS_ARGS: usize = 256; +/// Maximum aggregate byte size of the canonical main-process argv. +pub(super) const MAX_MAIN_PROCESS_ARGV_SIZE: usize = 256 * 1024; /// Validate exec request size limits and field-specific character constraints. /// @@ -163,26 +150,12 @@ pub(super) fn validate_dns1123_label(name: &str, field: &str) -> Result<(), Stat /// Validate field sizes on a `CreateSandboxRequest` before persisting. /// /// Returns `INVALID_ARGUMENT` on the first field that exceeds its limit. -pub(super) fn validate_sandbox_spec( - name: &str, - spec: &openshell_core::proto::SandboxSpec, -) -> Result<(), Status> { +pub(super) fn validate_sandbox_spec(name: &str, spec: &SandboxSpec) -> Result<(), Status> { // --- request.name --- - if !name.is_empty() && name.len() > MAX_ROUTABLE_NAME_LEN { - return Err(Status::invalid_argument(format!( - "name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})", - name.len() - ))); - } - validate_dns1123_label(name, "name")?; + validate_sandbox_name(name)?; // --- spec.providers --- - if spec.providers.len() > MAX_PROVIDERS { - return Err(Status::invalid_argument(format!( - "providers list exceeds maximum ({} > {MAX_PROVIDERS})", - spec.providers.len() - ))); - } + validate_sandbox_provider_count(spec)?; // --- spec.log_level --- if spec.log_level.len() > MAX_LOG_LEVEL_LEN { @@ -211,7 +184,50 @@ pub(super) fn validate_sandbox_spec( // --- spec.resource_requirements.gpu --- validate_gpu_request_fields(spec)?; + if !spec.command.is_empty() { + validate_main_process_command(&spec.command)?; + } + // --- spec.policy serialized size --- + validate_sandbox_policy_size(spec)?; + + Ok(()) +} + +pub(super) fn validate_sandbox_governance_spec( + name: &str, + spec: &SandboxSpec, +) -> Result<(), Status> { + validate_sandbox_name(name)?; + validate_sandbox_provider_count(spec)?; + if !spec.command.is_empty() { + validate_main_process_command(&spec.command)?; + } + validate_sandbox_policy_size(spec)?; + Ok(()) +} + +fn validate_sandbox_name(name: &str) -> Result<(), Status> { + if !name.is_empty() && name.len() > MAX_ROUTABLE_NAME_LEN { + return Err(Status::invalid_argument(format!( + "name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})", + name.len() + ))); + } + validate_dns1123_label(name, "name") +} + +fn validate_sandbox_provider_count(spec: &SandboxSpec) -> Result<(), Status> { + if spec.providers.len() > MAX_PROVIDERS { + return Err(Status::invalid_argument(format!( + "providers list exceeds maximum ({} > {MAX_PROVIDERS})", + spec.providers.len() + ))); + } + Ok(()) +} + +fn validate_sandbox_policy_size(spec: &SandboxSpec) -> Result<(), Status> { if let Some(ref policy) = spec.policy { let size = policy.encoded_len(); if size > MAX_POLICY_SIZE { @@ -224,7 +240,36 @@ pub(super) fn validate_sandbox_spec( Ok(()) } -fn validate_gpu_request_fields(spec: &openshell_core::proto::SandboxSpec) -> Result<(), Status> { +fn validate_main_process_command(command: &[String]) -> Result<(), Status> { + if command.len() > MAX_MAIN_PROCESS_ARGS { + return Err(Status::invalid_argument(format!( + "spec.command exceeds {MAX_MAIN_PROCESS_ARGS} argument limit" + ))); + } + if command[0].is_empty() { + return Err(Status::invalid_argument( + "spec.command[0] must not be empty", + )); + } + let argv_size: usize = command.iter().map(String::len).sum(); + if argv_size > MAX_MAIN_PROCESS_ARGV_SIZE { + return Err(Status::invalid_argument(format!( + "spec.command total size exceeds {MAX_MAIN_PROCESS_ARGV_SIZE} byte limit" + ))); + } + for (index, argument) in command.iter().enumerate() { + if argument.len() > MAX_EXEC_ARG_LEN { + return Err(Status::invalid_argument(format!( + "spec.command[{index}] exceeds {MAX_EXEC_ARG_LEN} byte limit" + ))); + } + reject_null_char(argument, &format!("spec.command[{index}]"))?; + } + + Ok(()) +} + +fn validate_gpu_request_fields(spec: &SandboxSpec) -> Result<(), Status> { if openshell_core::gpu::sandbox_gpu_count(spec.resource_requirements.as_ref()) == Some(0) { return Err(Status::invalid_argument("gpu count must be greater than 0")); } @@ -432,6 +477,8 @@ pub(super) fn validate_provider_mutable_fields(provider: &Provider) -> Result<() MAX_MAP_VALUE_LEN, "provider.credentials", )?; + validate_provider_credential_handles(&provider.credential_handles)?; + validate_provider_credential_sources(provider)?; validate_string_map( &provider.config, MAX_PROVIDER_CONFIG_ENTRIES, @@ -461,6 +508,99 @@ pub(super) fn validate_provider_mutable_fields(provider: &Provider) -> Result<() Ok(()) } +fn validate_provider_credential_sources(provider: &Provider) -> Result<(), Status> { + let total_credentials = provider.credentials.len() + provider.credential_handles.len(); + if total_credentials > MAX_PROVIDER_CREDENTIALS_ENTRIES { + return Err(Status::invalid_argument(format!( + "provider credential sources exceed maximum entries ({total_credentials} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})" + ))); + } + + for key in provider.credential_handles.keys() { + if provider.credentials.contains_key(key) { + return Err(Status::invalid_argument(format!( + "provider credential key '{key}' cannot be present in both provider.credentials and provider.credential_handles" + ))); + } + } + Ok(()) +} + +fn validate_provider_credential_handles( + credential_handles: &std::collections::HashMap, +) -> Result<(), Status> { + if credential_handles.len() > MAX_PROVIDER_CREDENTIALS_ENTRIES { + return Err(Status::invalid_argument(format!( + "provider.credential_handles exceeds maximum entries ({} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})", + credential_handles.len() + ))); + } + + for (credential_key, handle) in credential_handles { + if credential_key.len() > MAX_MAP_KEY_LEN { + return Err(Status::invalid_argument(format!( + "provider.credential_handles key exceeds maximum length ({} > {MAX_MAP_KEY_LEN})", + credential_key.len() + ))); + } + if !super::provider::is_valid_env_key(credential_key) { + return Err(Status::invalid_argument(format!( + "provider.credential_handles keys must match ^[A-Za-z_][A-Za-z0-9_]*$; got '{credential_key}'" + ))); + } + validate_credential_handle( + handle, + &format!("provider.credential_handles['{credential_key}']"), + )?; + } + + Ok(()) +} + +fn validate_credential_handle(handle: &CredentialHandle, field_name: &str) -> Result<(), Status> { + validate_required_credential_handle_string(&handle.driver, field_name, "driver")?; + validate_required_credential_handle_string(&handle.handle, field_name, "handle")?; + validate_string_map( + &handle.metadata, + MAX_PROVIDER_CONFIG_ENTRIES, + MAX_MAP_KEY_LEN, + MAX_MAP_VALUE_LEN, + &format!("{field_name}.metadata"), + )?; + for (key, value) in &handle.metadata { + reject_control_chars(key, &format!("{field_name}.metadata key"))?; + reject_control_chars(value, &format!("{field_name}.metadata value for '{key}'"))?; + } + Ok(()) +} + +fn validate_required_credential_handle_string( + value: &str, + field_name: &str, + component: &str, +) -> Result<(), Status> { + if value.trim().is_empty() { + return Err(Status::invalid_argument(format!( + "{field_name}.{component} is required" + ))); + } + validate_optional_credential_handle_string(value, field_name, component) +} + +fn validate_optional_credential_handle_string( + value: &str, + field_name: &str, + component: &str, +) -> Result<(), Status> { + if value.len() > MAX_MAP_VALUE_LEN { + return Err(Status::invalid_argument(format!( + "{field_name}.{component} exceeds maximum length ({} > {MAX_MAP_VALUE_LEN})", + value.len() + ))); + } + reject_control_chars(value, &format!("{field_name}.{component}")) +} + // --------------------------------------------------------------------------- // Label selector validation // --------------------------------------------------------------------------- @@ -924,6 +1064,16 @@ mod tests { assert!(validate_sandbox_spec("", &default_spec()).is_ok()); } + #[test] + fn validate_sandbox_spec_accepts_exact_main_process_argv() { + let spec = SandboxSpec { + command: vec!["/bin/sh".into(), "-c".into(), "printf 'a b'".into()], + tty: false, + ..Default::default() + }; + validate_sandbox_spec("", &spec).unwrap(); + } + #[test] fn validate_sandbox_spec_accepts_at_limit_name() { let name = "a".repeat(MAX_ROUTABLE_NAME_LEN); @@ -1257,6 +1407,18 @@ mod tests { std::iter::once(("KEY".to_string(), "val".to_string())).collect() } + fn one_credential_handle() -> HashMap { + std::iter::once(( + "API_KEY".to_string(), + CredentialHandle { + driver: "kubernetes-secrets".to_string(), + handle: "v1:openshell:provider-secret".to_string(), + metadata: HashMap::new(), + }, + )) + .collect() + } + fn make_test_provider( name: &str, provider_type: &str, @@ -1279,6 +1441,7 @@ mod tests { config, credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), } } @@ -1293,6 +1456,72 @@ mod tests { assert!(validate_provider_fields(&provider).is_ok()); } + #[test] + fn validate_provider_fields_accepts_credential_handles() { + let mut provider = + make_test_provider("my-provider", "claude", HashMap::new(), HashMap::new()); + provider.credential_handles = one_credential_handle(); + + assert!(validate_provider_fields(&provider).is_ok()); + } + + #[test] + fn validate_provider_fields_rejects_duplicate_inline_and_referenced_key() { + let mut provider = make_test_provider( + "my-provider", + "claude", + std::iter::once(("API_KEY".to_string(), "inline".to_string())).collect(), + HashMap::new(), + ); + provider.credential_handles = one_credential_handle(); + + let err = validate_provider_fields(&provider).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("provider.credentials")); + assert!(err.message().contains("provider.credential_handles")); + } + + #[test] + fn validate_provider_fields_rejects_too_many_combined_credential_sources() { + let refs: HashMap = (0..MAX_PROVIDER_CREDENTIALS_ENTRIES) + .map(|i| { + ( + format!("REF_{i}"), + CredentialHandle { + driver: "test".to_string(), + handle: format!("handle-{i}"), + metadata: HashMap::new(), + }, + ) + }) + .collect(); + let mut provider = make_test_provider("ok", "claude", one_credential(), HashMap::new()); + provider.credential_handles = refs; + + let err = validate_provider_fields(&provider).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("credential sources")); + } + + #[test] + fn validate_provider_fields_rejects_credential_handle_missing_handle() { + let mut provider = + make_test_provider("my-provider", "claude", HashMap::new(), HashMap::new()); + provider.credential_handles = std::iter::once(( + "API_KEY".to_string(), + CredentialHandle { + driver: "test".to_string(), + handle: String::new(), + metadata: HashMap::new(), + }, + )) + .collect(); + + let err = validate_provider_fields(&provider).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("handle is required")); + } + #[test] fn validate_provider_fields_rejects_over_limit_name() { let provider = make_test_provider( @@ -1678,44 +1907,6 @@ mod tests { // ---- Policy safety ---- - #[test] - fn process_identity_omission_is_driver_scoped() { - use openshell_core::proto::ProcessPolicy; - - for driver in [ComputeDriverKind::Docker, ComputeDriverKind::Podman] { - let mut policy = ProtoSandboxPolicy { - process: Some(ProcessPolicy { - run_as_user: "1234".into(), - run_as_group: String::new(), - }), - ..Default::default() - }; - normalize_process_identity_for_driver(&mut policy, Some(driver)); - assert!( - policy.process.unwrap().run_as_group.is_empty(), - "{driver:?} must preserve omission" - ); - } - - for driver in [ - Some(ComputeDriverKind::Kubernetes), - Some(ComputeDriverKind::Vm), - None, - ] { - let mut policy = ProtoSandboxPolicy { - process: Some(ProcessPolicy { - run_as_user: "1234".into(), - run_as_group: String::new(), - }), - ..Default::default() - }; - normalize_process_identity_for_driver(&mut policy, driver); - let process = policy.process.unwrap(); - assert_eq!(process.run_as_user, "1234"); - assert_eq!(process.run_as_group, "sandbox"); - } - } - #[test] fn validate_policy_safety_rejects_root_user() { use openshell_core::proto::{FilesystemPolicy, ProcessPolicy}; diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index a22a195226..7446938156 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -14,9 +14,9 @@ use openshell_core::proto::{ CreateWorkspaceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, GetWorkspaceRequest, GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, - RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, - SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, - WorkspaceMember, WorkspaceRole, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, SandboxWorkloadTemplate, + ServiceEndpoint, SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, + Workspace, WorkspaceMember, WorkspaceRole, }; use prost::Message; use tonic::{Request, Response, Status}; @@ -375,6 +375,7 @@ pub(super) async fn handle_delete_workspace( let mut blocking = Vec::new(); for (object_type, label) in [ (Sandbox::object_type(), "sandbox"), + (SandboxWorkloadTemplate::object_type(), "sandbox template"), (Provider::object_type(), "provider"), (StoredProviderProfile::object_type(), "provider profile"), (ServiceEndpoint::object_type(), "service"), @@ -423,6 +424,15 @@ pub(super) async fn handle_delete_workspace( .await .map_err(|e| Status::internal(format!("delete workspace members failed: {e}")))?; + // Keep the terminating workspace durable until platform cleanup has been + // accepted. A failed cleanup can then be retried through this same path. + state.compute.delete_workspace(&name).await.map_err(|e| { + Status::new( + e.code(), + format!("delete workspace platform resources failed: {e}"), + ) + })?; + let deleted = state .store .delete_if(Workspace::object_type(), &ws_id, delete_version) @@ -616,7 +626,9 @@ mod tests { use openshell_core::proto::datamodel::v1::ObjectMeta; use tonic::{Code, Request}; - use crate::grpc::test_support::{authed_request, test_server_state}; + use crate::grpc::test_support::{ + authed_request, test_server_state, test_server_state_with_workspace_cleanup_failures, + }; #[tokio::test] async fn create_workspace_returns_metadata() { @@ -808,6 +820,72 @@ mod tests { assert!(resp.deleted); } + #[tokio::test] + async fn delete_workspace_blocked_by_sandbox_template() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "templated".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let template = SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: "template-1".to_string(), + name: "gpu-kata".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: "templated".to_string(), + deletion_timestamp_ms: 0, + }), + spec: None, + }; + state.store.put_message(&template).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "templated".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("sandbox template"), + "error should name sandbox templates as blocking resources: {}", + err.message() + ); + + state + .store + .delete_by_name( + SandboxWorkloadTemplate::object_type(), + "templated", + "gpu-kata", + ) + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "templated".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + #[tokio::test] async fn delete_workspace_blocked_by_ssh_session() { let state = test_server_state().await; @@ -1322,6 +1400,62 @@ mod tests { assert!(resp.deleted); } + #[tokio::test] + async fn delete_workspace_retains_terminating_record_when_platform_cleanup_fails() { + let state = test_server_state_with_workspace_cleanup_failures(1).await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "cleanup-retry".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "cleanup-retry".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::Unavailable); + + let retained: Workspace = state + .store + .get_message_by_name("", "cleanup-retry") + .await + .unwrap() + .expect("workspace must remain durable after cleanup failure"); + assert_ne!( + retained.metadata.unwrap().deletion_timestamp_ms, + 0, + "retained workspace must remain terminating" + ); + + let retry = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "cleanup-retry".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(retry.deleted); + assert!( + state + .store + .get_message_by_name::("", "cleanup-retry") + .await + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn create_workspace_persists_labels_for_selector() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index c838ad021f..b83fd6be4f 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -81,9 +81,13 @@ impl Inference for InferenceService { .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found(format!("sandbox '{sandbox_id}' not found")))?; let workspace = sandbox.object_workspace(); - resolve_inference_bundle(self.state.store.as_ref(), workspace) - .await - .map(Response::new) + resolve_inference_bundle_with_credentials( + self.state.store.as_ref(), + workspace, + Some(&self.state.credentials), + ) + .await + .map(Response::new) } async fn set_inference_route( @@ -106,9 +110,10 @@ impl Inference for InferenceService { .ensure_active()?; let route_name = effective_route_name(&req.route_name)?; let verify = !req.no_verify; - let route = upsert_inference_route( + let route = upsert_cluster_inference_route_with_credentials( self.state.store.as_ref(), &workspace, + Some(&self.state.credentials), route_name, &req.provider_name, &req.model_id, @@ -216,6 +221,30 @@ impl Inference for InferenceService { } } +#[cfg(test)] +async fn upsert_cluster_inference_route( + store: &Store, + workspace: &str, + route_name: &str, + provider_name: &str, + model_id: &str, + timeout_secs: u64, + verify: bool, +) -> Result { + upsert_cluster_inference_route_with_credentials( + store, + workspace, + None, + route_name, + provider_name, + model_id, + timeout_secs, + verify, + ) + .await +} + +#[cfg(test)] async fn upsert_inference_route( store: &Store, workspace: &str, @@ -224,6 +253,29 @@ async fn upsert_inference_route( model_id: &str, timeout_secs: u64, verify: bool, +) -> Result { + upsert_cluster_inference_route( + store, + workspace, + route_name, + provider_name, + model_id, + timeout_secs, + verify, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn upsert_cluster_inference_route_with_credentials( + store: &Store, + workspace: &str, + credentials: Option<&crate::credentials::CredentialRuntime>, + route_name: &str, + provider_name: &str, + model_id: &str, + timeout_secs: u64, + verify: bool, ) -> Result { if provider_name.trim().is_empty() { return Err(Status::invalid_argument("provider_name is required")); @@ -241,6 +293,7 @@ async fn upsert_inference_route( "provider '{provider_name}' not found in workspace '{workspace}'" )) })?; + let provider = resolve_provider_credentials(provider, credentials).await?; let resolved = resolve_provider_route(&provider, model_id)?; let validation = if verify { @@ -645,6 +698,22 @@ fn resolve_vertex_ai_route( |p| p.eq_ignore_ascii_case("anthropic"), ); + // Vertex's OpenAI-compatible endpoint requires the request body's `model` + // field to carry a publisher prefix: `/` (e.g. + // `google/gemini-2.5-flash`). The publisher is taken from the explicit + // VERTEX_AI_PUBLISHER config value (when set to a non-Anthropic value) or + // inferred from the model name. For unrecognised models with no explicit + // publisher, the bare model ID is forwarded unchanged; Vertex will return + // a 400 in that case, which is the correct observable signal to the caller. + // Anthropic rawPredict routes encode the model in the URL path, not the + // body, so they are unaffected. + let body_model_id: String = if is_anthropic { + model_id.to_string() + } else { + let publisher = explicit_publisher.or_else(|| infer_vertex_publisher(model_id)); + publisher.map_or_else(|| model_id.to_string(), |p| format!("{p}/{model_id}")) + }; + // Escape hatch: caller-supplied full base URL still uses the model-derived // protocol and path contract, but only for the OpenAI-compatible Vertex surface. // Anthropic-on-Vertex needs model-path shaping and body adaptation that a fully @@ -668,7 +737,7 @@ fn resolve_vertex_ai_route( return Ok(build_vertex_route( route_name, base_url, - model_id, + &body_model_id, api_key, vec!["openai_chat_completions".to_string()], profile, @@ -719,7 +788,7 @@ fn resolve_vertex_ai_route( Ok(build_vertex_route( route_name, endpoint, - model_id, + &body_model_id, api_key, protocols, profile, @@ -961,16 +1030,39 @@ fn authorize_inference_bundle( } } -/// Resolve the inference bundle for a workspace (all managed routes + revision hash). +/// Resolve the inference bundle (all managed routes + revision hash). +#[cfg(test)] async fn resolve_inference_bundle( store: &Store, workspace: &str, +) -> Result { + resolve_inference_bundle_with_credentials(store, workspace, None).await +} + +async fn resolve_inference_bundle_with_credentials( + store: &Store, + workspace: &str, + credentials: Option<&crate::credentials::CredentialRuntime>, ) -> Result { let mut routes = Vec::new(); - if let Some(r) = resolve_route_by_name(store, workspace, CLUSTER_INFERENCE_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name_with_credentials( + store, + workspace, + credentials, + CLUSTER_INFERENCE_ROUTE_NAME, + ) + .await? + { routes.push(r); } - if let Some(r) = resolve_route_by_name(store, workspace, SANDBOX_SYSTEM_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name_with_credentials( + store, + workspace, + credentials, + SANDBOX_SYSTEM_ROUTE_NAME, + ) + .await? + { routes.push(r); } @@ -1007,10 +1099,20 @@ async fn resolve_inference_bundle( }) } +#[cfg(test)] async fn resolve_route_by_name( store: &Store, workspace: &str, route_name: &str, +) -> Result, Status> { + resolve_route_by_name_with_credentials(store, workspace, None, route_name).await +} + +async fn resolve_route_by_name_with_credentials( + store: &Store, + workspace: &str, + credentials: Option<&crate::credentials::CredentialRuntime>, + route_name: &str, ) -> Result, Status> { let route = store .get_message_by_name::(workspace, route_name) @@ -1047,13 +1149,14 @@ async fn resolve_route_by_name( config.provider_name )) })?; + let provider = resolve_provider_credentials(provider, credentials).await?; let resolved = resolve_provider_route(&provider, &config.model_id)?; Ok(Some(ResolvedRoute { name: route_name.to_string(), base_url: resolved.route.endpoint, - model_id: config.model_id.clone(), + model_id: resolved.route.model.clone(), api_key: resolved.route.api_key, protocols: resolved.route.protocols, provider_type: resolved.provider_type, @@ -1063,6 +1166,48 @@ async fn resolve_route_by_name( })) } +async fn resolve_provider_credentials( + mut provider: Provider, + credentials: Option<&crate::credentials::CredentialRuntime>, +) -> Result { + if provider.credential_handles.is_empty() { + return Ok(provider); + } + + let credentials = credentials.ok_or_else(|| { + Status::failed_precondition(format!( + "provider '{}' stores credentials as handles, but credential storage is unavailable", + provider.object_name() + )) + })?; + let resolved = credentials + .resolve_provider_handles(&provider, current_time_ms()) + .await?; + provider.credentials.extend(resolved.values); + + // Merge expiration times, keeping the earliest non-zero value + for (key, driver_expires_at_ms) in resolved.expires_at_ms { + let provider_expires_at_ms = provider + .credential_expires_at_ms + .get(&key) + .copied() + .unwrap_or(0); + + let effective_expires_at_ms = match (provider_expires_at_ms, driver_expires_at_ms) { + (0, driver) => driver, + (provider, 0) => provider, + (provider, driver) => provider.min(driver), + }; + + if effective_expires_at_ms > 0 { + provider + .credential_expires_at_ms + .insert(key, effective_expires_at_ms); + } + } + Ok(provider) +} + #[cfg(test)] mod tests { use super::*; @@ -1138,6 +1283,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), } } @@ -1311,6 +1457,7 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; store .put_message(&provider) @@ -1386,6 +1533,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; store .put_message(&provider) @@ -1438,6 +1586,7 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; store .put_message(&provider) @@ -1597,13 +1746,51 @@ mod tests { route.request_path_override, Some("/chat/completions".to_string()) ); - assert_eq!(route.model_id, "gemini-2.0-flash-001"); + assert_eq!(route.model_id, "google/gemini-2.0-flash-001"); assert_eq!( route.base_url, "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-gcp-project/locations/us-central1/endpoints/openapi" ); } + #[tokio::test] + async fn bundle_vertex_ai_non_anthropic_model_id_carries_publisher_prefix() { + // Regression test: the bundle's model_id must carry the publisher prefix + // so the router sends e.g. "google/gemini-2.5-flash" in the request body, + // not the bare "gemini-2.5-flash" that Vertex AI rejects with HTTP 400. + let store = test_store().await; + let config = [ + ( + "VERTEX_AI_PROJECT_ID".to_string(), + "my-gcp-project".to_string(), + ), + ("VERTEX_AI_REGION".to_string(), "us-central1".to_string()), + ] + .into_iter() + .collect(); + let provider = make_vertex_provider_with_config("vertex-dev", config); + store + .put_message(&provider) + .await + .expect("persist provider"); + let route = make_route( + CLUSTER_INFERENCE_ROUTE_NAME, + "vertex-dev", + "gemini-2.5-flash", + ); + store.put_message(&route).await.expect("persist route"); + + let resp = resolve_inference_bundle(&store, "default") + .await + .expect("bundle should resolve"); + + assert_eq!(resp.routes.len(), 1); + assert_eq!( + resp.routes[0].model_id, "google/gemini-2.5-flash", + "bundle model_id must carry publisher prefix for non-Anthropic Vertex routes" + ); + } + #[tokio::test] async fn bundle_without_cluster_route_returns_empty_routes() { let store = test_store().await; @@ -1669,6 +1856,7 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; store .put_message(&provider) @@ -1718,6 +1906,78 @@ mod tests { ); } + #[tokio::test] + async fn managed_route_resolves_default_credential_handles() { + let store = test_store().await; + let credentials = crate::credentials::CredentialRuntime::from_config_with_store( + &openshell_core::Config::new(None), + Arc::new(store.clone()), + ) + .expect("credential runtime should connect to default encrypted store"); + let handles = credentials + .store_provider_credentials( + "openai-dev", + "default", + "provider-1", + &HashMap::from([("OPENAI_API_KEY".to_string(), "sk-encrypted".to_string())]), + &HashMap::new(), + ) + .await + .expect("credential should be stored"); + + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-1".to_string(), + name: "openai-dev".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "openai".to_string(), + credentials: HashMap::new(), + config: std::iter::once(( + "OPENAI_BASE_URL".to_string(), + "https://station.example.com/v1".to_string(), + )) + .collect(), + credential_expires_at_ms: HashMap::new(), + credential_handles: handles, + profile_workspace: String::new(), + }; + store + .put_message(&provider) + .await + .expect("provider should persist"); + + upsert_cluster_inference_route_with_credentials( + &store, + "default", + Some(&credentials), + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "test/model", + 0, + false, + ) + .await + .expect("route should be created from handle-backed provider"); + + let managed = resolve_route_by_name_with_credentials( + &store, + "default", + Some(&credentials), + CLUSTER_INFERENCE_ROUTE_NAME, + ) + .await + .expect("route should resolve") + .expect("managed route should exist"); + + assert_eq!(managed.base_url, "https://station.example.com/v1"); + assert_eq!(managed.api_key, "sk-encrypted"); + } + #[tokio::test] async fn resolve_managed_route_reflects_provider_key_rotation() { let store = test_store().await; @@ -1748,6 +2008,7 @@ mod tests { config: provider.config.clone(), credential_expires_at_ms: provider.credential_expires_at_ms.clone(), profile_workspace: provider.profile_workspace.clone(), + credential_handles: HashMap::new(), }; store .put_message(&rotated_provider) @@ -1819,6 +2080,7 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; store .put_message(&provider) @@ -2154,6 +2416,7 @@ mod tests { config, credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), } } @@ -2404,6 +2667,11 @@ mod tests { .contains(&"anthropic_messages".to_string()), "must not have anthropic_messages protocol for gemini" ); + // Vertex OpenAI-compatible endpoint requires publisher prefix in body model field + assert_eq!( + resolved.route.model, "google/gemini-pro", + "Vertex non-Anthropic body model must carry publisher prefix" + ); } #[test] @@ -2442,6 +2710,67 @@ mod tests { .contains(&"anthropic_messages".to_string()), "must not have anthropic_messages for unknown model" ); + // Unknown models have no inferred publisher; body model ID is unchanged + assert_eq!(resolved.route.model, "some-unknown-model"); + } + + #[test] + fn resolve_vertex_ai_route_non_anthropic_publisher_prefix_gemini() { + // Gemini models must get `google/` in route.model so the + // OpenAI-compatible Vertex endpoint accepts the request body. + let config = + std::iter::once(("VERTEX_AI_PROJECT_ID".to_string(), "proj-abc".to_string())).collect(); + let provider = make_vertex_provider_with_config("vertex-gemini-flash", config); + + let resolved = + resolve_provider_route(&provider, "gemini-2.5-flash").expect("should resolve"); + + assert_eq!(resolved.route.model, "google/gemini-2.5-flash"); + } + + #[test] + fn resolve_vertex_ai_route_non_anthropic_publisher_prefix_llama() { + let config = + std::iter::once(("VERTEX_AI_PROJECT_ID".to_string(), "proj-abc".to_string())).collect(); + let provider = make_vertex_provider_with_config("vertex-llama", config); + + let resolved = resolve_provider_route(&provider, "llama-3-70b").expect("should resolve"); + + assert_eq!(resolved.route.model, "meta/llama-3-70b"); + } + + #[test] + fn resolve_vertex_ai_route_explicit_publisher_overrides_inference() { + // VERTEX_AI_PUBLISHER takes precedence over infer_vertex_publisher for + // unknown model names. + let config = [ + ("VERTEX_AI_PROJECT_ID".to_string(), "proj-abc".to_string()), + ("VERTEX_AI_PUBLISHER".to_string(), "acme".to_string()), + ] + .into_iter() + .collect(); + let provider = make_vertex_provider_with_config("vertex-explicit", config); + + let resolved = + resolve_provider_route(&provider, "some-acme-model").expect("should resolve"); + + assert_eq!(resolved.route.model, "acme/some-acme-model"); + } + + #[test] + fn resolve_vertex_ai_route_base_url_override_gemini_gets_publisher_prefix() { + // Publisher prefix must also be applied when a base URL override is used. + let config = std::iter::once(( + "VERTEX_AI_BASE_URL".to_string(), + "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/endpoints/openapi".to_string(), + )) + .collect(); + let provider = make_vertex_provider_with_config("vertex-base-url-gemini", config); + + let resolved = + resolve_provider_route(&provider, "gemini-2.0-flash").expect("should resolve"); + + assert_eq!(resolved.route.model, "google/gemini-2.0-flash"); } #[test] @@ -3238,6 +3567,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; store .put_message(&alpha_provider) @@ -3264,6 +3594,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), + credential_handles: HashMap::new(), }; store .put_message(&beta_provider) diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6151922b71..a8c8afdf08 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -9,25 +9,16 @@ //! - Protocol multiplexing (gRPC + HTTP on same port) //! - mTLS support //! -//! TODO(driver-abstraction): `build_compute_runtime` still switches on -//! built-in driver names and calls driver-specific constructors -//! ([`ComputeRuntime::new_kubernetes`], [`ComputeRuntime::new_docker`], -//! [`compute::vm::spawn`] + [`ComputeRuntime::new_remote_driver`], -//! [`ComputeRuntime::new_podman`]). Endpoint-backed drivers now share the -//! remote `compute_driver.proto` path, so new remote drivers should enter -//! through named endpoint acquisition rather than gateway-wide socket side -//! channels. Once we have a generalized compute-driver registry, the remaining -//! per-arm wiring here should collapse to driver construction records that -//! produce either an in-process `SharedComputeDriver` or an acquired remote -//! endpoint, then hand the rest of the gateway a uniform [`ComputeRuntime`]. -//! The VM launch plumbing now lives in [`compute::vm`]; keep this file limited -//! to selecting and acquiring drivers. +//! Compiled-in compute drivers are installed into a registry at gateway +//! startup. Runtime selection only consults that registry or a configured +//! external endpoint; it does not switch on driver names. mod auth; pub mod certgen; pub mod cli; mod compute; pub mod config_file; +mod credentials; mod defaults; mod gateway_listener; mod grpc; @@ -57,15 +48,24 @@ mod tracing_setup; mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; -use openshell_core::{ComputeDriverKind, Config, Error, ObjectLabels, Result}; +use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::telemetry::TelemetryComputeDriver; +use openshell_core::{Config, Error, ObjectLabels, Result}; +use openshell_extension_core::{ + BearerTokenSlot, ExtensionAudience, ExtensionCallerKind, ExtensionKind, MAX_EXTENSION_TOKEN_TTL, +}; use openshell_supervisor_middleware::MiddlewareRegistry; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::io::ErrorKind; use std::net::SocketAddr; +use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::LazyLock; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use tracing::{debug, error, info, warn}; @@ -77,10 +77,166 @@ pub(crate) static TEST_ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::n #[cfg(test)] pub(crate) static TEST_TRACING_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +pub(crate) fn install_jsonwebtoken_crypto_provider() { + let _ = jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER.install_default(); +} + use compute::ComputeRuntime; use gateway_listener::{BoundGatewayListener, GatewayListenerScope, bind_gateway_listeners}; pub use grpc::OpenShellService; pub use http::{health_router, http_router, metrics_router, service_http_router}; + +/// Deriving `Debug` is safe here: `BearerTokenSlot` renders only its expiry, +/// and an extension audience is configuration rather than secret material. +#[derive(Debug)] +struct GatewayExtensionCredential { + name: String, + audience: ExtensionAudience, + slot: BearerTokenSlot, + ttl: Duration, +} + +fn extension_token_ttl(issuer: &auth::sandbox_jwt::SandboxJwtIssuer) -> Duration { + if issuer.ttl().is_zero() { + Duration::from_secs(15 * 60) + } else { + issuer.ttl().min(MAX_EXTENSION_TOKEN_TTL) + } +} + +/// Mint the gateway-caller credential for one extension registration. +/// +/// Returns `Ok(None)` when the operator has explicitly opted the registration +/// out of extension authentication. The opt-out is deliberately loud: it +/// downgrades a security boundary, so it is reported once per registration at +/// startup rather than being silently tolerated. +fn mint_gateway_extension_credential( + issuer: &Arc, + kind: ExtensionKind, + name: &str, + audience: &str, + endpoint: &str, + allow_insecure_transport: bool, +) -> Result> { + // A middleware endpoint must be reachable from sandbox supervisors, so a + // gateway-local Unix socket is only an option for interceptors. + let (accepted, supports_unix) = match kind { + ExtensionKind::Middleware => ("https://", false), + ExtensionKind::Interceptor => ("https:// or unix://", true), + _ => { + return Err(Error::config(format!( + "extension kind '{kind}' is not supported by gateway authentication" + ))); + } + }; + if allow_insecure_transport { + warn!( + extension = %name, + endpoint = %endpoint, + "extension authentication is DISABLED for this registration by \ + allow_insecure_transport; OpenShell attaches no caller credential \ + and the service cannot distinguish OpenShell from any other \ + network client. Use {accepted} with the opt-out removed outside \ + trusted-network development deployments." + ); + return Ok(None); + } + let transport_supported = + endpoint.starts_with("https://") || (supports_unix && endpoint.starts_with("unix://")); + if !transport_supported { + return Err(Error::config(format!( + "authenticated {kind} '{name}' must use {accepted}; set \ + allow_insecure_transport = true to opt this registration out of \ + extension authentication instead" + ))); + } + let audience = ExtensionAudience::new(audience.to_string()).map_err(|error| { + Error::config(format!( + "extension '{name}' has an invalid audience: {error}" + )) + })?; + let ttl = extension_token_ttl(issuer); + let minted = issuer + .mint_extension_token(&audience, ExtensionCallerKind::Gateway, None, ttl) + .map_err(|status| { + Error::config(format!( + "failed to mint credential for extension '{name}': {}", + status.message() + )) + })?; + let slot = BearerTokenSlot::new(&minted.token, minted.expires_at_ms).map_err(|error| { + Error::config(format!( + "failed to install credential for extension '{name}': {error}" + )) + })?; + Ok(Some(GatewayExtensionCredential { + name: name.to_string(), + audience, + slot, + ttl, + })) +} + +fn spawn_gateway_extension_token_refresh( + issuer: Arc, + credentials: Vec, +) { + if credentials.is_empty() { + return; + } + tokio::spawn(async move { + loop { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }); + let remaining_ms = credentials + .iter() + .filter_map(|credential| credential.slot.expires_at_ms()) + .min() + .map_or(60_000, |expiry_ms| expiry_ms.saturating_sub(now_ms)); + let refresh_delay = if remaining_ms <= 0 { + Duration::from_millis(100) + } else { + Duration::from_millis( + u64::try_from(remaining_ms) + .unwrap_or(u64::MAX) + .saturating_mul(4) + .checked_div(5) + .unwrap_or(100) + .max(100), + ) + }; + tokio::time::sleep(refresh_delay).await; + for credential in &credentials { + match issuer.mint_extension_token( + &credential.audience, + ExtensionCallerKind::Gateway, + None, + credential.ttl, + ) { + Ok(minted) => { + if let Err(error) = + credential.slot.update(&minted.token, minted.expires_at_ms) + { + warn!( + extension = %credential.name, + error = %error, + "failed to rotate gateway extension credential" + ); + } + } + Err(status) => warn!( + extension = %credential.name, + error = %status, + "failed to mint gateway extension credential" + ), + } + } + } + }); +} pub use multiplex::{MultiplexService, MultiplexedService}; pub use persistence::Store; use sandbox_index::SandboxIndex; @@ -92,6 +248,7 @@ pub(crate) struct ServerStartupConfig { pub config: Config, pub config_file: Option, pub guest_tls: Option, + pub compute_driver: ComputeDriverSelection, } /// Server state shared across handlers. @@ -106,6 +263,9 @@ pub struct ServerState { /// Compute orchestration over the configured driver. pub compute: ComputeRuntime, + /// Credential-driver selection and resolution runtime. + pub credentials: credentials::CredentialRuntime, + /// In-memory sandbox correlation index. pub sandbox_index: SandboxIndex, @@ -132,11 +292,15 @@ pub struct ServerState { /// Registry of active supervisor sessions and pending relay channels. /// - /// Stored as `Arc` so compute drivers (e.g. the Docker driver) - /// can be constructed before `ServerState` and still + /// Stored as `Arc` so compiled compute drivers can be constructed before + /// `ServerState` and still /// query session state to surface supervisor readiness. pub supervisor_sessions: Arc, + /// Set once graceful gateway shutdown begins so stream handlers can + /// distinguish expected transport closes from runtime failures. + pub(crate) gateway_shutting_down: AtomicBool, + /// Validated built-in and operator-registered supervisor middleware. pub middleware_registry: Arc, @@ -154,14 +318,17 @@ pub struct ServerState { /// presenting a freshly minted token are recognized. pub sandbox_jwt_authenticator: Option>, - /// Optional K8s `ServiceAccount` authenticator that backs the - /// `IssueSandboxToken` bootstrap path. Only present when the gateway - /// runs in-cluster. - pub k8s_sa_authenticator: Option>, + /// Optional selected-driver authenticator for the `IssueSandboxToken` + /// bootstrap path. + pub compute_driver_authenticator: Option>, /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, + /// 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, + /// Immutable gateway interceptor execution plan. `None` when disabled. pub(crate) gateway_interceptors: Option, @@ -200,6 +367,36 @@ impl ServerState { tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, oidc_cache: Option>, + ) -> Self { + let credentials = + credentials::CredentialRuntime::from_config_with_store(&config, Arc::clone(&store)) + .expect("server config should be validated before ServerState::new"); + Self::new_with_credentials( + config, + store, + compute, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + oidc_cache, + credentials, + ) + } + + /// Create new server state with an already-initialized credential runtime. + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new_with_credentials( + config: Config, + store: Arc, + compute: ComputeRuntime, + sandbox_index: SandboxIndex, + sandbox_watch_bus: SandboxWatchBus, + tracing_log_bus: TracingLogBus, + supervisor_sessions: Arc, + oidc_cache: Option>, + credentials: credentials::CredentialRuntime, ) -> Self { let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config); let admin_role = config @@ -210,6 +407,7 @@ impl ServerState { config, store, compute, + credentials, sandbox_index, sandbox_watch_bus, tracing_log_bus, @@ -218,11 +416,13 @@ impl ServerState { ssh_connections_by_sandbox: Mutex::new(HashMap::new()), settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, + gateway_shutting_down: AtomicBool::new(false), + extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, sandbox_jwt_issuer: None, sandbox_jwt_authenticator: None, - k8s_sa_authenticator: None, + compute_driver_authenticator: None, grpc_rate_limiter, gateway_interceptors: None, provider_profile_sources: @@ -242,12 +442,15 @@ impl ServerState { pub(crate) async fn run_server( startup: ServerStartupConfig, tracing_log_bus: TracingLogBus, + compute_drivers: ComputeDriverRegistry, ) -> Result<()> { let ServerStartupConfig { config, config_file, guest_tls, + compute_driver, } = startup; + let (shutdown_tx, shutdown_rx) = watch::channel(false); auth::descriptor_authz::init() .map_err(|error| Error::config(format!("invalid gRPC authorization metadata: {error}")))?; @@ -257,6 +460,60 @@ pub(crate) async fn run_server( return Err(Error::config("database_url is required")); } + // Load signing material before connecting remote extensions so their + // startup Describe calls can authenticate with gateway-caller tokens. + let (sandbox_jwt_issuer, sandbox_jwt_authenticator) = if let Some(ref jwt) = config.gateway_jwt + { + let signing_pem = std::fs::read(&jwt.signing_key_path).map_err(|e| { + Error::config(format!( + "failed to read sandbox JWT signing key from {}: {e}", + jwt.signing_key_path.display() + )) + })?; + let public_pem = std::fs::read(&jwt.public_key_path).map_err(|e| { + Error::config(format!( + "failed to read sandbox JWT public key from {}: {e}", + jwt.public_key_path.display() + )) + })?; + let kid = std::fs::read_to_string(&jwt.kid_path) + .map_err(|e| { + Error::config(format!( + "failed to read sandbox JWT kid from {}: {e}", + jwt.kid_path.display() + )) + })? + .trim() + .to_string(); + if kid.is_empty() { + return Err(Error::config(format!( + "sandbox JWT kid file {} is empty", + jwt.kid_path.display() + ))); + } + let issuer = Arc::new( + auth::sandbox_jwt::SandboxJwtIssuer::from_pem( + &signing_pem, + kid.clone(), + &jwt.gateway_id, + Duration::from_secs(jwt.ttl_secs), + ) + .map_err(Error::config)?, + ); + let authenticator = Arc::new( + auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem(&public_pem, kid, &jwt.gateway_id) + .map_err(Error::config)?, + ); + info!( + gateway_id = %jwt.gateway_id, + ttl_secs = jwt.ttl_secs, + "gateway-minted sandbox JWT enabled" + ); + (Some(issuer), Some(authenticator)) + } else { + (None, None) + }; + let middleware_registrations = config_file .as_ref() .map(|file| { @@ -264,20 +521,52 @@ pub(crate) async fn run_server( .supervisor .middleware .iter() - .map(Into::into) - .collect() + .map(openshell_core::proto::SupervisorMiddlewareService::try_from) + .collect::, _>>() }) + .transpose() + .map_err(|error| Error::config(format!("middleware registration failed: {error}")))? .unwrap_or_default(); + let mut gateway_extension_credentials = Vec::new(); let middleware_registry = Arc::new( - MiddlewareRegistry::connect_services( - openshell_supervisor_middleware_builtins::services(), - middleware_registrations, - ) - .await + if let Some(issuer) = sandbox_jwt_issuer.as_ref() { + let mut slots = HashMap::new(); + for registration in &middleware_registrations { + if let Some(credential) = mint_gateway_extension_credential( + issuer, + ExtensionKind::Middleware, + ®istration.name, + ®istration.audience, + ®istration.grpc_endpoint, + registration.allow_insecure_transport, + )? { + slots.insert(registration.name.clone(), credential.slot.clone()); + gateway_extension_credentials.push(credential); + } + } + MiddlewareRegistry::connect_services_authenticated( + openshell_supervisor_middleware_builtins::services(), + middleware_registrations, + &slots, + ) + .await + } else { + MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + middleware_registrations, + ) + .await + } .map_err(|error| Error::config(format!("middleware registration failed: {error}")))?, ); let store = Arc::new(Store::connect(database_url).await?); + let credentials = credentials::CredentialRuntime::from_config_file_with_store( + &config, + config_file.as_ref(), + Arc::clone(&store), + ) + .await?; let oidc_cache = if let Some(ref oidc) = config.oidc { // Validate RBAC configuration before starting. @@ -308,6 +597,8 @@ pub(crate) async fn run_server( endpoint_overrides: &config.compute_driver_endpoints, }; let compute = build_compute_runtime( + &compute_drivers, + &compute_driver, &config, driver_startup, store.clone(), @@ -315,14 +606,34 @@ pub(crate) async fn run_server( sandbox_watch_bus.clone(), tracing_log_bus.clone(), supervisor_sessions.clone(), + shutdown_rx.clone(), ) .await?; - let gateway_interceptors = - openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()) - .await - .map_err(|e| { - Error::config(format!("gateway interceptor initialization failed: {e}")) - })?; + let gateway_interceptors = if let Some(issuer) = sandbox_jwt_issuer.as_ref() { + let mut slots = BTreeMap::new(); + for interceptor in &config.gateway_interceptors { + let audience = interceptor.resolved_audience(); + if let Some(credential) = mint_gateway_extension_credential( + issuer, + ExtensionKind::Interceptor, + &interceptor.name, + audience.as_ref(), + &interceptor.grpc_endpoint, + interceptor.allow_insecure_transport, + )? { + slots.insert(interceptor.name.clone(), credential.slot.clone()); + gateway_extension_credentials.push(credential); + } + } + openshell_gateway_interceptors::initialize_authenticated( + config.gateway_interceptors.clone(), + slots, + ) + .await + } else { + openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()).await + } + .map_err(|e| Error::config(format!("gateway interceptor initialization failed: {e}")))?; let provider_profile_sources = provider_profile_sources::ProviderProfileSources::from_config( &config.provider_profile_sources, gateway_interceptors.as_ref(), @@ -336,7 +647,7 @@ pub(crate) async fn run_server( sources = ?provider_profile_sources.source_ids(), "provider profile sources configured" ); - let mut state = ServerState::new( + let mut state = ServerState::new_with_credentials( config.clone(), store.clone(), compute, @@ -345,107 +656,41 @@ pub(crate) async fn run_server( tracing_log_bus, supervisor_sessions, oidc_cache, + credentials, ); state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; + state.sandbox_jwt_issuer = sandbox_jwt_issuer.clone(); + state.sandbox_jwt_authenticator = sandbox_jwt_authenticator; + if let Some(issuer) = sandbox_jwt_issuer { + spawn_gateway_extension_token_refresh(issuer, gateway_extension_credentials); + } - // Load the gateway-minted sandbox JWT signing key when configured. - // Optional so single-driver dev deployments without certgen continue - // to start. The helm-deployed gateway and the RPM init script populate - // `gateway_jwt` once `certgen` has produced the on-disk material. - if let Some(ref jwt) = config.gateway_jwt { - let signing_pem = std::fs::read(&jwt.signing_key_path).map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT signing key from {}: {e}", - jwt.signing_key_path.display() - )) - })?; - let public_pem = std::fs::read(&jwt.public_key_path).map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT public key from {}: {e}", - jwt.public_key_path.display() - )) - })?; - let kid = std::fs::read_to_string(&jwt.kid_path) - .map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT kid from {}: {e}", - jwt.kid_path.display() - )) - })? - .trim() - .to_string(); - if kid.is_empty() { - return Err(Error::config(format!( - "sandbox JWT kid file {} is empty", - jwt.kid_path.display() - ))); - } - let issuer = auth::sandbox_jwt::SandboxJwtIssuer::from_pem( - &signing_pem, - kid.clone(), - &jwt.gateway_id, - Duration::from_secs(jwt.ttl_secs), - ) - .map_err(Error::config)?; - let authenticator = - auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem(&public_pem, kid, &jwt.gateway_id) - .map_err(Error::config)?; + if state.sandbox_jwt_issuer.is_some() && state.compute.supports_sandbox_authentication() { + state.compute_driver_authenticator = Some(Arc::new( + auth::compute_driver::ComputeDriverAuthenticator::new(state.compute.clone()), + )); info!( - gateway_id = %jwt.gateway_id, - ttl_secs = jwt.ttl_secs, - "gateway-minted sandbox JWT enabled" + driver = state.compute.configured_driver_name(), + "compute-driver sandbox bootstrap authenticator enabled" ); - state.sandbox_jwt_issuer = Some(Arc::new(issuer)); - state.sandbox_jwt_authenticator = Some(Arc::new(authenticator)); - } - - // K8s ServiceAccount bootstrap authenticator. Only constructed when - // the gateway is running in-cluster (kubelet provides the API host - // env var) and has a sandbox JWT issuer to mint replacements against; - // outside the cluster we can't call the apiserver's TokenReview API, - // and without the issuer there's nothing to exchange the SA token for. - if state.sandbox_jwt_issuer.is_some() && std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { - // Pod lookups and TokenReview identity checks must match the sandbox - // namespace and service account used by the Kubernetes driver. - let kubernetes_config = - compute::driver_config::kubernetes_config_for_k8s_sa_bootstrap(config_file.as_ref())?; - let sandbox_namespace = kubernetes_config.namespace; - let sandbox_service_account = kubernetes_config.service_account_name; - match kube::Client::try_default().await { - Ok(client) => { - let resolver = Arc::new(auth::k8s_sa::LiveK8sResolver::new( - client, - &sandbox_namespace, - "openshell-gateway".to_string(), - sandbox_service_account.clone(), - )); - let authenticator = auth::k8s_sa::K8sServiceAccountAuthenticator::new(resolver); - state.k8s_sa_authenticator = Some(Arc::new(authenticator)); - info!( - namespace = %sandbox_namespace, - service_account = %sandbox_service_account, - "K8s ServiceAccount bootstrap authenticator enabled" - ); - } - Err(e) => warn!( - error = %e, - "in-cluster K8s client construction failed; \ - K8s ServiceAccount bootstrap is disabled" - ), - } } let state = Arc::new(state); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - - // Resume sandboxes that were stopped during the previous gateway - // shutdown so the running compute state matches the persisted store. - // Runs before watchers spawn so the watch loop sees the post-resume - // snapshot on its first poll. + // Reconcile local-driver running intent before watchers spawn so their + // first snapshots observe the post-start backend state. Explicitly stopped + // sandboxes remain stopped. ensure_default_workspace(&store).await?; + grpc::policy::validate_provider_composition_startup_preflight(&state) + .await + .map_err(|error| { + Error::config(format!( + "provider policy composition startup preflight failed: {}", + error.message() + )) + })?; let gateway_listeners = bind_gateway_listeners( config.bind_address, @@ -453,8 +698,8 @@ pub(crate) async fn run_server( ) .await?; - if let Err(err) = state.compute.resume_persisted_sandboxes().await { - warn!(error = %err, "Failed to resume persisted sandboxes during startup"); + if let Err(err) = state.compute.start_persisted_sandboxes().await { + warn!(error = %err, "Failed to start persisted sandboxes during startup"); } state.compute.spawn_watchers(shutdown_rx.clone()); @@ -518,6 +763,9 @@ pub(crate) async fn run_server( &tls.key_path, tls.client_ca_path.as_deref(), tls.require_client_auth, + tls.external_cert_path.as_deref(), + tls.external_key_path.as_deref(), + tls.external_server_names.clone(), )?; // Spawn file-watcher-based TLS certificate reload worker. @@ -545,6 +793,7 @@ pub(crate) async fn run_server( shutdown_signal().await; info!("Shutdown signal received; stopping gateway"); + state.gateway_shutting_down.store(true, Ordering::Release); let _ = shutdown_tx.send(true); for task in listener_tasks { @@ -598,6 +847,8 @@ async fn serve_gateway_listener( } }; + set_tcp_nodelay_best_effort(&stream); + spawn_gateway_connection( stream, addr, @@ -792,10 +1043,328 @@ async fn terminate_signal() { let _ = signal.recv().await; } -// Internal wiring helper: each argument is a distinct piece of runtime state -// that must be passed through, so the count is justified. +pub use compute::{ + AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, +}; + +/// Driver instance returned by a compiled compute-driver factory. +pub enum ComputeDriverInstance { + /// A driver hosted in the gateway process. + InProcess(SharedComputeDriver), + /// A driver process launched and owned by the gateway. + ManagedRemote(AcquiredRemoteDriverEndpoint), +} + +/// Factory for a compute driver linked into a gateway binary. +#[async_trait::async_trait] +pub trait ComputeDriverFactory: Send + Sync { + async fn build(&self, context: ComputeDriverBuildContext<'_>) -> Result; +} + +/// One named compiled-driver registration. +#[derive(Clone)] +pub struct ComputeDriverRegistration { + name: String, + detection_priority: u16, + detect: Option bool>, + factory: Arc, + telemetry_category: TelemetryComputeDriver, + inherited_config_keys: &'static [&'static str], + local_singleplayer: bool, + supports_mtls_user_auth: bool, + in_process_tracing: Option, +} + +impl std::fmt::Debug for ComputeDriverRegistration { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ComputeDriverRegistration") + .field("name", &self.name) + .field("detection_priority", &self.detection_priority) + .field("has_detection_probe", &self.detect.is_some()) + .finish_non_exhaustive() + } +} + +impl ComputeDriverRegistration { + /// Define a compiled driver. Lower detection priorities are preferred. + pub fn new( + name: impl Into, + detection_priority: u16, + detect: Option bool>, + factory: impl ComputeDriverFactory + 'static, + ) -> Result { + let name = openshell_core::config::normalize_compute_driver_name(&name.into()) + .map_err(Error::config)?; + Ok(Self { + name, + detection_priority, + detect, + factory: Arc::new(factory), + telemetry_category: TelemetryComputeDriver::custom(), + inherited_config_keys: &[], + local_singleplayer: false, + supports_mtls_user_auth: true, + in_process_tracing: None, + }) + } + + /// Select gateway-wide defaults understood by this driver's config type. + #[must_use] + pub fn with_inherited_config_keys(mut self, keys: &'static [&'static str]) -> Self { + self.inherited_config_keys = keys; + self + } + + /// Assign a bounded telemetry category chosen by the binary composition + /// boundary. Runtime driver names are never used as telemetry values. + #[must_use] + pub fn with_telemetry_category(mut self, category: TelemetryComputeDriver) -> Self { + self.telemetry_category = category; + self + } + + /// Mark a backend whose local deployment should use single-player defaults. + #[must_use] + pub fn with_local_singleplayer(mut self) -> Self { + self.local_singleplayer = true; + self + } + + /// Mark a backend that requires user authentication other than mTLS. + #[must_use] + pub fn without_mtls_user_auth(mut self) -> Self { + self.supports_mtls_user_auth = false; + self + } + + /// Attach process-wide tracing for this in-process compiled driver. + #[must_use] + pub fn with_in_process_tracing( + mut self, + tracing: openshell_otel::ComputeDriverTracing, + ) -> Self { + self.in_process_tracing = Some(tracing); + self + } + + #[must_use] + pub(crate) fn is_local_singleplayer(&self) -> bool { + self.local_singleplayer + } + + #[must_use] + pub(crate) fn supports_mtls_user_auth(&self) -> bool { + self.supports_mtls_user_auth + } + + #[must_use] + pub fn in_process_tracing(&self) -> Option { + self.in_process_tracing + } +} + +/// Registry of compute drivers compiled into this gateway binary. +/// +/// Like `SQLx`'s `Any` driver registry, installation is explicit at the binary +/// composition boundary while runtime selection is generic. +#[derive(Clone, Default)] +pub struct ComputeDriverRegistry { + drivers: BTreeMap, +} + +#[derive(Clone, Debug)] +struct ComputeDriverDetection { + available: Vec, +} + +impl ComputeDriverDetection { + fn selected(&self) -> Option<&str> { + self.available.first().map(String::as_str) + } +} + +#[derive(Clone, Debug)] +pub(crate) enum ComputeDriverSelection { + Configured { name: String }, + AutoDetected(ComputeDriverDetection), +} + +impl ComputeDriverSelection { + pub(crate) fn name(&self) -> &str { + match self { + Self::Configured { name } => name, + Self::AutoDetected(detection) => detection + .selected() + .expect("auto-detected selection has an available driver"), + } + } +} + +impl ComputeDriverRegistry { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Install a compiled driver factory. + pub fn install(&mut self, registration: ComputeDriverRegistration) -> Result<()> { + let name = registration.name.clone(); + match self.drivers.entry(name.clone()) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(registration); + Ok(()) + } + std::collections::btree_map::Entry::Occupied(_) => Err(Error::config(format!( + "compute driver '{name}' registered twice" + ))), + } + } + + /// Names installed into this gateway binary, in lexical order. + pub fn installed_driver_names(&self) -> impl Iterator { + self.drivers.keys().map(String::as_str) + } + + pub(crate) fn get(&self, name: &str) -> Option<&ComputeDriverRegistration> { + self.drivers.get(name) + } + + fn in_process_tracing( + &self, + selection: &ComputeDriverSelection, + endpoint_overrides: &BTreeMap, + ) -> Option { + let name = selection.name(); + if endpoint_overrides.contains_key(name) { + return None; + } + self.get(name) + .and_then(ComputeDriverRegistration::in_process_tracing) + } + + fn detect(&self) -> ComputeDriverDetection { + let mut candidates = self + .drivers + .values() + .filter(|registration| registration.detect.is_some()) + .collect::>(); + candidates.sort_by(|left, right| { + left.detection_priority + .cmp(&right.detection_priority) + .then_with(|| left.name.cmp(&right.name)) + }); + let available = candidates + .into_iter() + .filter(|registration| registration.detect.is_some_and(|detect| detect())) + .map(|registration| registration.name.clone()) + .collect(); + ComputeDriverDetection { available } + } + + pub(crate) fn select(&self, configured_drivers: &[String]) -> Result { + match configured_drivers { + [] => { + let detection = self.detect(); + if detection.selected().is_none() { + return Err(Error::config( + "no compute driver configured and auto-detection found no suitable installed \ + driver; set --drivers or OPENSHELL_DRIVERS=", + )); + } + Ok(ComputeDriverSelection::AutoDetected(detection)) + } + [driver] => { + let name = openshell_core::config::normalize_compute_driver_name(driver) + .map_err(Error::config)?; + Ok(ComputeDriverSelection::Configured { name }) + } + drivers => Err(Error::config(format!( + "multiple compute drivers are not supported yet; configured drivers: {}", + drivers.join(",") + ))), + } + } +} + +pub struct ComputeDriverBuildContext<'a> { + driver_name: String, + gateway_name: &'a str, + gateway_bind_address: SocketAddr, + gateway_log_level: &'a str, + driver_startup: compute::driver_config::DriverStartupContext<'a>, + shutdown_rx: watch::Receiver, + inherited_config_keys: &'static [&'static str], +} + +impl ComputeDriverBuildContext<'_> { + #[must_use] + pub fn driver_name(&self) -> &str { + &self.driver_name + } + + #[must_use] + pub fn gateway_name(&self) -> &str { + self.gateway_name + } + + #[must_use] + pub fn gateway_bind_address(&self) -> SocketAddr { + self.gateway_bind_address + } + + #[must_use] + pub fn gateway_log_level(&self) -> &str { + self.gateway_log_level + } + + #[must_use] + pub fn gateway_port(&self) -> u16 { + self.driver_startup.gateway_port + } + + #[must_use] + pub fn gateway_tls_enabled(&self) -> bool { + self.driver_startup.gateway_tls_enabled + } + + /// Gateway client credentials that a local driver may mount into guests. + #[must_use] + pub fn guest_tls_paths(&self) -> Option<(&Path, &Path, &Path)> { + self.driver_startup + .guest_tls + .map(compute::driver_config::GuestTlsPaths::as_paths) + } + + /// Deserialize the selected driver's merged TOML table. + pub fn driver_config(&self) -> Result + where + T: Default + serde::de::DeserializeOwned, + { + compute::driver_config::driver_config_from_context( + self.driver_startup, + &self.driver_name, + self.inherited_config_keys, + ) + } + + #[must_use] + pub fn shutdown_receiver(&self) -> watch::Receiver { + self.shutdown_rx.clone() + } + + #[must_use] + pub fn otlp_config(&self) -> Option<&config_file::OtlpConfig> { + self.driver_startup + .file + .and_then(|file| file.openshell.gateway.otlp.as_ref()) + } +} + #[allow(clippy::too_many_arguments)] async fn build_compute_runtime( + registry: &ComputeDriverRegistry, + selection: &ComputeDriverSelection, config: &Config, driver_startup: compute::driver_config::DriverStartupContext<'_>, store: Arc, @@ -803,65 +1372,65 @@ async fn build_compute_runtime( sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, + shutdown_rx: watch::Receiver, ) -> Result { - let driver = configured_compute_driver(config, driver_startup)?; + let driver = resolve_configured_compute_driver(registry, selection.name(), driver_startup)?; + let telemetry_compute_driver = driver.telemetry_compute_driver(registry); info!(driver = %driver.name(), "Using compute driver"); + if config + .gateway_jwt + .as_ref() + .is_some_and(|jwt| jwt.ttl_secs == 0) + && !driver.is_local_singleplayer(registry) + { + warn!( + "Gateway configured with non-expiring sandbox JWTs; set gateway_jwt.ttl_secs > 0 for shared deployments" + ); + } let runtime = match driver { - ConfiguredComputeDriver::Builtin(ComputeDriverKind::Kubernetes) => { - warn_if_kubernetes_sandbox_jwt_expiry_disabled(config); - let k8s_config = - compute::driver_config::kubernetes_config_from_context(driver_startup)?; - ComputeRuntime::new_kubernetes( - k8s_config, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions.clone(), - ) - .await - } - ConfiguredComputeDriver::Builtin(ComputeDriverKind::Docker) => { - let docker_config = compute::driver_config::docker_config_from_context(driver_startup)?; - ComputeRuntime::new_docker( - config.clone(), - docker_config, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - } - ConfiguredComputeDriver::Builtin(ComputeDriverKind::Podman) => { - let podman_config = compute::driver_config::podman_config_from_context(driver_startup)?; - ComputeRuntime::new_podman( - podman_config, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - } - ConfiguredComputeDriver::Builtin(ComputeDriverKind::Vm) => { - let vm_config = compute::driver_config::vm_config_from_context(driver_startup)?; - let otlp_config = driver_startup - .file - .and_then(|file| file.openshell.gateway.otlp.as_ref()); - let endpoint = compute::vm::spawn(config, &vm_config, otlp_config).await?; - ComputeRuntime::new_remote_driver( - endpoint, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await + ConfiguredComputeDriver::Registered(registration) => { + let build_context = ComputeDriverBuildContext { + driver_name: registration.name.clone(), + gateway_name: &config.name, + gateway_bind_address: config.bind_address, + gateway_log_level: &config.log_level, + driver_startup, + shutdown_rx, + inherited_config_keys: registration.inherited_config_keys, + }; + let instance = registration.factory.build(build_context).await?; + match instance { + ComputeDriverInstance::InProcess(driver) => ComputeRuntime::from_driver( + registration.name, + driver, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?, + ComputeDriverInstance::ManagedRemote(mut endpoint) => { + endpoint.name = registration.name; + ComputeRuntime::new_remote_driver( + endpoint, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })? + } + } } ConfiguredComputeDriver::Remote { name } => { let remote_config = @@ -883,89 +1452,79 @@ async fn build_compute_runtime( supervisor_sessions, ) .await + .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))? } }; - runtime.map_err(|e| Error::execution(format!("failed to create compute runtime: {e}"))) + Ok(runtime.with_telemetry_compute_driver(telemetry_compute_driver)) } #[derive(Debug, Clone)] enum ConfiguredComputeDriver { - Builtin(ComputeDriverKind), + Registered(ComputeDriverRegistration), Remote { name: String }, } impl ConfiguredComputeDriver { fn name(&self) -> &str { match self { - Self::Builtin(kind) => kind.as_str(), + Self::Registered(registration) => ®istration.name, Self::Remote { name } => name, } } + + fn is_local_singleplayer(&self, registry: &ComputeDriverRegistry) -> bool { + match self { + Self::Registered(registration) => registration.is_local_singleplayer(), + Self::Remote { name } => registry + .get(name) + .is_some_and(ComputeDriverRegistration::is_local_singleplayer), + } + } + + fn telemetry_compute_driver(&self, registry: &ComputeDriverRegistry) -> TelemetryComputeDriver { + match self { + Self::Registered(registration) => registration.telemetry_category, + Self::Remote { name } => registry + .get(name) + .map_or_else(TelemetryComputeDriver::custom, |registration| { + registration.telemetry_category + }), + } + } } +#[cfg(test)] fn configured_compute_driver( + registry: &ComputeDriverRegistry, config: &Config, driver_startup: compute::driver_config::DriverStartupContext<'_>, ) -> Result { - match config.compute_drivers.as_slice() { - [] => match openshell_core::config::detect_driver() { - Some(ComputeDriverKind::Vm) => Err(Error::config( - "vm compute driver is opt-in only; set --drivers vm or OPENSHELL_DRIVERS=vm", - )), - Some(driver) => Ok(ConfiguredComputeDriver::Builtin(driver)), - None => Err(Error::config( - "no compute driver configured and auto-detection found no suitable driver; \ - set --drivers or OPENSHELL_DRIVERS to kubernetes, podman, docker, or vm", - )), - }, - [driver] => resolve_configured_compute_driver(driver, driver_startup), - drivers => Err(Error::config(format!( - "multiple compute drivers are not supported yet; configured drivers: {}", - drivers.join(",") - ))), - } + let selection = registry.select(&config.compute_drivers)?; + resolve_configured_compute_driver(registry, selection.name(), driver_startup) } fn resolve_configured_compute_driver( + registry: &ComputeDriverRegistry, driver_name: &str, driver_startup: compute::driver_config::DriverStartupContext<'_>, ) -> Result { let name = openshell_core::config::normalize_compute_driver_name(driver_name) .map_err(Error::config)?; - let driver_kind = builtin_compute_driver(&name); - if driver_kind.is_some() && driver_startup.endpoint_overrides.contains_key(&name) { - return Err(Error::config(format!( - "compute driver '{name}' is a reserved built-in driver and cannot be selected with a socket endpoint" - ))); + // An operator-provided endpoint replaces normal construction for the + // selected name, including a compiled registration with the same name. + // The gateway connects to it; it does not provision the remote driver. + if driver_startup.endpoint_overrides.contains_key(&name) { + return Ok(ConfiguredComputeDriver::Remote { name }); } - if let Some(kind) = driver_kind { - return Ok(ConfiguredComputeDriver::Builtin(kind)); + if let Some(registration) = registry.get(&name) { + return Ok(ConfiguredComputeDriver::Registered(registration.clone())); } Ok(ConfiguredComputeDriver::Remote { name }) } -fn builtin_compute_driver(name: &str) -> Option { - name.parse().ok() -} - -fn kubernetes_sandbox_jwt_expiry_disabled(config: &Config) -> bool { - config - .gateway_jwt - .as_ref() - .is_some_and(|jwt| jwt.ttl_secs == 0) -} - -fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) { - if kubernetes_sandbox_jwt_expiry_disabled(config) { - warn!( - "Kubernetes gateway configured with non-expiring sandbox JWTs (gateway_jwt.ttl_secs = 0); set ttl_secs > 0 for shared Kubernetes deployments" - ); - } -} - pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { use grpc::workspace::{DEFAULT_WORKSPACE_NAME, WORKSPACE_OBJECT_TYPE}; use openshell_core::proto::Workspace; @@ -1028,20 +1587,20 @@ pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { #[cfg(test)] mod tests { use super::{ - BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, GatewayListenerScope, - MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, - bind_gateway_listeners, classify_initial_bytes, configured_compute_driver, - is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, - serve_gateway_listener, + BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, ExtensionKind, + GatewayListenerScope, MultiplexService, ServerState, TlsAcceptor, + allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, + configured_compute_driver, is_benign_tls_handshake_failure, + mint_gateway_extension_credential, serve_gateway_listener, }; use openshell_core::{ - ComputeDriverKind, Config, + Config, proto::{HealthRequest, open_shell_client::OpenShellClient}, }; use std::io::{Error, ErrorKind}; use std::net::SocketAddr; use std::sync::{ - Arc, + Arc, LazyLock, Mutex, atomic::{AtomicBool, Ordering}, }; use std::time::Duration; @@ -1056,6 +1615,114 @@ mod tests { tls_test_utils::{generate_test_certs_with_ca, install_rustls_provider}, }; + static DETECTION_PROBE_ORDER: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn record_detection_probe(name: &'static str, available: bool) -> bool { + DETECTION_PROBE_ORDER.lock().unwrap().push(name); + available + } + + fn unavailable_first_probe() -> bool { + record_detection_probe("first", false) + } + + fn available_second_probe() -> bool { + record_detection_probe("second", true) + } + + fn available_third_probe() -> bool { + record_detection_probe("third", true) + } + + fn extension_test_issuer() -> Arc { + let material = openshell_bootstrap::jwt::generate_jwt_key().expect("jwt key"); + Arc::new( + crate::auth::sandbox_jwt::SandboxJwtIssuer::from_pem( + material.signing_key_pem.as_bytes(), + material.kid, + "gateway-a", + Duration::from_secs(900), + ) + .expect("issuer"), + ) + } + + #[test] + fn plaintext_extension_endpoint_is_rejected_unless_explicitly_opted_out() { + let issuer = extension_test_issuer(); + + // Default posture: a plaintext endpoint cannot carry a bearer + // credential, so startup fails and names the opt-out. + let error = mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "http://host.openshell.internal:50051", + false, + ) + .expect_err("plaintext endpoint must not silently downgrade"); + assert!(error.to_string().contains("allow_insecure_transport")); + + // Explicit opt-out starts the gateway with no credential attached. + assert!( + mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "http://host.openshell.internal:50051", + true, + ) + .expect("opt-out must be permitted") + .is_none() + ); + } + + #[test] + fn authenticated_extension_endpoints_mint_a_credential() { + let issuer = extension_test_issuer(); + let credential = mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "https://content-guard.example:50051", + false, + ) + .expect("credential") + .expect("authenticated endpoint mints a credential"); + assert_eq!(credential.name, "content-guard"); + assert!(credential.slot.expires_at_ms().is_some_and(|ms| ms > 0)); + + // Unix sockets are gateway-local, so only interceptors can use them. + // A middleware endpoint must also be reachable from every supervisor. + let error = mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "unix:///run/openshell/content-guard.sock", + false, + ) + .expect_err("middleware cannot be reached over a gateway-local socket"); + assert!(error.to_string().contains("must use https://")); + + assert!( + mint_gateway_extension_credential( + &issuer, + ExtensionKind::Interceptor, + "quota", + "urn:openshell:extension:interceptor:quota", + "unix:///run/openshell/interceptors/quota.sock", + false, + ) + .expect("credential") + .is_some() + ); + } + fn test_driver_startup<'a>( config: &'a Config, file: Option<&'a super::config_file::ConfigFile>, @@ -1069,6 +1736,42 @@ mod tests { } } + fn test_compute_drivers() -> super::ComputeDriverRegistry { + let mut registry = super::ComputeDriverRegistry::new(); + for (name, priority) in [("alpha", 100), ("beta", 200), ("gamma", 300)] { + registry + .install( + super::ComputeDriverRegistration::new( + name, + priority, + None, + TestComputeDriverFactory, + ) + .unwrap() + .with_telemetry_category( + openshell_core::telemetry::TelemetryComputeDriver::anonymous_category( + "registered", + ), + ), + ) + .unwrap(); + } + registry + } + + #[derive(Clone, Copy)] + struct TestComputeDriverFactory; + + #[async_trait::async_trait] + impl super::ComputeDriverFactory for TestComputeDriverFactory { + async fn build( + &self, + _context: super::ComputeDriverBuildContext<'_>, + ) -> openshell_core::Result { + unreachable!("selection tests do not construct the driver") + } + } + fn test_tls_acceptor() -> (TempDir, TlsAcceptor) { install_rustls_provider(); @@ -1080,6 +1783,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + Vec::new(), ) .expect("failed to build tls acceptor"); @@ -1101,7 +1807,8 @@ mod tests { .with_database_url("sqlite::memory:?cache=shared") .with_bind_address(bind_addr) .with_server_sans(["*.dev.openshell.localhost"]) - .with_loopback_service_http(enable_loopback_service_http), + .with_loopback_service_http(enable_loopback_service_http) + .with_credential_drivers(["test-static"]), store, compute, crate::sandbox_index::SandboxIndex::new(), @@ -1374,160 +2081,193 @@ mod tests { #[test] fn configured_compute_driver_triggers_auto_detection_when_empty() { - let config = Config::new(None).with_compute_drivers(std::iter::empty::()); - // Empty drivers triggers auto-detection, which may return Some or None - // depending on the environment. This test verifies the auto-detection path - // is taken rather than immediately returning an error. - let result = configured_compute_driver(&config, test_driver_startup(&config, None)); - // Either we get a detected driver or an error about none being detected. - match result { - Ok(ConfiguredComputeDriver::Builtin(driver)) => { - assert!( - matches!( - driver, - ComputeDriverKind::Kubernetes - | ComputeDriverKind::Docker - | ComputeDriverKind::Podman - ), - "auto-detected unexpected driver: {driver:?}" - ); - } - Ok(ConfiguredComputeDriver::Remote { name }) => { - panic!("auto-detection returned remote driver: {name}"); - } - Err(e) => { - assert!( - e.to_string() - .contains("auto-detection found no suitable driver"), - "unexpected error: {e}" - ); - } + fn available() -> bool { + true } + + let mut registry = super::ComputeDriverRegistry::new(); + registry + .install( + super::ComputeDriverRegistration::new( + "detected", + 100, + Some(available), + TestComputeDriverFactory, + ) + .unwrap(), + ) + .unwrap(); + let config = Config::new(None).with_compute_drivers(std::iter::empty::()); + let result = + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + + let ConfiguredComputeDriver::Registered(registration) = result else { + panic!("auto-detection must select a registered driver"); + }; + assert_eq!(registration.name, "detected"); + } + + #[test] + fn registry_detection_reports_available_drivers_in_priority_order() { + DETECTION_PROBE_ORDER.lock().unwrap().clear(); + let mut registry = super::ComputeDriverRegistry::new(); + registry + .install( + super::ComputeDriverRegistration::new( + "third", + 300, + Some(available_third_probe), + TestComputeDriverFactory, + ) + .unwrap(), + ) + .unwrap(); + registry + .install( + super::ComputeDriverRegistration::new( + "first", + 100, + Some(unavailable_first_probe), + TestComputeDriverFactory, + ) + .unwrap(), + ) + .unwrap(); + registry + .install( + super::ComputeDriverRegistration::new( + "second", + 200, + Some(available_second_probe), + TestComputeDriverFactory, + ) + .unwrap(), + ) + .unwrap(); + + let detection = registry.detect(); + assert_eq!(detection.selected(), Some("second")); + assert_eq!( + detection + .available + .iter() + .map(String::as_str) + .collect::>(), + vec!["second", "third"] + ); + assert_eq!( + DETECTION_PROBE_ORDER.lock().unwrap().as_slice(), + ["first", "second", "third"] + ); + assert_eq!( + registry.installed_driver_names().collect::>(), + vec!["first", "second", "third"] + ); } #[test] fn configured_compute_driver_rejects_multiple_entries() { - let config = Config::new(None) - .with_compute_drivers([ComputeDriverKind::Kubernetes, ComputeDriverKind::Podman]); - let err = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err(); + let config = Config::new(None).with_compute_drivers(["alpha", "beta"]); + let err = configured_compute_driver( + &test_compute_drivers(), + &config, + test_driver_startup(&config, None), + ) + .unwrap_err(); assert!( err.to_string() .contains("multiple compute drivers are not supported yet") ); - assert!(err.to_string().contains("kubernetes,podman")); - } - - #[test] - fn configured_compute_driver_accepts_podman() { - let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Podman]); - let driver = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); - assert!(matches!( - driver, - ConfiguredComputeDriver::Builtin(ComputeDriverKind::Podman) - )); + assert!(err.to_string().contains("alpha,beta")); } #[test] - fn configured_compute_driver_accepts_vm() { - let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Vm]); + fn configured_compute_driver_accepts_registered_name() { + let config = Config::new(None).with_compute_drivers(["beta"]); + let registry = test_compute_drivers(); let driver = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); - assert!(matches!( - driver, - ConfiguredComputeDriver::Builtin(ComputeDriverKind::Vm) - )); - } - - #[test] - fn configured_compute_driver_accepts_docker() { - let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Docker]); - let driver = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + assert_eq!( + driver.telemetry_compute_driver(®istry).as_str(), + "registered" + ); assert!(matches!( driver, - ConfiguredComputeDriver::Builtin(ComputeDriverKind::Docker) + ConfiguredComputeDriver::Registered(registration) if registration.name == "beta" )); } #[test] fn configured_compute_driver_resolves_named_remote() { let config = Config::new(None).with_compute_drivers(["kyma"]); + let registry = test_compute_drivers(); let driver = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + assert_eq!( + driver.telemetry_compute_driver(®istry).as_str(), + "custom" + ); match driver { ConfiguredComputeDriver::Remote { name } => { assert_eq!(name, "kyma"); } - ConfiguredComputeDriver::Builtin(other) => { - panic!("expected remote driver, got builtin driver {other:?}") + ConfiguredComputeDriver::Registered(other) => { + panic!( + "expected remote driver, got registered driver {}", + other.name + ) } } } #[test] - fn configured_compute_driver_rejects_vm_endpoint_from_config() { + fn configured_compute_driver_uses_endpoint_override() { let config = Config::new(None) - .with_compute_drivers([ComputeDriverKind::Vm]) - .with_compute_driver_endpoint("vm", "/run/openshell/vm.sock"); + .with_compute_drivers(["alpha"]) + .with_compute_driver_endpoint("alpha", "/run/openshell/alpha.sock"); + let registry = test_compute_drivers(); - let err = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err(); - - assert!( - err.to_string() - .contains("reserved built-in driver and cannot be selected with a socket endpoint"), - "unexpected error: {err}" + let driver = + configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) + .unwrap(); + assert_eq!( + driver.telemetry_compute_driver(®istry).as_str(), + "registered" ); + assert!(matches!( + driver, + ConfiguredComputeDriver::Remote { name } if name == "alpha" + )); } #[test] - fn configured_compute_driver_rejects_builtin_endpoint() { + fn configured_compute_driver_uses_builtin_endpoint_override() { let config = Config::new(None) - .with_compute_drivers([ComputeDriverKind::Docker]) - .with_compute_driver_endpoint("docker", "/run/openshell/docker.sock"); - - let err = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err(); - - assert!( - err.to_string() - .contains("cannot be selected with a socket endpoint"), - "unexpected error: {err}" - ); - } + .with_compute_drivers(["beta"]) + .with_compute_driver_endpoint("beta", "/run/openshell/beta.sock"); - #[test] - fn kubernetes_sandbox_jwt_expiry_disabled_warns_for_zero_ttl() { - fn config_with_jwt_ttl(ttl_secs: u64) -> Config { - let mut config = Config::new(None); - config.gateway_jwt = Some(openshell_core::GatewayJwtConfig { - signing_key_path: "/tmp/signing.pem".into(), - public_key_path: "/tmp/public.pem".into(), - kid_path: "/tmp/kid".into(), - gateway_id: "openshell".to_string(), - ttl_secs, - }); - config - } - - assert!(kubernetes_sandbox_jwt_expiry_disabled( - &config_with_jwt_ttl(0) - )); - assert!(!kubernetes_sandbox_jwt_expiry_disabled( - &config_with_jwt_ttl(3600) + let driver = configured_compute_driver( + &test_compute_drivers(), + &config, + test_driver_startup(&config, None), + ) + .unwrap(); + assert!(matches!( + driver, + ConfiguredComputeDriver::Remote { name } if name == "beta" )); - assert!(!kubernetes_sandbox_jwt_expiry_disabled(&Config::new(None))); } #[tokio::test] - async fn failed_gateway_listener_bind_does_not_attempt_persisted_sandbox_resume() { + async fn failed_gateway_listener_bind_does_not_attempt_persisted_sandbox_start() { let occupied_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let occupied_address = occupied_listener.local_addr().unwrap(); - let resume_attempted = AtomicBool::new(false); + let start_attempted = AtomicBool::new(false); let primary_address: SocketAddr = "127.0.0.1:0".parse().unwrap(); let result: openshell_core::Result<()> = async { @@ -1536,7 +2276,7 @@ mod tests { &[docker_listener_requirement(occupied_address)], ) .await?; - resume_attempted.store(true, Ordering::SeqCst); + start_attempted.store(true, Ordering::SeqCst); Ok(()) } .await; @@ -1546,8 +2286,8 @@ mod tests { "binding the occupied extra gateway address should fail" ); assert!( - !resume_attempted.load(Ordering::SeqCst), - "persisted sandbox resume must not run before every gateway listener is bound" + !start_attempted.load(Ordering::SeqCst), + "persisted sandbox start must not run before every gateway listener is bound" ); } diff --git a/crates/openshell-server/src/main.rs b/crates/openshell-server/src/main.rs deleted file mode 100644 index 0f33c685f4..0000000000 --- a/crates/openshell-server/src/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! `OpenShell` Gateway binary entrypoint. - -use miette::Result; - -#[tokio::main] -async fn main() -> Result<()> { - openshell_server::cli::run_cli().await -} diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 114201b982..3ef774e4cb 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -17,15 +17,19 @@ use hyper_util::{ service::TowerToHyperService, }; use metrics::{counter, histogram}; -use openshell_core::Config; use openshell_core::proto::{ inference_server::InferenceServer, open_shell_server::OpenShellServer, }; +use openshell_core::{ + Config, + proto::{Provider, UpdateProviderRequest}, +}; use openshell_gateway_interceptors::{EvaluationContext, GatewayInterceptorRuntime}; use openshell_otel::HeaderMapExtractor; use opentelemetry::propagation::TextMapPropagator; use opentelemetry::trace::TraceContextExt as _; use opentelemetry_sdk::propagation::TraceContextPropagator; +use prost::Message; use std::collections::BTreeMap; use std::convert::Infallible; use std::future::Future; @@ -46,6 +50,7 @@ use crate::{ auth::identity::Identity, auth::oidc::{self, OidcAuthenticator}, auth::principal::{Principal, UserPrincipal}, + auth::workspace_authz::{MinWorkspaceRole, authorize_workspace}, gateway_listener::GatewayListenerScope, http_router, inference::InferenceService, @@ -267,8 +272,11 @@ impl MultiplexService { { let openshell = OpenShellServer::new(OpenShellService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); - let openshell = - GatewayInterceptorGrpcService::new(openshell, self.state.gateway_interceptors.clone()); + let openshell = GatewayInterceptorGrpcService::new( + openshell, + self.state.gateway_interceptors.clone(), + Some(self.state.clone()), + ); let inference = InferenceServer::new(InferenceService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { @@ -394,13 +402,19 @@ where struct GatewayInterceptorGrpcService { inner: S, interceptors: Option, + state: Option>, } impl GatewayInterceptorGrpcService { - fn new(inner: S, interceptors: Option) -> Self { + fn new( + inner: S, + interceptors: Option, + state: Option>, + ) -> Self { Self { inner, interceptors, + state, } } } @@ -424,6 +438,7 @@ where fn call(&mut self, req: Request) -> Self::Future { let interceptors = self.interceptors.clone(); + let state = self.state.clone(); let mut inner = self.inner.clone(); Box::pin(async move { @@ -437,11 +452,21 @@ where } let context = gateway_interceptor_context(req.extensions()); + let principal = req.extensions().get::().cloned(); let (parts, body) = req.into_parts(); - let body = match collect_intercepted_grpc_body(body).await { + let mut body = match collect_intercepted_grpc_body(body).await { Ok(body) => body, Err(status) => return Ok(status.into_http()), }; + if let Some(state) = state.as_ref() { + body = + match hydrate_update_provider_identity(&path, body, state, principal.as_ref()) + .await + { + Ok(body) => body, + Err(status) => return Ok(status.into_http()), + }; + } let intercepted = match interceptors.evaluate_request(&path, &body, &context).await { Ok(intercepted) => intercepted, @@ -497,6 +522,104 @@ where } } +const UPDATE_PROVIDER_PATH: &str = "/openshell.v1.OpenShell/UpdateProvider"; +const GRPC_FRAME_HEADER_LEN: usize = 5; + +/// Complete immutable provider identity before policy interception. +/// +/// Update requests intentionally omit immutable fields. Loading them here keeps +/// `provider update` a write-only operation while giving policy interceptors a +/// canonical proposed operation derived from trusted gateway state. +async fn hydrate_update_provider_identity( + path: &str, + body: Bytes, + state: &ServerState, + principal: Option<&Principal>, +) -> Result { + if path != UPDATE_PROVIDER_PATH { + return Ok(body); + } + + let mut request = decode_unary_grpc_message::(&body)?; + let Some(provider) = request.provider.as_mut() else { + return Ok(body); + }; + if !provider.r#type.is_empty() && !provider.profile_workspace.is_empty() { + return Ok(body); + } + let name = provider + .metadata + .as_ref() + .map(|metadata| metadata.name.as_str()) + .unwrap_or_default(); + if name.is_empty() { + return Ok(body); + } + + let principal = + principal.ok_or_else(|| tonic::Status::unauthenticated("authentication required"))?; + let authorized = authorize_workspace( + state.store.as_ref(), + &state.admin_role, + principal, + &request.workspace, + MinWorkspaceRole::Admin, + ) + .await?; + let workspace = + crate::grpc::workspace::resolve_workspace(state.store.as_ref(), &authorized.workspace) + .await? + .name; + let existing = state + .store + .get_message_by_name::(&workspace, name) + .await + .map_err(|error| tonic::Status::internal(format!("provider lookup failed: {error}")))? + .ok_or_else(|| tonic::Status::not_found(format!("provider '{name}' not found")))?; + + if provider.r#type.is_empty() { + provider.r#type = existing.r#type; + } + if provider.profile_workspace.is_empty() { + provider.profile_workspace = existing.profile_workspace; + } + + encode_unary_grpc_message(&request) +} + +fn decode_unary_grpc_message(body: &[u8]) -> Result +where + M: Message + Default, +{ + if body.len() < GRPC_FRAME_HEADER_LEN { + return Err(tonic::Status::invalid_argument("gRPC frame is too short")); + } + if body[0] != 0 { + return Err(tonic::Status::unimplemented( + "gateway interceptors do not support compressed gRPC frames", + )); + } + let message_len = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize; + if body.len() != GRPC_FRAME_HEADER_LEN + message_len { + return Err(tonic::Status::invalid_argument( + "gRPC body must contain exactly one frame", + )); + } + M::decode(&body[GRPC_FRAME_HEADER_LEN..]) + .map_err(|error| tonic::Status::invalid_argument(format!("invalid gRPC message: {error}"))) +} + +fn encode_unary_grpc_message(message: &M) -> Result { + let message = message.encode_to_vec(); + let message_len = u32::try_from(message.len()) + .map_err(|_| tonic::Status::resource_exhausted("gRPC message exceeds u32"))?; + let mut frame = Vec::with_capacity(GRPC_FRAME_HEADER_LEN + message.len()); + frame.push(0); + frame.extend_from_slice(&message_len.to_be_bytes()); + frame.extend_from_slice(&message); + Ok(Bytes::from(frame)) +} + async fn collect_intercepted_grpc_body(body: BoxBody) -> Result { Limited::new(body, MAX_INTERCEPTED_GRPC_BODY_SIZE) .collect() @@ -622,7 +745,7 @@ fn gateway_principal_fields(principal: &Principal) -> BTreeMap { match &sandbox.source { SandboxIdentitySource::BootstrapJwt { .. } => "bootstrap_jwt", SandboxIdentitySource::BootstrapCert { .. } => "bootstrap_cert", - SandboxIdentitySource::K8sServiceAccount { .. } => "k8s_service_account", + SandboxIdentitySource::ComputeDriver { .. } => "compute_driver", } .to_string(), ); @@ -853,10 +976,9 @@ where /// Assemble the authenticator chain for the gateway. /// /// Chain order (first-match-wins): -/// 1. `K8sServiceAccountAuthenticator` (path-scoped to `IssueSandboxToken`) -/// — exchanges a projected SA token for a `Principal::Sandbox` so the -/// `IssueSandboxToken` handler can mint a gateway JWT. No-op on every -/// other path; only present when the gateway runs in-cluster. +/// 1. `ComputeDriverAuthenticator` (path-scoped to `IssueSandboxToken`) +/// — delegates a driver-native credential and receives a sandbox identity +/// so the handler can mint a gateway JWT. No-op on every other path. /// 2. `SandboxJwtAuthenticator` — validates gateway-minted JWTs. Recognized /// via a distinctive `kid` so non-matching Bearer tokens fall through. /// 3. `OidcAuthenticator` — validates user Bearer tokens against the @@ -874,8 +996,8 @@ where /// to pass-through unless mTLS or local unauthenticated users are enabled. fn build_authenticator_chain(state: &ServerState) -> Option { let mut authenticators: Vec> = Vec::new(); - if let Some(k8s) = state.k8s_sa_authenticator.clone() { - authenticators.push(k8s); + if let Some(driver) = state.compute_driver_authenticator.clone() { + authenticators.push(driver); } if let Some(jwt) = state.sandbox_jwt_authenticator.clone() { authenticators.push(jwt); @@ -1325,7 +1447,6 @@ mod tests { ProviderProfileSnapshotRequest, gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; - use prost::Message as _; use std::convert::Infallible; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1466,6 +1587,7 @@ mod tests { failure_policy: "fail_open".to_string(), }], provider_profiles: false, + expected_audience: String::new(), })) } @@ -1652,6 +1774,57 @@ mod tests { assert_eq!(status.code(), tonic::Code::ResourceExhausted); } + #[tokio::test] + async fn update_provider_interception_hydrates_identity_from_trusted_state() { + let state = crate::grpc::test_support::test_server_state().await; + let existing = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-id".to_string(), + name: "managed-provider".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "agent-tool-gateway".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }; + state.store.put_message(&existing).await.unwrap(); + + let request = UpdateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "managed-provider".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + credentials: std::collections::HashMap::from([( + "TOKEN".to_string(), + "rotated".to_string(), + )]), + ..Default::default() + }), + workspace: "default".to_string(), + ..Default::default() + }; + let authed = crate::grpc::test_support::authed_request(()); + let principal = authed.extensions().get::().unwrap(); + + let hydrated = hydrate_update_provider_identity( + UPDATE_PROVIDER_PATH, + grpc_frame(&request.encode_to_vec()), + state.as_ref(), + Some(principal), + ) + .await + .unwrap(); + let hydrated = decode_unary_grpc_message::(&hydrated).unwrap(); + let provider = hydrated.provider.unwrap(); + + assert_eq!(provider.r#type, "agent-tool-gateway"); + assert_eq!(provider.profile_workspace, "default"); + assert_eq!(provider.credentials.get("TOKEN").unwrap(), "rotated"); + } + #[tokio::test] async fn intercepted_grpc_response_preserves_body_and_trailers() { let bytes = Bytes::from_static(b"committed-response"); @@ -1719,7 +1892,7 @@ mod tests { ))) } }); - let mut service = GatewayInterceptorGrpcService::new(inner, Some(runtime)); + let mut service = GatewayInterceptorGrpcService::new(inner, Some(runtime), None); let request_body = grpc_frame(&CreateSandboxRequest::default().encode_to_vec()); let request = Request::builder() .uri("/openshell.v1.OpenShell/CreateSandbox") @@ -2538,6 +2711,18 @@ mod tests { }) } + fn provider_writer_principal() -> Principal { + Principal::User(UserPrincipal { + identity: Identity { + subject: "provider-rotation-job".to_string(), + display_name: None, + roles: vec!["openshell-admin".to_string()], + scopes: vec!["provider:write".to_string()], + provider: IdentityProvider::Oidc, + }, + }) + } + fn mtls_identity(subject: &str) -> Identity { Identity { subject: subject.to_string(), @@ -2794,6 +2979,41 @@ mod tests { } } + #[tokio::test] + async fn provider_write_scope_allows_update_without_provider_read() { + let policy = AuthzPolicy { + admin_role: "openshell-admin".to_string(), + user_role: "openshell-user".to_string(), + scopes_enabled: true, + }; + + let mock = Arc::new(MockAuthenticator::returning(Ok(Some( + provider_writer_principal(), + )))); + let chain = AuthenticatorChain::new(vec![mock]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), Some(policy.clone())); + let response = router + .call(empty_request(UPDATE_PROVIDER_PATH)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(seen.lock().unwrap().is_some()); + + let mock = Arc::new(MockAuthenticator::returning(Ok(Some( + provider_writer_principal(), + )))); + let chain = AuthenticatorChain::new(vec![mock]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), Some(policy)); + let response = router + .call(empty_request("/openshell.v1.OpenShell/GetProvider")) + .await + .unwrap(); + assert_eq!(grpc_status(&response).as_deref(), Some("7")); + assert!(seen.lock().unwrap().is_none()); + } + #[tokio::test] async fn sandbox_principal_is_denied_on_user_and_admin_methods() { for path in [ diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index cbe23f4231..da12500050 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -37,7 +37,35 @@ const DEFAULT_SERVICE_NAME: &str = "openshell-gateway"; /// Instrumentation scope recorded on spans this gateway emits. const INSTRUMENTATION_SCOPE: &str = "openshell-gateway"; -fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { +/// Gateway identity recorded on every exported span. +#[derive(Debug, Clone, Copy, Default)] +pub struct GatewayResourceAttributes<'a> { + name: Option<&'a str>, + compute_driver: Option<&'a str>, +} + +impl<'a> GatewayResourceAttributes<'a> { + pub fn new(name: Option<&'a str>, compute_driver: Option<&'a str>) -> Self { + Self { + name, + compute_driver, + } + } + /// The configured gateway installation name, if any. + pub fn name(&self) -> Option<&'a str> { + self.name + } + + /// The configured compute-driver name, if any. + pub fn compute_driver(&self) -> Option<&'a str> { + self.compute_driver + } +} + +fn trace_config<'cfg>( + cfg: &'cfg OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> OtlpTraceConfig<'cfg> { let service_name = cfg .service_name .as_deref() @@ -52,13 +80,16 @@ fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { endpoint: &cfg.endpoint, service_name, service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), + resource_attributes: openshell_otel::gateway_resource_attributes( + gateway.name(), + gateway.compute_driver(), + ), } } #[cfg(test)] -fn build_resource(cfg: &OtlpConfig) -> Resource { - openshell_otel::resource_for(&trace_config(cfg)) +fn build_resource(cfg: &OtlpConfig, gateway: GatewayResourceAttributes<'_>) -> Resource { + openshell_otel::resource_for(&trace_config(cfg, gateway)) } /// Build a tracer provider exporting over OTLP/gRPC to the configured endpoint. @@ -70,8 +101,11 @@ fn build_resource(cfg: &OtlpConfig) -> Resource { /// The sampler and span limits are left at the SDK's defaults, which are /// themselves resolved from `OTEL_*` env vars (see the module docs). #[cfg(test)] -fn build_provider(cfg: &OtlpConfig) -> Result { - openshell_otel::build_provider(&trace_config(cfg)) +fn build_provider( + cfg: &OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> Result { + openshell_otel::build_provider(&trace_config(cfg, gateway)) } /// Resolve the tracer provider for a gateway config file's optional @@ -82,19 +116,31 @@ fn build_provider(cfg: &OtlpConfig) -> Result { /// /// The error is returned rather than logged because the provider is built /// before the subscriber it attaches to, so logging here would go nowhere. -pub fn provider_for(cfg: Option<&OtlpConfig>) -> (Option, Option) { - openshell_otel::provider_for(cfg.map(trace_config)) +pub fn provider_for( + cfg: Option<&OtlpConfig>, + gateway: GatewayResourceAttributes<'_>, +) -> (Option, Option) { + openshell_otel::provider_for(cfg.map(|cfg| trace_config(cfg, gateway))) } /// Build the `tracing` layer that forwards spans to `provider`. /// /// Events stay on the gateway's logging layers. Spans emitted by the /// OpenTelemetry crates are excluded to prevent recursive export traffic. -pub fn layer(provider: &SdkTracerProvider) -> openshell_otel::OtlpLayer +pub fn layer( + provider: &SdkTracerProvider, + driver: Option, +) -> openshell_otel::TargetOtlpLayer where S: Subscriber + for<'span> LookupSpan<'span>, { - openshell_otel::layer(provider, INSTRUMENTATION_SCOPE) + openshell_otel::layer_excluding_target_prefixes( + provider, + INSTRUMENTATION_SCOPE, + driver + .into_iter() + .flat_map(openshell_otel::ComputeDriverTracing::in_process_targets), + ) } /// Isolated in-memory span exporters for tracing tests. @@ -127,7 +173,7 @@ pub mod test_exporter { let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() .with_simple_exporter(exporter.clone()) .build(); - let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); + let subscriber = tracing_subscriber::registry().with(super::layer(&provider, None)); let dispatch = tracing::Dispatch::new(subscriber); TracingTestGuard { _default: tracing::dispatcher::set_default(&dispatch), @@ -253,6 +299,10 @@ mod tests { } } + fn build_test_resource(cfg: &OtlpConfig) -> Resource { + build_resource(cfg, GatewayResourceAttributes::default()) + } + #[test] fn resource_defaults_the_service_name() { let _lock = crate::TEST_ENV_LOCK @@ -261,7 +311,7 @@ mod tests { let _env = EnvVarGuard::remove("OTEL_SERVICE_NAME"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -270,7 +320,7 @@ mod tests { fn resource_honors_configured_service_name_and_carries_version() { let mut cfg = config(); cfg.service_name = Some("gateway-staging".into()); - let resource = build_resource(&cfg); + let resource = build_test_resource(&cfg); assert_eq!( resource @@ -286,6 +336,31 @@ mod tests { ); } + #[test] + fn resource_carries_gateway_name_and_compute_driver() { + let resource = build_resource( + &config(), + GatewayResourceAttributes::new(Some("vm-dev"), Some("vm")), + ); + + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.name", + )) + .map(|v| v.to_string()), + Some("vm-dev".to_string()) + ); + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.compute_driver", + )) + .map(|v| v.to_string()), + Some("vm".to_string()) + ); + } + struct EnvVarGuard { key: &'static str, original: Option, @@ -340,7 +415,7 @@ mod tests { cfg.service_name = Some("from-config".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some("from-config".to_string()) ); } @@ -355,7 +430,7 @@ mod tests { let _env = EnvVarGuard::set("OTEL_SERVICE_NAME", "from-env"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some("from-env".to_string()) ); } @@ -370,7 +445,7 @@ mod tests { let mut cfg = config(); cfg.service_name = Some(" ".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -379,7 +454,8 @@ mod tests { fn provider_rejects_a_malformed_endpoint() { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let err = build_provider(&cfg).expect_err("malformed endpoint"); + let err = build_provider(&cfg, GatewayResourceAttributes::default()) + .expect_err("malformed endpoint"); assert!( err.to_string().contains("definitely not a url"), "error names the offending endpoint: {err}" @@ -390,7 +466,10 @@ mod tests { fn provider_rejects_an_empty_endpoint() { let mut cfg = config(); cfg.endpoint = " ".into(); - assert!(build_provider(&cfg).is_err(), "empty endpoint is rejected"); + assert!( + build_provider(&cfg, GatewayResourceAttributes::default()).is_err(), + "empty endpoint is rejected" + ); } #[tokio::test] @@ -398,7 +477,8 @@ mod tests { // The OTLP batch exporter connects lazily, so a valid endpoint must // build even when nothing is listening — the gateway must not fail to // start because its collector is down. - let provider = build_provider(&config()).expect("provider builds"); + let provider = build_provider(&config(), GatewayResourceAttributes::default()) + .expect("provider builds"); provider.shutdown().ok(); } @@ -407,7 +487,7 @@ mod tests { /// error for the caller to log — see the misconfigured-endpoint test. #[tokio::test] async fn absent_otlp_table_disables_export() { - let (provider, err) = provider_for(None); + let (provider, err) = provider_for(None, GatewayResourceAttributes::default()); assert!(provider.is_none(), "export is off"); assert!( err.is_none(), @@ -417,7 +497,7 @@ mod tests { #[tokio::test] async fn present_otlp_table_enables_export() { - let (provider, err) = provider_for(Some(&config())); + let (provider, err) = provider_for(Some(&config()), GatewayResourceAttributes::default()); assert!(err.is_none()); provider.expect("provider is present").shutdown().ok(); } @@ -430,7 +510,7 @@ mod tests { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let (provider, err) = provider_for(Some(&cfg)); + let (provider, err) = provider_for(Some(&cfg), GatewayResourceAttributes::default()); assert!( provider.is_none(), "a bad endpoint degrades to no export rather than failing startup" diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 3ad20e1082..716f26dad9 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -26,6 +26,12 @@ pub const DRAFT_CHUNK_OBJECT_TYPE: &str = "draft_policy_chunk"; pub type PersistenceResult = Result; +/// Maximum number of object ids sent in one set-based delete statement. +/// +/// Keep this well below `SQLite`'s bind-variable limit. Backends split larger +/// requests into independently retryable, bounded write statements. +pub const DELETE_MANY_BATCH_SIZE: usize = 128; + /// Persistence-layer error type. #[derive(Debug, Error, Clone)] pub enum PersistenceError { @@ -101,6 +107,30 @@ pub struct ObjectRecord { pub resource_version: u64, } +/// Stable position in the global object-listing order. +/// +/// Keyset consumers must use the matching store method for the order encoded +/// here: workspace-scoped lists use `created_at_ms`, `name`, and `id`; global +/// lists additionally include `workspace`. +#[derive(Debug, Clone)] +pub struct ObjectCursor { + pub created_at_ms: i64, + pub name: String, + pub workspace: String, + pub id: String, +} + +impl From<&ObjectRecord> for ObjectCursor { + fn from(record: &ObjectRecord) -> Self { + Self { + created_at_ms: record.created_at_ms, + name: record.name.clone(), + workspace: record.workspace.clone(), + id: record.id.clone(), + } + } +} + /// Write condition for compare-and-swap operations. #[derive(Debug, Clone, Copy)] pub enum WriteCondition { @@ -339,6 +369,71 @@ impl Store { )) } + /// Atomically insert a generic named object with an application-owned scope. + /// + /// Unlike [`Self::put_scoped`], this never updates an existing object. A + /// duplicate id or `(object_type, workspace, name)` returns + /// [`PersistenceError::UniqueViolation`]. + #[allow(clippy::too_many_arguments)] + #[tracing::instrument( + name = "store", + skip_all, + fields(otel.name = "store.create_scoped", otel.status_code = tracing::field::Empty, object_type = %object_type, object.id = %id, object.name = %name, workspace = %workspace, scope = %scope) + )] + pub async fn create_scoped( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + scope: &str, + payload: &[u8], + labels: Option<&str>, + ) -> PersistenceResult { + store_dispatch_traced!(self.create_scoped( + object_type, + id, + name, + workspace, + scope, + payload, + labels + )) + } + + /// Atomically insert a named object only if its workspace has fewer than + /// `max_count` objects of the same type. + /// + /// Returns `Ok(None)` when the quota is already full. A duplicate id or + /// `(object_type, workspace, name)` still returns + /// [`PersistenceError::UniqueViolation`]. + #[allow(clippy::too_many_arguments)] + #[tracing::instrument( + name = "store", + skip_all, + fields(otel.name = "store.create_if_workspace_count_below", otel.status_code = tracing::field::Empty, object_type = %object_type, object.id = %id, object.name = %name, workspace = %workspace, max_count = max_count) + )] + pub async fn create_if_workspace_count_below( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + payload: &[u8], + labels: Option<&str>, + max_count: u64, + ) -> PersistenceResult> { + store_dispatch_traced!(self.create_if_workspace_count_below( + object_type, + id, + name, + workspace, + payload, + labels, + max_count + )) + } + /// Fetch an object by id. #[tracing::instrument( name = "store", @@ -383,6 +478,22 @@ impl Store { store_dispatch_traced!(self.delete(object_type, id)) } + /// Delete objects of one type by id in bounded, set-based statements. + #[tracing::instrument( + name = "store", + skip_all, + fields( + otel.name = "store.delete_many", + otel.status_code = tracing::field::Empty, + object_type = %object_type, + object_count = ids.len(), + batch_count = ids.len().div_ceil(DELETE_MANY_BATCH_SIZE), + ) + )] + pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult { + store_dispatch_traced!(self.delete_many(object_type, ids)) + } + /// Count objects of a given type within a workspace. #[tracing::instrument( name = "store", @@ -467,6 +578,46 @@ impl Store { store_dispatch_traced!(self.list_by_type(object_type, limit, offset)) } + /// List workspace objects after a stable cursor, without offset drift. + #[tracing::instrument( + name = "store", + skip_all, + fields( + otel.name = "store.list_after", + otel.status_code = tracing::field::Empty, + object_type = %object_type, + workspace = %workspace, + ) + )] + pub async fn list_after( + &self, + object_type: &str, + workspace: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + store_dispatch_traced!(self.list_after(object_type, workspace, after, limit)) + } + + /// List objects across workspaces after a stable cursor, without offset drift. + #[tracing::instrument( + name = "store", + skip_all, + fields( + otel.name = "store.list_by_type_after", + otel.status_code = tracing::field::Empty, + object_type = %object_type, + ) + )] + pub async fn list_by_type_after( + &self, + object_type: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + store_dispatch_traced!(self.list_by_type_after(object_type, after, limit)) + } + /// List objects by type and application-owned scope. /// /// Workspace filtering is intentionally omitted: scope values are sandbox diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 9195f5dda4..19c50c6187 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use super::{ - DraftChunkRecord, ObjectRecord, PersistenceError, PersistenceResult, PolicyRecord, - WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, + DraftChunkRecord, ObjectCursor, ObjectRecord, PersistenceError, PersistenceResult, + PolicyRecord, WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, @@ -14,11 +14,11 @@ use openshell_core::SetResourceVersion; use openshell_core::proto::Sandbox; use prost::Message; use sqlx::postgres::PgPoolOptions; -use sqlx::{Connection, PgPool, Row}; +use sqlx::{Connection, PgPool, Postgres, QueryBuilder, Row}; static POSTGRES_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/postgres"); -use super::{DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; +use super::{DELETE_MANY_BATCH_SIZE, DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; #[derive(Debug, Clone)] pub struct PostgresStore { @@ -293,6 +293,117 @@ ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET Ok(()) } + #[allow(clippy::too_many_arguments)] + pub async fn create_scoped( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + scope: &str, + payload: &[u8], + labels: Option<&str>, + ) -> PersistenceResult { + let now_ms = current_time_ms(); + let labels_jsonb: Option = labels + .map(serde_json::from_str) + .transpose() + .map_err(|e| PersistenceError::Encode(format!("invalid labels JSON: {e}")))?; + + let row = sqlx::query( + r" +INSERT INTO objects (object_type, id, name, workspace, scope, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7, COALESCE($8, '{}'::jsonb), 1) +RETURNING resource_version, created_at_ms, updated_at_ms +", + ) + .bind(object_type) + .bind(id) + .bind(name) + .bind(workspace) + .bind(scope) + .bind(payload) + .bind(now_ms) + .bind(labels_jsonb) + .fetch_one(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + let resource_version_i64: i64 = row.try_get("resource_version").unwrap_or(1); + Ok(WriteResult { + resource_version: resource_version_i64.max(1).cast_unsigned(), + created_at_ms: row.get("created_at_ms"), + updated_at_ms: row.get("updated_at_ms"), + }) + } + + #[allow(clippy::too_many_arguments)] + pub async fn create_if_workspace_count_below( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + payload: &[u8], + labels: Option<&str>, + max_count: u64, + ) -> PersistenceResult> { + let now_ms = current_time_ms(); + let labels_jsonb: Option = labels + .map(serde_json::from_str) + .transpose() + .map_err(|e| PersistenceError::Encode(format!("invalid labels JSON: {e}")))?; + let mut tx = self.pool.begin().await.map_err(|e| map_db_error(&e))?; + + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))") + .bind(object_type) + .bind(workspace) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + let count = u64::try_from(row.0).unwrap_or(0); + if count >= max_count { + tx.commit().await.map_err(|e| map_db_error(&e))?; + return Ok(None); + } + + 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, COALESCE($7, '{}'::jsonb), 1) +RETURNING resource_version, created_at_ms, updated_at_ms +", + ) + .bind(object_type) + .bind(id) + .bind(name) + .bind(workspace) + .bind(payload) + .bind(now_ms) + .bind(labels_jsonb) + .fetch_one(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + + let resource_version_i64: i64 = row.try_get("resource_version").unwrap_or(1); + Ok(Some(WriteResult { + resource_version: resource_version_i64.max(1).cast_unsigned(), + created_at_ms: row.get("created_at_ms"), + updated_at_ms: row.get("updated_at_ms"), + })) + } + pub async fn get( &self, object_type: &str, @@ -347,6 +458,28 @@ WHERE object_type = $1 AND workspace = $2 AND name = $3 Ok(result.rows_affected() > 0) } + pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult { + let mut deleted = 0_u64; + for ids in ids.chunks(DELETE_MANY_BATCH_SIZE) { + let mut query = + QueryBuilder::::new("DELETE FROM objects WHERE object_type = "); + query.push_bind(object_type).push(" AND id IN ("); + let mut separated = query.separated(", "); + for id in ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + + deleted += query + .build() + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))? + .rows_affected(); + } + Ok(deleted) + } + pub async fn count_in_workspace( &self, object_type: &str, @@ -456,6 +589,33 @@ LIMIT $2 OFFSET $3 Ok(rows.into_iter().map(row_to_object_record).collect()) } + pub async fn list_after( + &self, + object_type: &str, + workspace: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND workspace = $2 AND (created_at_ms, name, id) > ($3, $4, $5) ORDER BY created_at_ms, name, id LIMIT $6").bind(object_type).bind(workspace).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await + } else { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND workspace = $2 ORDER BY created_at_ms, name, id LIMIT $3").bind(object_type).bind(workspace).bind(i64::from(limit)).fetch_all(&self.pool).await + }.map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + pub async fn list_by_type_after( + &self, + object_type: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND (created_at_ms, name, workspace, id) > ($2, $3, $4, $5) ORDER BY created_at_ms, name, workspace, id LIMIT $6").bind(object_type).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.workspace).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await + } else { + sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 ORDER BY created_at_ms, name, workspace, id LIMIT $2").bind(object_type).bind(i64::from(limit)).fetch_all(&self.pool).await + }.map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } pub async fn list_with_membership( &self, @@ -1135,6 +1295,28 @@ WHERE object_type = $1 AND id = $2 AND status = 'pending' Ok(result.rows_affected() > 0) } + pub async fn update_draft_chunk_evaluation( + &self, + chunk: &DraftChunkRecord, + ) -> PersistenceResult { + let payload = draft_chunk_payload_from_record(chunk)?; + let result = sqlx::query( + r#" +UPDATE "objects" +SET "payload" = $3, "updated_at_ms" = $4 +WHERE "object_type" = $1 AND "id" = $2 AND "status" IN ('pending', 'rejected') +"#, + ) + .bind(DRAFT_CHUNK_OBJECT_TYPE) + .bind(&chunk.id) + .bind(payload) + .bind(chunk.last_seen_ms) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected() > 0) + } + pub async fn delete_draft_chunks( &self, sandbox_id: &str, diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index b54c41e111..c945c417f9 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use super::{ - DraftChunkRecord, ObjectRecord, PersistenceError, PersistenceResult, PolicyRecord, - WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, + DraftChunkRecord, ObjectCursor, ObjectRecord, PersistenceError, PersistenceResult, + PolicyRecord, WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, @@ -15,13 +15,13 @@ use openshell_core::paths::set_file_owner_only; use openshell_core::proto::Sandbox; use prost::Message; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -use sqlx::{Connection, Row, SqlitePool}; +use sqlx::{Connection, QueryBuilder, Row, Sqlite, SqlitePool}; use std::path::{Path, PathBuf}; use std::str::FromStr; static SQLITE_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/sqlite"); -use super::{DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; +use super::{DELETE_MANY_BATCH_SIZE, DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}; #[derive(Debug, Clone)] pub struct SqliteStore { @@ -319,6 +319,105 @@ ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPD Ok(()) } + #[allow(clippy::too_many_arguments)] + pub async fn create_scoped( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + scope: &str, + payload: &[u8], + labels: Option<&str>, + ) -> PersistenceResult { + let now_ms = current_time_ms(); + + sqlx::query( + r#" +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "scope", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, 1) +"#, + ) + .bind(object_type) + .bind(id) + .bind(name) + .bind(workspace) + .bind(scope) + .bind(payload) + .bind(now_ms) + .bind(labels.unwrap_or("{}")) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(WriteResult { + resource_version: 1, + created_at_ms: now_ms, + updated_at_ms: now_ms, + }) + } + + #[allow(clippy::too_many_arguments)] + pub async fn create_if_workspace_count_below( + &self, + object_type: &str, + id: &str, + name: &str, + workspace: &str, + payload: &[u8], + labels: Option<&str>, + max_count: u64, + ) -> PersistenceResult> { + let now_ms = current_time_ms(); + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|e| map_db_error(&e))?; + + let row: (i64,) = sqlx::query_as( + r#" +SELECT COUNT(*) FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + let count = u64::try_from(row.0).unwrap_or(0); + if count >= max_count { + tx.commit().await.map_err(|e| map_db_error(&e))?; + return Ok(None); + } + + 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) +"#, + ) + .bind(object_type) + .bind(id) + .bind(name) + .bind(workspace) + .bind(payload) + .bind(now_ms) + .bind(labels.unwrap_or("{}")) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + + Ok(Some(WriteResult { + resource_version: 1, + created_at_ms: now_ms, + updated_at_ms: now_ms, + })) + } + pub async fn get( &self, object_type: &str, @@ -378,6 +477,27 @@ WHERE "object_type" = ?1 AND "id" = ?2 Ok(result.rows_affected() > 0) } + pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult { + let mut deleted = 0_u64; + for ids in ids.chunks(DELETE_MANY_BATCH_SIZE) { + let mut query = QueryBuilder::::new("DELETE FROM objects WHERE object_type = "); + query.push_bind(object_type).push(" AND id IN ("); + let mut separated = query.separated(", "); + for id in ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + + deleted += query + .build() + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))? + .rows_affected(); + } + Ok(deleted) + } + pub async fn count_in_workspace( &self, object_type: &str, @@ -503,6 +623,77 @@ LIMIT ?2 OFFSET ?3 Ok(rows.into_iter().map(row_to_object_record).collect()) } + pub async fn list_after( + &self, + object_type: &str, + workspace: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query( + r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 + AND ("created_at_ms", "name", "id") > (?3, ?4, ?5) +ORDER BY "created_at_ms" ASC, "name" ASC, "id" ASC +LIMIT ?6 +"#, + ) + .bind(object_type) + .bind(workspace) + .bind(cursor.created_at_ms) + .bind(&cursor.name) + .bind(&cursor.id) + .bind(i64::from(limit)) + .fetch_all(&self.pool) + .await + } else { + sqlx::query( + r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +ORDER BY "created_at_ms" ASC, "name" ASC, "id" ASC +LIMIT ?3 +"#, + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .fetch_all(&self.pool) + .await + } + .map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + pub async fn list_by_type_after( + &self, + object_type: &str, + after: Option<&ObjectCursor>, + limit: u32, + ) -> PersistenceResult> { + let rows = if let Some(cursor) = after { + sqlx::query(r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 + AND ("created_at_ms", "name", "workspace", "id") > (?2, ?3, ?4, ?5) +ORDER BY "created_at_ms" ASC, "name" ASC, "workspace" ASC, "id" ASC +LIMIT ?6 +"#).bind(object_type).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.workspace).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await + } else { + sqlx::query(r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 +ORDER BY "created_at_ms" ASC, "name" ASC, "workspace" ASC, "id" ASC +LIMIT ?2 +"#).bind(object_type).bind(i64::from(limit)).fetch_all(&self.pool).await + }.map_err(|e| map_db_error(&e))?; + Ok(rows.into_iter().map(row_to_object_record).collect()) + } pub async fn list_with_membership( &self, @@ -1202,6 +1393,28 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "status" = 'pending' Ok(result.rows_affected() > 0) } + pub async fn update_draft_chunk_evaluation( + &self, + chunk: &DraftChunkRecord, + ) -> PersistenceResult { + let payload = draft_chunk_payload_from_record(chunk)?; + let result = sqlx::query( + r#" +UPDATE "objects" +SET "payload" = ?3, "updated_at_ms" = ?4 +WHERE "object_type" = ?1 AND "id" = ?2 AND "status" IN ('pending', 'rejected') +"#, + ) + .bind(DRAFT_CHUNK_OBJECT_TYPE) + .bind(&chunk.id) + .bind(payload) + .bind(chunk.last_seen_ms) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected() > 0) + } + pub async fn delete_draft_chunks( &self, sandbox_id: &str, diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index 9b6079cae4..7882c9246c 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -35,6 +35,7 @@ async fn failed_store_calls_are_marked_on_the_span() { /// the span must stay clean — otherwise every lease a replica does not win, and /// every gateway restart, exports as a failure. #[tokio::test] +#[ignore = "flaky under concurrent test execution"] async fn expected_conflicts_leave_the_span_unmarked() { use crate::otel_tracing::test_exporter; @@ -78,6 +79,7 @@ async fn expected_conflicts_leave_the_span_unmarked() { /// Span names stay low-cardinality so they group across object types; what /// each call touched is carried as attributes. #[tokio::test] +#[ignore = "flaky under concurrent test execution"] async fn store_spans_record_what_they_touched_as_attributes() { use crate::otel_tracing::test_exporter; @@ -364,6 +366,116 @@ async fn sqlite_delete_behavior() { assert!(!deleted_again); } +#[tokio::test] +async fn delete_many_is_bounded_idempotent_and_type_scoped() { + let store = test_store().await; + let mut ids = Vec::new(); + for idx in 0..(super::DELETE_MANY_BATCH_SIZE + 12) { + let id = format!("sandbox-{idx}"); + store + .put( + "sandbox", + &id, + &format!("name-{idx}"), + "default", + b"payload", + None, + ) + .await + .unwrap(); + ids.push(id); + } + store + .put( + "provider", + "other-type", + "other-type", + "default", + b"payload", + None, + ) + .await + .unwrap(); + + ids.extend([ + "missing".to_string(), + "other-type".to_string(), + "sandbox-0".to_string(), + ]); + let expected = u64::try_from(super::DELETE_MANY_BATCH_SIZE + 12).unwrap(); + assert_eq!(store.delete_many("sandbox", &ids).await.unwrap(), expected); + assert_eq!(store.delete_many("sandbox", &ids).await.unwrap(), 0); + assert_eq!(store.delete_many("sandbox", &[]).await.unwrap(), 0); + assert!(store.get("provider", "other-type").await.unwrap().is_some()); +} + +#[tokio::test] +async fn file_backed_sqlite_bulk_delete_allows_concurrent_control_reads() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let url = format!("sqlite:{}?mode=rwc", tmp.path().join("bulk.db").display()); + let store = Store::connect(&url) + .await + .expect("connect file-backed store"); + store + .put( + "provider", + "control-row", + "control-row", + "default", + b"control", + None, + ) + .await + .unwrap(); + + let mut ids = Vec::new(); + for idx in 0..500 { + let id = format!("session-{idx}"); + store + .put("ssh_session", &id, &id, "default", b"payload", None) + .await + .unwrap(); + ids.push(id); + } + + let stop = Arc::new(AtomicBool::new(false)); + let read_store = store.clone(); + let read_stop = stop.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let reader = tokio::spawn(async move { + let mut reads = 0_usize; + let mut started_tx = Some(started_tx); + while !read_stop.load(Ordering::Relaxed) { + read_store + .get("provider", "control-row") + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "control row disappeared".to_string())?; + reads += 1; + if let Some(started_tx) = started_tx.take() { + let _ = started_tx.send(()); + } + tokio::task::yield_now().await; + } + Ok::(reads) + }); + started_rx.await.expect("reader started"); + + assert_eq!(store.delete_many("ssh_session", &ids).await.unwrap(), 500); + stop.store(true, Ordering::Relaxed); + assert!(reader.await.unwrap().unwrap() > 0); + assert!( + store + .get("provider", "control-row") + .await + .unwrap() + .is_some() + ); +} + #[tokio::test] async fn sqlite_protobuf_round_trip() { let store = test_store().await; @@ -1300,6 +1412,65 @@ fn parse_label_selector_handles_whitespace() { assert_eq!(result.get("tier"), Some(&"frontend".to_string())); } +#[tokio::test] +async fn create_scoped_is_insert_only_and_preserves_scope() { + use super::PersistenceError; + + let store = test_store().await; + let created = store + .create_scoped( + "refresh", + "winner-id", + "provider-token", + "default", + "winner-provider", + b"winner", + None, + ) + .await + .unwrap(); + assert_eq!(created.resource_version, 1); + + let duplicate = store + .create_scoped( + "refresh", + "loser-id", + "provider-token", + "default", + "loser-provider", + b"loser", + None, + ) + .await; + assert!(matches!( + duplicate, + Err(PersistenceError::UniqueViolation { .. }) + )); + + let winner = store + .get_by_name("refresh", "default", "provider-token") + .await + .unwrap() + .unwrap(); + assert_eq!(winner.id, "winner-id"); + assert_eq!(winner.payload, b"winner"); + assert_eq!( + store + .list_by_scope("refresh", "winner-provider", 10, 0) + .await + .unwrap() + .len(), + 1 + ); + assert!( + store + .list_by_scope("refresh", "loser-provider", 10, 0) + .await + .unwrap() + .is_empty() + ); +} + // --------------------------------------------------------------------------- // CAS (compare-and-swap) tests // --------------------------------------------------------------------------- @@ -1648,6 +1819,7 @@ async fn cas_update_message_cas_succeeds() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); @@ -1690,6 +1862,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); @@ -1760,6 +1933,7 @@ async fn cas_update_message_cas_rejects_workspace_change() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); @@ -1802,6 +1976,7 @@ async fn cas_update_message_cas_rejects_name_change() { }), spec: None, status: None, + ..Sandbox::default() }; store.put_message(&sandbox).await.unwrap(); @@ -2182,6 +2357,7 @@ async fn membership_selector_escapes_adversarial_label_key() { /// so a trace decomposes an RPC into the storage work it did rather than /// bottoming out at the request boundary. #[tokio::test] +#[ignore = "flaky under concurrent test execution"] async fn store_operations_export_spans_with_parents() { use tracing::Instrument as _; diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 1a49e35a1f..bd044c8712 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -197,10 +197,12 @@ pub trait PolicyStoreExt { rejection_reason: &str, ) -> PersistenceResult; - async fn update_draft_chunk_rule( + /// Replace the policy-dependent evaluation payload for a pending chunk. + /// Status and observation counters remain owned by their dedicated + /// columns and are not changed by this update. + async fn update_draft_chunk_evaluation( &self, - id: &str, - proposed_rule: &[u8], + chunk: &DraftChunkRecord, ) -> PersistenceResult; async fn delete_draft_chunks(&self, sandbox_id: &str, status: &str) -> PersistenceResult; @@ -394,14 +396,13 @@ impl PolicyStoreExt for Store { } } - async fn update_draft_chunk_rule( + async fn update_draft_chunk_evaluation( &self, - id: &str, - proposed_rule: &[u8], + chunk: &DraftChunkRecord, ) -> PersistenceResult { match self { - Self::Postgres(store) => store.update_draft_chunk_rule(id, proposed_rule).await, - Self::Sqlite(store) => store.update_draft_chunk_rule(id, proposed_rule).await, + Self::Postgres(store) => store.update_draft_chunk_evaluation(chunk).await, + Self::Sqlite(store) => store.update_draft_chunk_evaluation(chunk).await, } } @@ -497,6 +498,12 @@ pub fn draft_chunk_payload_from_record(chunk: &DraftChunkRecord) -> PersistenceR draft_version: chunk.draft_version, validation_result: chunk.validation_result.clone(), rejection_reason: chunk.rejection_reason.clone(), + application_error: chunk.application_error.clone(), + review_token: chunk.review_token.clone(), + current_effective_policy_hash: chunk.current_effective_policy_hash.clone(), + candidate_effective_policy_hash: chunk.candidate_effective_policy_hash.clone(), + current_effective_policy: chunk.current_effective_policy.clone(), + candidate_effective_policy: chunk.candidate_effective_policy.clone(), } .encode_to_vec()) } @@ -536,5 +543,11 @@ pub fn draft_chunk_record_from_parts( last_seen_ms: updated_at_ms, validation_result: wrapper.validation_result, rejection_reason: wrapper.rejection_reason, + application_error: wrapper.application_error, + review_token: wrapper.review_token, + current_effective_policy_hash: wrapper.current_effective_policy_hash, + candidate_effective_policy_hash: wrapper.candidate_effective_policy_hash, + current_effective_policy: wrapper.current_effective_policy, + candidate_effective_policy: wrapper.candidate_effective_policy, }) } diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index c8ab5cf23a..f080c296d1 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -15,7 +15,7 @@ use openshell_gateway_interceptors::{ }; use openshell_providers::{ ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, - validate_profile_set, + normalize_provider_type, validate_profile_set, }; use prost::Message as _; use sha2::{Digest, Sha256}; @@ -238,6 +238,7 @@ struct ScopedProfileEntry { struct EffectiveProfileEntry { effective: ScopedProfileEntry, platform_fallback: Option, + static_fallback: Option, } #[derive(Debug, Clone)] @@ -415,6 +416,9 @@ impl EffectiveProviderProfileCatalog { if let Some(fallback) = &entry.platform_fallback { result.push((fallback.scope, fallback.response.clone())); } + if let Some(fallback) = &entry.static_fallback { + result.push((fallback.scope, fallback.response.clone())); + } } result } @@ -427,6 +431,7 @@ impl EffectiveProviderProfileCatalog { .map(|entry| entry.effective.response.clone()) } + #[cfg(test)] pub(crate) fn get_type_profile(&self, id: &str) -> Option { let id = normalize_profile_id(id)?; self.profiles @@ -439,23 +444,46 @@ impl EffectiveProviderProfileCatalog { id: &str, profile_workspace: &str, ) -> Option { + self.scoped_type_profile_for_scope(id, profile_workspace) + .map(|entry| entry.profile.clone()) + } + + fn scoped_type_profile_for_scope( + &self, + id: &str, + profile_workspace: &str, + ) -> Option<&ScopedProfileEntry> { + if let Some(entry) = self.exact_scoped_type_profile_for_scope(id, profile_workspace) { + return Some(entry); + } + + let alias = normalize_provider_type(id)?; + if normalize_profile_id(id).as_deref() == Some(alias) { + return None; + } + self.exact_scoped_type_profile_for_scope(alias, profile_workspace) + } + + fn exact_scoped_type_profile_for_scope( + &self, + id: &str, + profile_workspace: &str, + ) -> Option<&ScopedProfileEntry> { let id = normalize_profile_id(id)?; let entry = self.profiles.get(&id)?; if entry.effective.scope == ProfileScope::Static { - return Some(entry.effective.profile.clone()); + return Some(&entry.effective); } if profile_workspace.is_empty() { match &entry.platform_fallback { - Some(fallback) => Some(fallback.profile.clone()), - None if entry.effective.scope == ProfileScope::Platform => { - Some(entry.effective.profile.clone()) - } - None => None, + Some(fallback) => Some(fallback), + None if entry.effective.scope == ProfileScope::Platform => Some(&entry.effective), + None => entry.static_fallback.as_ref(), } } else { - Some(entry.effective.profile.clone()) + Some(&entry.effective) } } @@ -463,40 +491,50 @@ impl EffectiveProviderProfileCatalog { let id = normalize_profile_id(id)?; self.profiles .get(&id) - .filter(|entry| !entry.effective.user_managed) - .map(|entry| entry.effective.source_id.clone()) + .and_then(|entry| { + if entry.effective.user_managed { + entry.static_fallback.as_ref() + } else { + Some(&entry.effective) + } + }) + .map(|entry| entry.source_id.clone()) } - pub(crate) fn hash_profile_revision(&self, profile_id: &str, hasher: &mut Sha256) { - let Some(profile_id) = normalize_profile_id(profile_id) else { - hasher.update(b"invalid-profile-id"); - return; - }; - - let Some(entry) = self.profiles.get(&profile_id) else { + pub(crate) fn hash_type_profile_revision_for_scope( + &self, + profile_id: &str, + profile_workspace: &str, + hasher: &mut Sha256, + ) { + let Some(entry) = self.scoped_type_profile_for_scope(profile_id, profile_workspace) else { hasher.update(b"missing"); return; }; - hasher.update(b"provider-profile-source-entry"); - hasher.update(entry.effective.source_id.as_bytes()); - hasher.update(entry.effective.source_revision.as_bytes()); - let scope_tag: &[u8] = match entry.effective.scope { - ProfileScope::Static => b"static", - ProfileScope::Platform => b"platform", - ProfileScope::Workspace => b"workspace", - }; - hasher.update(scope_tag); - let ownership_tag: &[u8] = if entry.effective.user_managed { - b"user-managed" - } else { - b"source-managed" - }; - hasher.update(ownership_tag); - hasher.update(entry.effective.response.encode_to_vec()); + hash_scoped_profile_revision(entry, hasher); } } +fn hash_scoped_profile_revision(entry: &ScopedProfileEntry, hasher: &mut Sha256) { + hasher.update(b"provider-profile-source-entry"); + hasher.update(entry.source_id.as_bytes()); + hasher.update(entry.source_revision.as_bytes()); + let scope_tag: &[u8] = match entry.scope { + ProfileScope::Static => b"static", + ProfileScope::Platform => b"platform", + ProfileScope::Workspace => b"workspace", + }; + hasher.update(scope_tag); + let ownership_tag: &[u8] = if entry.user_managed { + b"user-managed" + } else { + b"source-managed" + }; + hasher.update(ownership_tag); + hasher.update(entry.response.encode_to_vec()); +} + fn scope_to_string(scope: ProfileScope) -> &'static str { match scope { ProfileScope::Static => "", @@ -612,6 +650,19 @@ fn build_effective_profiles( let new_scope = new_entry.scope; match (existing_scope, new_scope) { + (ProfileScope::Static, _) + if existing.effective.source_id == BUILTIN_SOURCE_ID + && new_entry.user_managed => + { + let fallback = std::mem::replace(&mut existing.effective, new_entry); + existing.static_fallback = Some(fallback); + } + (_, ProfileScope::Static) + if new_entry.source_id == BUILTIN_SOURCE_ID + && existing.effective.user_managed => + { + existing.static_fallback = Some(new_entry); + } (ProfileScope::Static, _) | (_, ProfileScope::Static) => { let location = if existing.effective.source_id == source_id { format!("within source '{source_id}'") @@ -645,6 +696,7 @@ fn build_effective_profiles( EffectiveProfileEntry { effective: new_entry, platform_fallback: None, + static_fallback: None, }, ); } @@ -697,7 +749,7 @@ fn profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { format!("sha256:{:x}", hasher.finalize()) } -#[expect(dead_code)] +#[cfg(test)] pub fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { use crate::persistence::current_time_ms; let now_ms = current_time_ms(); @@ -921,7 +973,11 @@ mod tests { ); assert!(first.get_type_profile("moving-profile").is_some()); let mut first_profile_hash = Sha256::new(); - first.hash_profile_revision("moving-profile", &mut first_profile_hash); + first.hash_type_profile_revision_for_scope( + "moving-profile", + "default", + &mut first_profile_hash, + ); let first_profile_hash = first_profile_hash.finalize(); assert_eq!(fetch_count.load(Ordering::SeqCst), 1); @@ -932,7 +988,11 @@ mod tests { "revision-b" ); let mut second_profile_hash = Sha256::new(); - second.hash_profile_revision("moving-profile", &mut second_profile_hash); + second.hash_type_profile_revision_for_scope( + "moving-profile", + "default", + &mut second_profile_hash, + ); assert_ne!(first_profile_hash, second_profile_hash.finalize()); assert_ne!(first.revision(), second.revision()); } @@ -1486,28 +1546,46 @@ mod tests { } #[test] - fn same_id_static_vs_user_still_errors() { - let err = build_effective_profiles(vec![ + fn user_managed_profiles_shadow_new_builtins_in_scope() { + let catalog = build_effective_profiles(vec![ CollectedProviderProfileSnapshot { source_id: "builtin".to_string(), revision: "v1".to_string(), - profiles: vec![scoped(ProfileScope::Static, "openai")], + profiles: vec![ + scoped(ProfileScope::Static, "openai"), + scoped(ProfileScope::Static, "anthropic"), + ], user_managed: false, allow_empty: false, }, CollectedProviderProfileSnapshot { source_id: "user".to_string(), revision: "v1".to_string(), - profiles: vec![scoped(ProfileScope::Platform, "openai")], + profiles: vec![ + scoped(ProfileScope::Platform, "openai"), + scoped(ProfileScope::Workspace, "anthropic"), + ], user_managed: true, allow_empty: true, }, ]) - .unwrap_err(); + .unwrap(); - assert!( - err.message() - .contains("duplicate provider profile id 'openai'") + let openai = catalog + .get_type_profile_for_scope("openai", "default") + .expect("platform override"); + assert_eq!(openai.source, "user"); + let anthropic = catalog + .get_type_profile_for_scope("anthropic", "default") + .expect("workspace override"); + assert_eq!(anthropic.source, "user"); + let platform_anthropic = catalog + .get_type_profile_for_scope("anthropic", "") + .expect("built-in fallback outside workspace scope"); + assert_eq!(platform_anthropic.source, "builtin"); + assert_eq!( + catalog.static_source_for_profile("openai").as_deref(), + Some("builtin") ); } @@ -1633,6 +1711,65 @@ mod tests { assert_eq!(result.unwrap().display_name, "Platform Anthropic"); } + #[test] + fn scoped_profile_revision_hashes_platform_fallback_beneath_workspace_override() { + let catalog = |platform_path: &str| { + let mut platform = profile("anthropic"); + platform.endpoints.truncate(1); + platform.endpoints[0].path = platform_path.to_string(); + let mut workspace = profile("anthropic"); + workspace.display_name = "Workspace Anthropic".to_string(); + + build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "same-source-revision".to_string(), + profiles: vec![ + ScopedSnapshotProfile { + scope: ProfileScope::Platform, + profile: platform, + }, + ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: workspace, + }, + ], + user_managed: true, + allow_empty: true, + }]) + .unwrap() + }; + + let broad = catalog("/**"); + let narrow = catalog("/v1/**"); + let mut broad_platform_hash = Sha256::new(); + broad.hash_type_profile_revision_for_scope("anthropic", "", &mut broad_platform_hash); + let mut narrow_platform_hash = Sha256::new(); + narrow.hash_type_profile_revision_for_scope("anthropic", "", &mut narrow_platform_hash); + assert_ne!( + broad_platform_hash.finalize(), + narrow_platform_hash.finalize(), + "platform-scoped providers must hash the selected platform fallback" + ); + + let mut broad_workspace_hash = Sha256::new(); + broad.hash_type_profile_revision_for_scope( + "anthropic", + "default", + &mut broad_workspace_hash, + ); + let mut narrow_workspace_hash = Sha256::new(); + narrow.hash_type_profile_revision_for_scope( + "anthropic", + "default", + &mut narrow_workspace_hash, + ); + assert_eq!( + broad_workspace_hash.finalize(), + narrow_workspace_hash.finalize(), + "workspace-scoped providers must remain keyed to the workspace override" + ); + } + #[test] fn scope_lookup_empty_pw_returns_platform_when_no_shadow() { let mut plat_profile = profile("anthropic"); @@ -1714,4 +1851,70 @@ mod tests { assert!(result.is_some()); assert_eq!(result.unwrap().display_name, "Workspace Anthropic"); } + + #[test] + fn exact_alias_shaped_profile_wins_before_builtin_alias_fallback() { + let mut builtin = profile("github"); + builtin.display_name = "Built-in GitHub".to_string(); + let mut custom = profile("gh"); + custom.display_name = "Enterprise GitHub".to_string(); + + let catalog = build_effective_profiles(vec![ + CollectedProviderProfileSnapshot { + source_id: "builtin".to_string(), + revision: "builtin-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Static, + profile: builtin, + }], + user_managed: false, + allow_empty: false, + }, + CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "user-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: custom, + }], + user_managed: true, + allow_empty: true, + }, + ]) + .unwrap(); + + let exact = catalog + .get_type_profile_for_scope("gh", "default") + .expect("exact custom profile"); + assert_eq!(exact.id, "gh"); + assert_eq!(exact.display_name, "Enterprise GitHub"); + + let builtin = catalog + .get_type_profile_for_scope("github", "default") + .expect("built-in profile"); + assert_eq!(builtin.id, "github"); + } + + #[test] + fn custom_profile_can_reuse_default_id_when_builtin_source_is_not_loaded() { + let mut custom = profile("github"); + custom.display_name = "Private GitHub".to_string(); + let catalog = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "user-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: custom, + }], + user_managed: true, + allow_empty: true, + }]) + .unwrap(); + + let resolved = catalog + .get_type_profile_for_scope("github", "default") + .expect("custom profile reusing unloaded default ID"); + assert_eq!(resolved.id, "github"); + assert_eq!(resolved.display_name, "Private GitHub"); + } } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index a03fdb0b17..bba28c3843 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -5,23 +5,39 @@ #![allow(clippy::result_large_err)] +use crate::credentials::RefreshMaterialScope; use crate::persistence::{ObjectType, PersistenceError, Store, WriteCondition, current_time_ms}; use openshell_core::ObjectWorkspace; use openshell_core::proto::{ - Provider, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, - StoredProviderCredentialRefreshState, + CredentialHandle, Provider, ProviderCredentialRefreshRecoveryAction, + ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, + StoredProviderCredentialRefreshState, StoredRefreshMaterialDeletion, }; +use openshell_core::{ObjectId, ObjectName, SetResourceVersion}; use prost::Message; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; -use tonic::Status; +use tonic::{Code, Status}; use tracing::{info, warn}; const DEFAULT_REFRESH_BEFORE_SECONDS: i64 = 300; const DEFAULT_MAX_LIFETIME_SECONDS: i64 = 3600; const REFRESH_ERROR_RETRY_SECONDS: i64 = 60; +const REFRESH_CONFIGURATION_RETRY_SECONDS: i64 = 60 * 60; const REFRESH_WORKER_PAGE_SIZE: u32 = 1000; +const MAX_OAUTH_ERROR_RESPONSE_BYTES: usize = 8 * 1024; + +pub fn refresh_material_scope( + state: &StoredProviderCredentialRefreshState, +) -> RefreshMaterialScope<'_> { + RefreshMaterialScope { + provider_name: &state.provider_name, + workspace: state.object_workspace(), + provider_id: &state.provider_id, + credential_key: &state.credential_key, + } +} impl ObjectType for StoredProviderCredentialRefreshState { fn object_type() -> &'static str { @@ -38,6 +54,29 @@ pub fn refresh_state_name(provider_id: &str, credential_key: &str) -> String { format!("provider-refresh-{provider_id}-{key}") } +/// Return the durable authorization epoch for one configured refresh grant. +/// +/// Records created before the explicit epoch field was introduced use their +/// gateway-generated object ID as a stable migration epoch. An explicit +/// reconfiguration writes a new random epoch while preserving object metadata, +/// so reauthorization still revokes handles derived from the legacy value. +pub fn effective_authorization_epoch( + state: &StoredProviderCredentialRefreshState, +) -> Result<&str, Status> { + if !state.authorization_epoch.is_empty() { + return Ok(&state.authorization_epoch); + } + state + .metadata + .as_ref() + .map(|metadata| metadata.id.as_str()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + Status::failed_precondition("provider refresh state has no authorization epoch") + }) +} + +#[cfg(test)] pub async fn put_refresh_state( store: &Store, state: &StoredProviderCredentialRefreshState, @@ -48,6 +87,37 @@ pub async fn put_refresh_state( .map_err(|e| Status::internal(format!("persist provider refresh state failed: {e}"))) } +/// Atomically claim a new provider-and-credential refresh identity. +/// +/// The refresh name is unique within a workspace. A concurrent creator must +/// lose instead of overwriting the winner so its caller can delete any secret +/// material staged before this write. +pub async fn create_refresh_state( + store: &Store, + state: &StoredProviderCredentialRefreshState, +) -> Result<(), Status> { + match store + .create_scoped( + StoredProviderCredentialRefreshState::object_type(), + state.object_id(), + state.object_name(), + state.object_workspace(), + &state.provider_id, + &state.encode_to_vec(), + None, + ) + .await + { + Ok(_) => Ok(()), + Err(PersistenceError::UniqueViolation { .. }) => Err(Status::aborted( + "provider refresh was concurrently configured", + )), + Err(err) => Err(Status::internal(format!( + "create provider refresh state failed: {err}" + ))), + } +} + /// Persist an updated refresh state only if the row still exists with the /// generation read at the start of the rotation. /// @@ -84,6 +154,18 @@ async fn persist_refresh_state_if_current( } } +pub async fn replace_refresh_state_if_current( + store: &Store, + state: &StoredProviderCredentialRefreshState, + expected_version: u64, +) -> Result { + Ok( + persist_refresh_state_if_current(store, state, expected_version) + .await? + .is_some(), + ) +} + pub async fn list_refresh_states_for_provider( store: &Store, provider_id: &str, @@ -100,11 +182,10 @@ pub async fn list_refresh_states_for_provider( let mut states = Vec::with_capacity(records.len()); for record in records { - states.push( - StoredProviderCredentialRefreshState::decode(record.payload.as_slice()).map_err( - |e| Status::internal(format!("decode provider refresh state failed: {e}")), - )?, - ); + let mut state = StoredProviderCredentialRefreshState::decode(record.payload.as_slice()) + .map_err(|e| Status::internal(format!("decode provider refresh state failed: {e}")))?; + state.set_resource_version(record.resource_version); + states.push(state); } Ok(states) } @@ -115,30 +196,23 @@ pub async fn list_all_refresh_states( let mut states = Vec::new(); let mut offset = 0; loop { - let records = store - .list_by_type( - StoredProviderCredentialRefreshState::object_type(), + let page = store + .list_all_messages::( REFRESH_WORKER_PAGE_SIZE, offset, ) .await .map_err(|e| Status::internal(format!("list provider refresh states failed: {e}")))?; - if records.is_empty() { + if page.is_empty() { break; } offset = offset .checked_add( - u32::try_from(records.len()) + u32::try_from(page.len()) .map_err(|_| Status::internal("provider refresh page size exceeded u32"))?, ) .ok_or_else(|| Status::internal("provider refresh pagination offset overflow"))?; - for record in records { - states.push( - StoredProviderCredentialRefreshState::decode(record.payload.as_slice()).map_err( - |e| Status::internal(format!("decode provider refresh state failed: {e}")), - )?, - ); - } + states.extend(page); } Ok(states) } @@ -156,38 +230,80 @@ pub async fn get_refresh_state( .map_err(|e| Status::internal(format!("fetch provider refresh state failed: {e}"))) } -pub async fn delete_refresh_state( +pub async fn delete_refresh_state_with_credentials( store: &Store, + credentials: &crate::credentials::CredentialRuntime, workspace: &str, provider_id: &str, credential_key: &str, ) -> Result { - let name = refresh_state_name(provider_id, credential_key); + let Some(mut state) = get_refresh_state(store, workspace, provider_id, credential_key).await? + else { + return Ok(false); + }; + let mut version = state + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + if state + .metadata + .as_ref() + .is_some_and(|metadata| metadata.deletion_timestamp_ms == 0) + { + if let Some(metadata) = state.metadata.as_mut() { + metadata.deletion_timestamp_ms = current_time_ms(); + } + state.authorization_epoch = uuid::Uuid::new_v4().to_string(); + state.status = "deleting".to_string(); + state.next_refresh_at_ms = i64::MAX; + version = persist_refresh_state_if_current(store, &state, version) + .await? + .ok_or_else(|| { + Status::aborted("provider refresh was concurrently modified during deletion") + })?; + if let Some(metadata) = state.metadata.as_mut() { + metadata.resource_version = version; + } + } + + delete_pending_secret_handles(credentials, &state).await?; + credentials + .delete_refresh_material_handles( + refresh_material_scope(&state), + &state.secret_material_handles, + ) + .await?; store - .delete_by_name( + .delete_if( StoredProviderCredentialRefreshState::object_type(), - workspace, - &name, + state.object_id(), + version, ) .await - .map_err(|e| Status::internal(format!("delete provider refresh state failed: {e}"))) + .map_err(|err| match err { + PersistenceError::Conflict { .. } => { + Status::aborted("provider refresh was concurrently modified during deletion") + } + other => Status::internal(format!("delete provider refresh state failed: {other}")), + }) } -pub async fn delete_refresh_states_for_provider( +pub async fn delete_refresh_states_for_provider_with_credentials( store: &Store, + credentials: &crate::credentials::CredentialRuntime, provider_id: &str, ) -> Result { let states = list_refresh_states_for_provider(store, provider_id).await?; let mut deleted = 0; for state in &states { - if store - .delete_by_name( - StoredProviderCredentialRefreshState::object_type(), - state.object_workspace(), - state.object_name(), - ) - .await - .map_err(|e| Status::internal(format!("delete provider refresh state failed: {e}")))? + if delete_refresh_state_with_credentials( + store, + credentials, + state.object_workspace(), + provider_id, + &state.credential_key, + ) + .await? { deleted += 1; } @@ -208,6 +324,10 @@ pub fn refresh_status_from_state( next_refresh_at_ms: state.next_refresh_at_ms, last_refresh_at_ms: state.last_refresh_at_ms, last_error: state.last_error.clone(), + recovery_action: state.recovery_action, + failure_code: state.failure_code.clone(), + provider_error_subtype: state.provider_error_subtype.clone(), + last_error_at_ms: state.last_error_at_ms, } } @@ -269,11 +389,16 @@ pub fn new_refresh_state( refresh_before_seconds: config.refresh_before_seconds, max_lifetime_seconds: config.max_lifetime_seconds, additional_output_keys: config.additional_output_keys, + authorization_epoch: uuid::Uuid::new_v4().to_string(), + secret_material_handles: HashMap::new(), + pending_secret_deletions: Vec::new(), + recovery_action: ProviderCredentialRefreshRecoveryAction::Unspecified as i32, + failure_code: String::new(), + provider_error_subtype: String::new(), + last_error_at_ms: 0, }) } -use openshell_core::{ObjectId, ObjectName}; - #[derive(Debug)] struct MintedCredential { access_token: String, @@ -289,6 +414,130 @@ struct TokenResponse { refresh_token: Option, } +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: String, + #[serde(default, deserialize_with = "deserialize_optional_oauth_string")] + error_subtype: Option, +} + +fn deserialize_optional_oauth_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + Ok(value.as_str().map(str::to_owned)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OAuthGrantKind { + UserRefreshToken, + NonInteractive, +} + +#[derive(Debug)] +struct RefreshFailure { + status: Status, + recovery_action: ProviderCredentialRefreshRecoveryAction, + failure_code: &'static str, + provider_error_subtype: Option<&'static str>, + retry_schedule: RefreshRetrySchedule, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RefreshRetrySchedule { + Short, + Configuration, + Parked, +} + +impl RefreshFailure { + fn retryable(status: Status, failure_code: &'static str) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::Retry, + failure_code, + provider_error_subtype: None, + retry_schedule: RefreshRetrySchedule::Short, + } + } + + fn investigate(status: Status, failure_code: &'static str) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::Investigate, + failure_code, + provider_error_subtype: None, + retry_schedule: RefreshRetrySchedule::Short, + } + } + + fn reauthorize( + status: Status, + failure_code: &'static str, + provider_error_subtype: Option<&'static str>, + ) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::Reauthorize, + failure_code, + provider_error_subtype, + retry_schedule: RefreshRetrySchedule::Parked, + } + } + + fn fix_configuration(status: Status, failure_code: &'static str) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::FixConfiguration, + failure_code, + provider_error_subtype: None, + retry_schedule: RefreshRetrySchedule::Configuration, + } + } + + fn fix_configuration_with_subtype( + status: Status, + failure_code: &'static str, + provider_error_subtype: &'static str, + ) -> Self { + Self { + status, + recovery_action: ProviderCredentialRefreshRecoveryAction::FixConfiguration, + failure_code, + provider_error_subtype: Some(provider_error_subtype), + retry_schedule: RefreshRetrySchedule::Configuration, + } + } + + fn into_status(self) -> Status { + self.status + } + + fn from_status(status: &Status) -> Self { + Self::from(Status::new(status.code(), status.message().to_string())) + } +} + +impl From for RefreshFailure { + fn from(status: Status) -> Self { + match status.code() { + Code::InvalidArgument + | Code::FailedPrecondition + | Code::PermissionDenied + | Code::Unauthenticated => { + Self::fix_configuration(status, "refresh_configuration_invalid") + } + Code::Unavailable + | Code::DeadlineExceeded + | Code::ResourceExhausted + | Code::Aborted + | Code::Internal => Self::retryable(status, "refresh_failed"), + _ => Self::investigate(status, "refresh_failed"), + } + } +} + #[derive(Debug, Serialize)] struct GoogleServiceAccountClaims<'a> { iss: &'a str, @@ -340,9 +589,191 @@ pub fn refresh_strategy_name(strategy: i32) -> &'static str { pub use openshell_providers::is_gateway_mintable_strategy; +/// Secret source-material fields that are security-sensitive by strategy even +/// when a direct API caller omits `secret_material_keys`. +pub fn strategy_secret_material_keys( + strategy: ProviderCredentialRefreshStrategy, +) -> &'static [&'static str] { + match strategy { + ProviderCredentialRefreshStrategy::Oauth2RefreshToken => { + &["refresh_token", "client_secret"] + } + ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => &["client_secret"], + ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => &["private_key"], + ProviderCredentialRefreshStrategy::AwsStsAssumeRole => { + &["aws_secret_access_key", "aws_session_token"] + } + ProviderCredentialRefreshStrategy::Static + | ProviderCredentialRefreshStrategy::External + | ProviderCredentialRefreshStrategy::Unspecified => &[], + } +} + +async fn resolve_refresh_material( + credentials: Option<&crate::credentials::CredentialRuntime>, + state: &StoredProviderCredentialRefreshState, +) -> Result { + if state.secret_material_handles.is_empty() { + return Ok(state.clone()); + } + let credentials = credentials.ok_or_else(|| { + Status::failed_precondition( + "provider refresh material requires the configured credential runtime", + ) + })?; + let resolved = credentials + .resolve_refresh_material( + refresh_material_scope(state), + &state.secret_material_handles, + ) + .await?; + let mut transient = state.clone(); + transient.material.extend(resolved); + Ok(transient) +} + +pub fn enqueue_pending_secret_deletion( + state: &mut StoredProviderCredentialRefreshState, + material_key: &str, + handle: CredentialHandle, +) { + state + .pending_secret_deletions + .push(StoredRefreshMaterialDeletion { + material_key: material_key.to_string(), + handle: Some(handle), + }); +} + +async fn delete_pending_secret_handles( + credentials: &crate::credentials::CredentialRuntime, + state: &StoredProviderCredentialRefreshState, +) -> Result<(), Status> { + credentials + .delete_refresh_material_deletions( + refresh_material_scope(state), + &state.pending_secret_deletions, + ) + .await +} + +async fn cleanup_pending_secret_deletions( + store: &Store, + credentials: Option<&crate::credentials::CredentialRuntime>, + state: &mut StoredProviderCredentialRefreshState, + expected_version: u64, +) -> Result { + if state.pending_secret_deletions.is_empty() { + return Ok(expected_version); + } + let credentials = credentials.ok_or_else(|| { + Status::failed_precondition( + "provider refresh cleanup requires the configured credential runtime", + ) + })?; + delete_pending_secret_handles(credentials, state).await?; + // Keep the caller's in-memory state unchanged unless the CAS succeeds. A + // failed cleanup must not let the live refresh path persist a locally + // cleared tombstone list over the durable retry references. + let mut cleaned = state.clone(); + cleaned.pending_secret_deletions.clear(); + let new_version = persist_refresh_state_if_current(store, &cleaned, expected_version) + .await? + .ok_or_else(|| { + Status::aborted("provider refresh was deleted or superseded during secret cleanup") + })?; + if let Some(metadata) = cleaned.metadata.as_mut() { + metadata.resource_version = new_version; + } + *state = cleaned; + Ok(new_version) +} + +async fn persist_retryable_refresh_error_state( + store: &Store, + state: &mut StoredProviderCredentialRefreshState, + expected_version: u64, + error: &Status, +) -> Result { + let failure = RefreshFailure::retryable( + Status::new(error.code(), error.message().to_string()), + "refresh_failed", + ); + persist_refresh_failure_state(store, state, expected_version, &failure).await +} + +async fn persist_refresh_failure_state( + store: &Store, + state: &mut StoredProviderCredentialRefreshState, + expected_version: u64, + failure: &RefreshFailure, +) -> Result { + let now_ms = current_time_ms(); + state.status = match failure.recovery_action { + ProviderCredentialRefreshRecoveryAction::Retry + | ProviderCredentialRefreshRecoveryAction::Unspecified => "error", + ProviderCredentialRefreshRecoveryAction::Reauthorize => "reauthorization_required", + ProviderCredentialRefreshRecoveryAction::FixConfiguration => "configuration_required", + ProviderCredentialRefreshRecoveryAction::Investigate => "investigation_required", + } + .to_string(); + state.last_error = failure.status.message().to_string(); + state.recovery_action = failure.recovery_action as i32; + state.failure_code = failure.failure_code.to_string(); + state.provider_error_subtype = failure + .provider_error_subtype + .unwrap_or_default() + .to_string(); + state.last_error_at_ms = now_ms; + state.next_refresh_at_ms = match failure.retry_schedule { + RefreshRetrySchedule::Short => { + now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000)) + } + RefreshRetrySchedule::Configuration => { + now_ms.saturating_add(REFRESH_CONFIGURATION_RETRY_SECONDS.saturating_mul(1000)) + } + RefreshRetrySchedule::Parked => i64::MAX, + }; + let new_version = persist_refresh_state_if_current(store, state, expected_version) + .await? + .ok_or_else(|| { + Status::aborted( + "provider refresh was deleted or superseded while recording a refresh error", + ) + })?; + if let Some(metadata) = state.metadata.as_mut() { + metadata.resource_version = new_version; + } + Ok(new_version) +} + +fn validate_secret_material_references( + state: &StoredProviderCredentialRefreshState, +) -> Result<(), Status> { + let mut missing: Vec<_> = state + .secret_material_keys + .iter() + .filter(|key| { + !state.material.contains_key(*key) && !state.secret_material_handles.contains_key(*key) + }) + .cloned() + .collect(); + if missing.is_empty() { + return Ok(()); + } + missing.sort(); + missing.dedup(); + Err(Status::failed_precondition(format!( + "provider refresh secret material is missing both inline values and credential handles for {}; a mixed-version gateway upgrade may have discarded the handles, so restore the refresh state or reconfigure the grant", + missing.join(", ") + ))) +} + pub async fn refresh_provider_credential( store: &Store, workspace: &str, + credentials: &crate::credentials::CredentialRuntime, + compute: Option<&crate::compute::ComputeRuntime>, provider_name: &str, credential_key: &str, ) -> Result { @@ -351,11 +782,22 @@ pub async fn refresh_provider_credential( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; - let Some(mut state) = + let Some(state) = get_refresh_state(store, workspace, provider.object_id(), credential_key).await? else { return Err(Status::not_found("provider refresh state not found")); }; + if state + .metadata + .as_ref() + .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + { + return Err(Status::failed_precondition( + "provider refresh is being deleted", + )); + } + let mut state = state; + validate_secret_material_references(&state)?; // Generation of the refresh at the start of the rotation. Terminal persists // match on it so a concurrent delete or rotation is detected rather than // clobbered, and a deleted refresh is never recreated (CWE-362). @@ -363,6 +805,25 @@ pub async fn refresh_provider_credential( .metadata .as_ref() .map_or(0, |meta| meta.resource_version); + let expected_version = match cleanup_pending_secret_deletions( + store, + Some(credentials), + &mut state, + expected_version, + ) + .await + { + Ok(new_version) => new_version, + Err(err) => { + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + error = %err, + "provider refresh material cleanup failed; continuing with live refresh" + ); + expected_version + } + }; info!( provider = %state.provider_name, @@ -374,36 +835,15 @@ pub async fn refresh_provider_credential( "provider credential refresh started" ); - // Enforce the providers_v2 gate on every mint, not just at configure time. - // Otherwise disabling providers_v2_enabled leaves already-configured refresh - // states that the worker and manual rotation keep minting from. - if let Err(err) = ensure_refresh_providers_v2_gate(store, &state).await { - let now_ms = current_time_ms(); - state.status = "error".to_string(); - state.last_error = err.message().to_string(); - state.next_refresh_at_ms = - now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000)); - persist_refresh_state_if_current(store, &state, expected_version).await?; - warn!( - provider = %state.provider_name, - credential_key = %state.credential_key, - strategy = %refresh_strategy_name(state.strategy), - status = %state.status, - error = %err, - "provider credential refresh gate rejected" - ); - return Err(err); - } - - match mint_credential(&state).await { + let mint_result = match resolve_refresh_material(Some(credentials), &state).await { + Ok(transient_state) => mint_credential(&transient_state).await, + Err(err) => Err(err.into()), + }; + match mint_result { Ok(minted) => { let now_ms = current_time_ms(); - // Fold the minted result into the refresh state before claiming the - // generation. - if let Some(refresh_token) = minted.refresh_token.clone() { - state - .material - .insert("refresh_token".to_string(), refresh_token); + let mut staged_refresh_token_handles = HashMap::new(); + if let Some(ref refresh_token) = minted.refresh_token { if !state .secret_material_keys .iter() @@ -411,6 +851,68 @@ pub async fn refresh_provider_credential( { state.secret_material_keys.push("refresh_token".to_string()); } + let material = + HashMap::from([("refresh_token".to_string(), refresh_token.clone())]); + let staging_id = format!( + "{}-refresh-material-{}", + state.object_id(), + uuid::Uuid::new_v4() + ); + staged_refresh_token_handles = match credentials + .store_refresh_material_with_object_id( + refresh_material_scope(&state), + &staging_id, + &material, + &HashMap::new(), + ) + .await + { + Ok(handles) => handles, + Err(store_err) => { + let failure = RefreshFailure::reauthorize( + Status::failed_precondition(format!( + "the OAuth provider rotated the refresh token, but the replacement could not be stored; the grant must be re-authorized: {}", + store_err.message() + )), + "oauth_rotated_refresh_token_store_failed", + None, + ); + persist_refresh_failure_state( + store, + &mut state, + expected_version, + &failure, + ) + .await?; + return Err(failure.into_status()); + } + }; + let Some(handle) = staged_refresh_token_handles.get("refresh_token").cloned() + else { + let failure = RefreshFailure::reauthorize( + Status::failed_precondition( + "the OAuth provider rotated the refresh token, but the credential driver returned no replacement handle; the grant must be re-authorized", + ), + "oauth_rotated_refresh_token_handle_missing", + None, + ); + cleanup_staged_refresh_material_handles( + credentials, + &state, + &staged_refresh_token_handles, + ) + .await; + persist_refresh_failure_state(store, &mut state, expected_version, &failure) + .await?; + return Err(failure.into_status()); + }; + if let Some(previous) = state + .secret_material_handles + .insert("refresh_token".to_string(), handle) + { + enqueue_pending_secret_deletion(&mut state, "refresh_token", previous); + } + state.material.remove("refresh_token"); } state.expires_at_ms = minted.expires_at_ms; state.next_refresh_at_ms = next_refresh_at_ms( @@ -422,6 +924,10 @@ pub async fn refresh_provider_credential( state.last_refresh_at_ms = now_ms; state.status = "refreshed".to_string(); state.last_error.clear(); + state.recovery_action = ProviderCredentialRefreshRecoveryAction::Unspecified as i32; + state.failure_code.clear(); + state.provider_error_subtype.clear(); + state.last_error_at_ms = 0; // Claim the refresh generation with a version-matched write BEFORE // touching the provider. It succeeds only if the refresh still holds @@ -431,31 +937,67 @@ pub async fn refresh_provider_credential( // from a stale generation are written, and a deleted refresh is not // resurrected (CWE-362). This makes generation ownership the gate on // the provider credential write. - let Some(new_version) = - persist_refresh_state_if_current(store, &state, expected_version).await? - else { - warn!( - provider = %state.provider_name, - credential_key = %state.credential_key, - strategy = %refresh_strategy_name(state.strategy), - "provider credential refresh deleted or superseded during rotation; discarding minted credentials" - ); - return Err(Status::aborted( - "provider refresh was deleted or superseded during rotation", - )); + let new_version = match persist_refresh_state_if_current( + store, + &state, + expected_version, + ) + .await + { + Ok(Some(new_version)) => new_version, + Ok(None) => { + if !staged_refresh_token_handles.is_empty() { + cleanup_staged_refresh_material_handles( + credentials, + &state, + &staged_refresh_token_handles, + ) + .await; + } + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + strategy = %refresh_strategy_name(state.strategy), + "provider credential refresh deleted or superseded during rotation; discarding minted credentials" + ); + return Err(Status::aborted( + "provider refresh was deleted or superseded during rotation", + )); + } + Err(err) => { + // The replacement refresh token is already in credential + // storage. Retry the same CAS with error/backoff state so a + // transient database failure does not discard the only + // upstream-valid grant. If that also fails, leave the + // staged object intact for operator recovery rather than + // deleting an irreplaceable rotated token. + persist_retryable_refresh_error_state( + store, + &mut state, + expected_version, + &err, + ) + .await?; + return Err(err); + } }; // Generation is ours; write the minted credentials into the provider. - if let Err(err) = - apply_minted_credential(store, workspace, &provider, credential_key, &minted).await + if let Err(err) = apply_minted_credential( + store, + workspace, + Some(credentials), + compute, + &provider, + credential_key, + &minted, + ) + .await { - state.status = "error".to_string(); - state.last_error = err.message().to_string(); - state.next_refresh_at_ms = - now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000)); // Reflect the failure on the state we just wrote; skip silently // if it was deleted concurrently (it is not recreated). - persist_refresh_state_if_current(store, &state, new_version).await?; + let failure = RefreshFailure::from_status(&err); + persist_refresh_failure_state(store, &mut state, new_version, &failure).await?; warn!( provider = %state.provider_name, credential_key = %state.credential_key, @@ -478,15 +1020,27 @@ pub async fn refresh_provider_credential( seconds_until_refresh = seconds_until_ms(now_ms, state.next_refresh_at_ms), "provider credential refresh completed" ); + if !state.pending_secret_deletions.is_empty() + && let Err(err) = cleanup_pending_secret_deletions( + store, + Some(credentials), + &mut state, + new_version, + ) + .await + { + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + error = %err, + "failed to clean up replaced refresh material; retrying on the next sweep" + ); + } Ok(state) } - Err(err) => { + Err(failure) => { let now_ms = current_time_ms(); - state.status = "error".to_string(); - state.last_error = err.message().to_string(); - state.next_refresh_at_ms = - now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000)); - persist_refresh_state_if_current(store, &state, expected_version).await?; + persist_refresh_failure_state(store, &mut state, expected_version, &failure).await?; warn!( provider = %state.provider_name, credential_key = %state.credential_key, @@ -494,28 +1048,90 @@ pub async fn refresh_provider_credential( status = %state.status, next_refresh_at_ms = state.next_refresh_at_ms, seconds_until_refresh = seconds_until_ms(now_ms, state.next_refresh_at_ms), - error = %err, + recovery_action = ?failure.recovery_action, + failure_code = failure.failure_code, + error = %failure.status, "provider credential refresh errored" ); - Err(err) + Err(failure.into_status()) } } } +async fn cleanup_staged_refresh_material_handles( + credentials: &crate::credentials::CredentialRuntime, + state: &StoredProviderCredentialRefreshState, + handles: &HashMap, +) { + if let Err(err) = credentials + .delete_refresh_material_handles(refresh_material_scope(state), handles) + .await + { + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + error = %err, + "failed to clean up staged provider refresh material" + ); + } +} + async fn apply_minted_credential( store: &Store, workspace: &str, + credentials: Option<&crate::credentials::CredentialRuntime>, + compute: Option<&crate::compute::ComputeRuntime>, provider: &Provider, credential_key: &str, minted: &MintedCredential, ) -> Result<(), Status> { let mut updated = provider.clone(); - updated - .credentials - .insert(credential_key.to_string(), minted.access_token.clone()); - for (key, value) in &minted.additional_credentials { - updated.credentials.insert(key.clone(), value.clone()); - } + let staging_id = format!("{}-refresh-{}", provider.object_id(), uuid::Uuid::new_v4()); + let staged_handles = if let Some(credentials) = credentials + && credentials.stores_provider_credentials() + { + if let Some(compute) = compute { + compute.ensure_workspace(workspace).await?; + } + let mut creds_to_store = + HashMap::from([(credential_key.to_string(), minted.access_token.clone())]); + for (key, value) in &minted.additional_credentials { + creds_to_store.insert(key.clone(), value.clone()); + } + // Stage under new handles with a unique staging ID to ensure we don't overwrite + // the still-committed values before validation/CAS succeeds + let staged = credentials + .store_provider_credentials_with_object_id( + provider.object_name(), + provider.object_workspace(), + provider.object_id(), + &staging_id, + &creds_to_store, + &HashMap::new(), // Empty map forces creation of new handles + ) + .await?; + if !staged.contains_key(credential_key) { + cleanup_staged_refresh_handles(credentials, provider, &staged).await; + return Err(Status::internal( + "credential driver did not return refreshed credential handle", + )); + } + for (key, handle) in &staged { + updated.credentials.remove(key); + updated + .credential_handles + .insert(key.clone(), handle.clone()); + } + Some(staged) + } else { + updated + .credentials + .insert(credential_key.to_string(), minted.access_token.clone()); + for (key, value) in &minted.additional_credentials { + updated.credentials.insert(key.clone(), value.clone()); + } + None + }; if minted.expires_at_ms > 0 { updated .credential_expires_at_ms @@ -531,18 +1147,44 @@ async fn apply_minted_credential( updated.credential_expires_at_ms.remove(key); } } - crate::grpc::provider::validate_provider_update_against_attached_sandboxes( + if let Err(err) = crate::grpc::provider::validate_provider_update_against_attached_sandboxes( store, workspace, &updated, ) - .await?; - store + .await + { + if let Some(credentials) = credentials + && let Some(handles) = &staged_handles + { + cleanup_staged_refresh_handles(credentials, provider, handles).await; + } + return Err(err); + } + + // Capture only handles actually replaced in the CAS snapshot. This avoids + // deleting unchanged sibling handles and remains correct if another refresh + // updated the provider after this refresh began. + let mut old_handles_to_delete = HashMap::new(); + let cas_result = store .update_message_cas::(provider.object_id(), 0, |current| { - current - .credentials - .insert(credential_key.to_string(), minted.access_token.clone()); - for (key, value) in &minted.additional_credentials { - current.credentials.insert(key.clone(), value.clone()); - } + if let Some(handles) = staged_handles.clone() { + for (key, handle) in &handles { + current.credentials.remove(key); + if let Some(old_handle) = current + .credential_handles + .insert(key.clone(), handle.clone()) + && old_handle != *handle + { + old_handles_to_delete.insert(key.clone(), old_handle); + } + } + } else { + current + .credentials + .insert(credential_key.to_string(), minted.access_token.clone()); + for (key, value) in &minted.additional_credentials { + current.credentials.insert(key.clone(), value.clone()); + } + } if minted.expires_at_ms > 0 { current .credential_expires_at_ms @@ -561,37 +1203,65 @@ async fn apply_minted_credential( }) .await .map(|_| ()) - .map_err(|e| Status::internal(format!("persist refreshed provider credential failed: {e}"))) -} + .map_err(|e| { + Status::internal(format!("persist refreshed provider credential failed: {e}")) + }); + if cas_result.is_err() + && let Some(credentials) = credentials + && let Some(ref handles) = staged_handles + { + cleanup_staged_refresh_handles(credentials, provider, handles).await; + } -/// Reject minting for strategies that require `providers_v2_enabled` when the -/// setting is off. Runs on every refresh (worker sweep and manual rotation), so -/// disabling the setting halts further mints from already-configured states. -async fn ensure_refresh_providers_v2_gate( - store: &Store, - state: &StoredProviderCredentialRefreshState, -) -> Result<(), Status> { - let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy) - .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); - if strategy != ProviderCredentialRefreshStrategy::AwsStsAssumeRole { - return Ok(()); + // If CAS succeeded and we have old handles to delete, clean them up + if cas_result.is_ok() + && !old_handles_to_delete.is_empty() + && let Some(credentials) = credentials + && let Err(cleanup_err) = credentials + .delete_provider_credential_handles( + provider.object_name(), + provider.object_workspace(), + provider.object_id(), + &old_handles_to_delete, + ) + .await + { + warn!( + provider_name = %provider.object_name(), + error = %cleanup_err, + "failed to clean up old provider credential handles after successful refresh" + ); + // Don't fail the operation - the refresh succeeded, this is just cleanup } - if !crate::grpc::policy::global_bool_setting_enabled( - store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - ) - .await? + + cas_result +} + +async fn cleanup_staged_refresh_handles( + credentials: &crate::credentials::CredentialRuntime, + provider: &Provider, + handles: &HashMap, +) { + if let Err(cleanup_err) = credentials + .delete_provider_credential_handles( + provider.object_name(), + provider.object_workspace(), + provider.object_id(), + handles, + ) + .await { - return Err(Status::failed_precondition( - "aws_sts_assume_role requires providers_v2_enabled=true", - )); + warn!( + provider_name = %provider.object_name(), + error = %cleanup_err, + "failed to clean up staged provider credentials after refresh failure" + ); } - Ok(()) } async fn mint_credential( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy) .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); match strategy { @@ -611,13 +1281,14 @@ async fn mint_credential( | ProviderCredentialRefreshStrategy::Static | ProviderCredentialRefreshStrategy::Unspecified => Err(Status::failed_precondition( format!("refresh strategy '{strategy:?}' cannot be minted by the gateway"), - )), + ) + .into()), } } async fn mint_oauth2_refresh_token( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let token_url = oauth2_token_url(state)?; let client_id = required_material(&state.material, "client_id")?; let refresh_token = required_material(&state.material, "refresh_token")?; @@ -634,12 +1305,18 @@ async fn mint_oauth2_refresh_token( form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token( + &token_url, + &form, + state.max_lifetime_seconds, + OAuthGrantKind::UserRefreshToken, + ) + .await } async fn mint_oauth2_client_credentials( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let token_url = oauth2_token_url(state)?; let client_id = required_material(&state.material, "client_id")?; let client_secret = required_material(&state.material, "client_secret")?; @@ -653,12 +1330,19 @@ async fn mint_oauth2_client_credentials( form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token( + &token_url, + &form, + state.max_lifetime_seconds, + OAuthGrantKind::NonInteractive, + ) + .await } async fn mint_google_service_account_jwt( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { + crate::install_jsonwebtoken_crypto_provider(); let token_url = google_token_url(state); let client_email = required_material(&state.material, "client_email")?; let private_key = required_material(&state.material, "private_key")?; @@ -666,7 +1350,8 @@ async fn mint_google_service_account_jwt( if scopes.is_empty() { return Err(Status::invalid_argument( "google_service_account_jwt requires at least one scope", - )); + ) + .into()); } let now_ms = current_time_ms(); let now_secs = now_ms / 1000; @@ -699,12 +1384,18 @@ async fn mint_google_service_account_jwt( ), ("assertion".to_string(), assertion), ]; - request_token(&token_url, &form, lifetime_secs).await + request_token( + &token_url, + &form, + lifetime_secs, + OAuthGrantKind::NonInteractive, + ) + .await } async fn mint_aws_sts_assume_role( state: &StoredProviderCredentialRefreshState, -) -> Result { +) -> Result { let role_arn = required_material(&state.material, "role_arn")?; let session_name = material_value(&state.material, &["session_name"]) .unwrap_or_else(|| "openshell-sandbox".to_string()); @@ -739,13 +1430,15 @@ async fn mint_aws_sts_assume_role( (None, None) if session_token.is_some() => { return Err(Status::invalid_argument( "aws_session_token requires aws_access_key_id and aws_secret_access_key", - )); + ) + .into()); } (None, None) => {} _ => { return Err(Status::invalid_argument( "aws_access_key_id and aws_secret_access_key must both be set or both omitted", - )); + ) + .into()); } } @@ -829,7 +1522,8 @@ async fn request_token( token_url: &str, form: &[(String, String)], max_lifetime_seconds: i64, -) -> Result { + grant_kind: OAuthGrantKind, +) -> Result { let parsed = reqwest::Url::parse(token_url) .map_err(|_| Status::invalid_argument("token_url must be an absolute URL"))?; match parsed.scheme() { @@ -838,7 +1532,8 @@ async fn request_token( _ => { return Err(Status::invalid_argument( "token_url must use https, except loopback http for local tests", - )); + ) + .into()); } } @@ -851,20 +1546,41 @@ async fn request_token( .form(form) .send() .await - .map_err(|e| Status::unavailable(format!("token endpoint request failed: {e}")))?; + .map_err(|error| { + let error_kind = if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else if error.is_request() { + "request" + } else { + "other" + }; + warn!( + error = %error.without_url(), + error_kind, + "OAuth token endpoint request failed" + ); + RefreshFailure::retryable( + Status::unavailable("token endpoint request failed"), + "oauth_token_endpoint_unavailable", + ) + })?; let status = response.status(); if !status.is_success() { - return Err(Status::failed_precondition(format!( - "token endpoint returned HTTP {status}" - ))); + let body = read_bounded_oauth_error_body(response).await; + return Err(classify_oauth_token_error(status, &body, grant_kind)); } - let token = response - .json::() - .await - .map_err(|_| Status::failed_precondition("token endpoint returned invalid JSON"))?; + let token = response.json::().await.map_err(|_| { + RefreshFailure::investigate( + Status::failed_precondition("token endpoint returned invalid JSON"), + "oauth_invalid_success_response", + ) + })?; if token.access_token.trim().is_empty() { - return Err(Status::failed_precondition( - "token endpoint returned empty access_token", + return Err(RefreshFailure::investigate( + Status::failed_precondition("token endpoint returned empty access_token"), + "oauth_empty_access_token", )); } let now_ms = current_time_ms(); @@ -888,6 +1604,135 @@ async fn request_token( }) } +async fn read_bounded_oauth_error_body(mut response: reqwest::Response) -> Vec { + let mut body = Vec::new(); + while body.len() < MAX_OAUTH_ERROR_RESPONSE_BYTES { + let Ok(Some(chunk)) = response.chunk().await else { + break; + }; + let remaining = MAX_OAUTH_ERROR_RESPONSE_BYTES.saturating_sub(body.len()); + body.extend_from_slice(&chunk[..chunk.len().min(remaining)]); + if chunk.len() >= remaining { + break; + } + } + body +} + +fn classify_oauth_token_error( + status: reqwest::StatusCode, + body: &[u8], + grant_kind: OAuthGrantKind, +) -> RefreshFailure { + if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() { + return RefreshFailure::retryable( + Status::unavailable(format!("token endpoint returned HTTP {status}")), + "oauth_token_endpoint_retryable", + ); + } + + let Ok(error_response) = serde_json::from_slice::(body) else { + return RefreshFailure::investigate( + Status::failed_precondition(format!( + "token endpoint returned HTTP {status} without a recognized OAuth error" + )), + "oauth_unrecognized_error_response", + ); + }; + + match error_response.error.as_str() { + "invalid_grant" if grant_kind == OAuthGrantKind::UserRefreshToken => { + let subtype = error_response + .error_subtype + .as_deref() + .filter(|subtype| *subtype == "invalid_rapt") + .map(|_| "invalid_rapt"); + let message = if subtype.is_some() { + "OAuth refresh grant requires interactive reauthorization (invalid_grant/invalid_rapt)" + } else { + "OAuth refresh grant is no longer usable (invalid_grant); user reauthorization is required" + }; + RefreshFailure::reauthorize( + Status::failed_precondition(message), + "oauth_invalid_grant", + subtype, + ) + } + "invalid_client" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the client configuration (invalid_client)", + ), + "oauth_invalid_client", + ), + "unauthorized_client" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the client grant (unauthorized_client)", + ), + "oauth_unauthorized_client", + ), + "invalid_scope" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the configured scopes (invalid_scope)", + ), + "oauth_invalid_scope", + ), + "unsupported_grant_type" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the configured grant type (unsupported_grant_type)", + ), + "oauth_unsupported_grant_type", + ), + "admin_policy_enforced" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth access is blocked by an administrator policy (admin_policy_enforced)", + ), + "oauth_admin_policy_enforced", + ), + "access_denied" + if error_response.error_subtype.as_deref() == Some("admin_policy_enforced") => + { + RefreshFailure::fix_configuration_with_subtype( + Status::failed_precondition( + "OAuth access is blocked by an administrator policy (access_denied/admin_policy_enforced)", + ), + "oauth_admin_policy_enforced", + "admin_policy_enforced", + ) + } + "access_denied" if grant_kind == OAuthGrantKind::UserRefreshToken => { + RefreshFailure::reauthorize( + Status::failed_precondition( + "OAuth access was denied; user reauthorization is required (access_denied)", + ), + "oauth_access_denied", + None, + ) + } + "access_denied" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth access was denied for the non-interactive grant (access_denied)", + ), + "oauth_access_denied", + ), + "invalid_grant" => RefreshFailure::fix_configuration( + Status::failed_precondition( + "OAuth token endpoint rejected the non-interactive grant (invalid_grant)", + ), + "oauth_invalid_grant", + ), + "server_error" | "temporarily_unavailable" => RefreshFailure::retryable( + Status::unavailable("OAuth token endpoint reported a temporary failure"), + "oauth_token_endpoint_retryable", + ), + _ => RefreshFailure::investigate( + Status::failed_precondition(format!( + "token endpoint returned HTTP {status} with an unrecognized OAuth error" + )), + "oauth_unrecognized_error", + ), + } +} + pub fn refresh_scopes(state: &StoredProviderCredentialRefreshState) -> Vec { if !state.scopes.is_empty() { return state.scopes.clone(); @@ -993,7 +1838,13 @@ pub fn spawn_refresh_worker(state: std::sync::Arc, interval: ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticker.tick().await; - if let Err(err) = run_refresh_worker_tick(state.store.as_ref()).await { + if let Err(err) = run_refresh_worker_tick( + state.store.as_ref(), + Some(&state.credentials), + Some(&state.compute), + ) + .await + { warn!(error = %err, "provider credential refresh worker tick failed"); } } @@ -1009,7 +1860,11 @@ pub fn spawn_refresh_worker(state: std::sync::Arc, interval: due_count = tracing::field::Empty, ) )] -async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { +async fn run_refresh_worker_tick( + store: &Store, + credentials: Option<&crate::credentials::CredentialRuntime>, + compute: Option<&crate::compute::ComputeRuntime>, +) -> Result<(), Status> { let now_ms = current_time_ms(); let states = list_all_refresh_states(store).await.inspect_err(|_| { crate::otel_tracing::mark_error(&tracing::Span::current()); @@ -1031,8 +1886,64 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { due_count, rotation_requested_count, "provider credential refresh worker sweep" ); for state in states { + if state + .metadata + .as_ref() + .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + { + let Some(credentials) = credentials else { + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + "cannot finalize tombstoned provider refresh without credential runtime" + ); + continue; + }; + if let Err(err) = delete_refresh_state_with_credentials( + store, + credentials, + state.object_workspace(), + &state.provider_id, + &state.credential_key, + ) + .await + { + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + error = %err, + "failed to finalize tombstoned provider refresh; retrying on the next sweep" + ); + } + continue; + } + let mut state = state; + let expected_version = state + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + if let Err(err) = + cleanup_pending_secret_deletions(store, credentials, &mut state, expected_version).await + { + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + error = %err, + "provider refresh material cleanup failed; continuing with live refresh" + ); + } let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy) .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); + if !is_gateway_mintable_strategy(strategy) { + warn!( + provider = %state.provider_name, + credential_key = %state.credential_key, + strategy = %refresh_strategy_name(state.strategy), + status = %state.status, + "skipping non-gateway-mintable provider credential refresh state" + ); + continue; + } let due = state.next_refresh_at_ms <= 0 || state.next_refresh_at_ms <= now_ms; let rotation_requested = state.status == "rotation_requested"; info!( @@ -1052,16 +1963,14 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { if !due && !rotation_requested { continue; } - if !is_gateway_mintable_strategy(strategy) { + let Some(credentials) = credentials else { warn!( provider = %state.provider_name, credential_key = %state.credential_key, - strategy = %refresh_strategy_name(state.strategy), - status = %state.status, - "skipping non-gateway-mintable provider credential refresh state" + "cannot refresh provider credential without credential runtime" ); continue; - } + }; info!( provider = %state.provider_name, credential_key = %state.credential_key, @@ -1072,6 +1981,8 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { if let Err(err) = refresh_provider_credential( store, state.object_workspace(), + credentials, + compute, &state.provider_name, &state.credential_key, ) @@ -1094,20 +2005,34 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { #[cfg(test)] mod tests { use super::{ - NewRefreshStateConfig, delete_refresh_state, get_refresh_state, new_refresh_state, - put_refresh_state, refresh_provider_credential, refresh_state_name, refresh_strategy_name, - run_refresh_worker_tick, seconds_until_ms, + MAX_OAUTH_ERROR_RESPONSE_BYTES, NewRefreshStateConfig, OAuthGrantKind, RefreshFailure, + RefreshRetrySchedule, Status, classify_oauth_token_error, + delete_refresh_state_with_credentials, effective_authorization_epoch, + enqueue_pending_secret_deletion, get_refresh_state, list_all_refresh_states, + list_refresh_states_for_provider, new_refresh_state, put_refresh_state, + read_bounded_oauth_error_body, refresh_material_scope, refresh_provider_credential, + refresh_state_name, refresh_strategy_name, run_refresh_worker_tick, seconds_until_ms, + validate_secret_material_references, }; - use crate::persistence::test_store; - use openshell_core::ObjectId; + use crate::credentials::CredentialRuntime; + use crate::persistence::{current_time_ms, test_store}; + use openshell_core::Config; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ - Provider, ProviderCredentialRefreshStrategy, Sandbox, SandboxSpec, + CredentialHandle, Provider, ProviderCredentialRefreshRecoveryAction, + ProviderCredentialRefreshStrategy, Sandbox, SandboxSpec, + StoredProviderCredentialRefreshState, }; + use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use std::collections::HashMap; use wiremock::matchers::{body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; + fn test_credentials() -> CredentialRuntime { + CredentialRuntime::from_config(&Config::new(None).with_credential_drivers(["test-static"])) + .expect("test credential runtime") + } + #[test] fn refresh_state_name_preserves_distinct_credential_keys() { let provider_id = "provider-id"; @@ -1126,6 +2051,68 @@ mod tests { ); } + #[tokio::test] + async fn refresh_state_lists_hydrate_authoritative_resource_versions() { + let store = test_store().await; + let provider_id = "provider-id"; + let state = StoredProviderCredentialRefreshState { + metadata: Some(ObjectMeta { + id: "refresh-id".to_string(), + name: refresh_state_name(provider_id, "ACCESS_TOKEN"), + workspace: "default".to_string(), + ..Default::default() + }), + provider_id: provider_id.to_string(), + credential_key: "ACCESS_TOKEN".to_string(), + ..Default::default() + }; + put_refresh_state(&store, &state).await.unwrap(); + + let scoped = list_refresh_states_for_provider(&store, provider_id) + .await + .unwrap(); + let all = list_all_refresh_states(&store).await.unwrap(); + assert_eq!(scoped[0].metadata.as_ref().unwrap().resource_version, 1); + assert_eq!(all[0].metadata.as_ref().unwrap().resource_version, 1); + } + + #[test] + fn new_refresh_configuration_rotates_authorization_epoch_and_legacy_state_is_stable() { + let provider = Provider { + metadata: Some(ObjectMeta { + id: "provider-id".to_string(), + name: "provider".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + ..Default::default() + }; + let config = || NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms: 0, + token_url: "https://issuer.example/token".to_string(), + scopes: vec!["scope".to_string()], + refresh_before_seconds: 300, + max_lifetime_seconds: 3600, + additional_output_keys: HashMap::new(), + }; + let first = new_refresh_state(&provider, "default", "ACCESS_TOKEN", config()) + .expect("first refresh configuration"); + let second = new_refresh_state(&provider, "default", "ACCESS_TOKEN", config()) + .expect("second refresh configuration"); + assert!(!first.authorization_epoch.is_empty()); + assert_ne!(first.authorization_epoch, second.authorization_epoch); + + let mut legacy = first; + legacy.authorization_epoch.clear(); + assert_eq!( + effective_authorization_epoch(&legacy).expect("legacy migration epoch"), + legacy.metadata.as_ref().expect("metadata").id + ); + } + #[test] fn refresh_log_helpers_format_safe_operational_fields() { assert_eq!(seconds_until_ms(1_000, 61_000), 60); @@ -1150,87 +2137,630 @@ mod tests { assert_eq!(refresh_strategy_name(i32::MAX), "unspecified"); } - #[tokio::test] - async fn oauth2_client_credentials_refresh_mints_and_persists_access_token() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .and(body_string_contains("grant_type=client_credentials")) - .and(body_string_contains("client_id=client-id")) - .and(body_string_contains( - "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "minted-graph-token", - "expires_in": 3600, - "token_type": "Bearer" - }))) - .mount(&mock_server) - .await; + #[test] + fn local_refresh_statuses_default_to_safe_recovery_actions() { + for status in [ + Status::invalid_argument("missing material"), + Status::failed_precondition("strategy cannot be minted"), + Status::permission_denied("client is not allowed"), + ] { + let failure = RefreshFailure::from(status); + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(failure.failure_code, "refresh_configuration_invalid"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration); + } - let store = test_store().await; - let provider = provider("my-graph", "outlook"); - store.put_message(&provider).await.unwrap(); - let before_refresh_ms = crate::persistence::current_time_ms(); - let state = new_refresh_state( - &provider, - "default", - "MS_GRAPH_ACCESS_TOKEN", - NewRefreshStateConfig { - additional_output_keys: HashMap::new(), - strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, - material: HashMap::from([ - ("client_id".to_string(), "client-id".to_string()), - ("client_secret".to_string(), "client-secret".to_string()), - ]), - secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: 0, - token_url: format!("{}/token", mock_server.uri()), - scopes: vec!["https://graph.microsoft.com/.default".to_string()], - refresh_before_seconds: 30, - max_lifetime_seconds: 60, - }, - ) - .unwrap(); - put_refresh_state(&store, &state).await.unwrap(); + let retry = RefreshFailure::from(Status::unavailable("temporary backend outage")); + assert_eq!( + retry.recovery_action, + ProviderCredentialRefreshRecoveryAction::Retry + ); + assert_eq!(retry.retry_schedule, RefreshRetrySchedule::Short); - let refreshed = - refresh_provider_credential(&store, "default", "my-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap(); - assert_eq!(refreshed.status, "refreshed"); - assert!(refreshed.expires_at_ms > 0); - assert!(refreshed.next_refresh_at_ms > 0); - assert!(refreshed.expires_at_ms <= before_refresh_ms + 120_000); - assert!(refreshed.last_error.is_empty()); + let investigate = RefreshFailure::from(Status::unknown("unclassified failure")); + assert_eq!( + investigate.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(investigate.retry_schedule, RefreshRetrySchedule::Short); + } - let stored = store - .get_message_by_name::("default", "my-graph") - .await - .unwrap() - .unwrap(); + #[test] + fn pending_secret_deletions_preserve_multiple_generations_for_one_key() { + let mut state = StoredProviderCredentialRefreshState::default(); + for handle in ["first", "second"] { + enqueue_pending_secret_deletion( + &mut state, + "refresh_token", + CredentialHandle { + driver: "test-static".to_string(), + handle: handle.to_string(), + metadata: HashMap::new(), + }, + ); + } + + assert_eq!(state.pending_secret_deletions.len(), 2); assert_eq!( - stored.credentials.get("MS_GRAPH_ACCESS_TOKEN"), - Some(&"minted-graph-token".to_string()) + state.pending_secret_deletions[0] + .handle + .as_ref() + .unwrap() + .handle, + "first" ); assert_eq!( - stored.credential_expires_at_ms.get("MS_GRAPH_ACCESS_TOKEN"), - Some(&refreshed.expires_at_ms) + state.pending_secret_deletions[1] + .handle + .as_ref() + .unwrap() + .handle, + "second" ); } - #[tokio::test] - async fn refresh_rejects_minted_credential_key_collision_for_attached_sandbox() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "minted-graph-token", - "expires_in": 3600, - "token_type": "Bearer" - }))) - .mount(&mock_server) - .await; + #[test] + fn refresh_rejects_secret_material_lost_by_mixed_version_gateway() { + let mut state = StoredProviderCredentialRefreshState { + secret_material_keys: vec!["refresh_token".to_string()], + ..Default::default() + }; + let err = validate_secret_material_references(&state).unwrap_err(); + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("mixed-version gateway")); + assert!(err.message().contains("refresh_token")); + + state.secret_material_handles.insert( + "refresh_token".to_string(), + CredentialHandle { + driver: "test-static".to_string(), + handle: "stored-token".to_string(), + metadata: HashMap::new(), + }, + ); + validate_secret_material_references(&state).unwrap(); + } + + #[test] + fn oauth_invalid_grant_requires_user_reauthorization_without_exposing_description() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"invalid_grant","error_subtype":"invalid_rapt","error_description":"sensitive provider detail"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize + ); + assert_eq!(failure.failure_code, "oauth_invalid_grant"); + assert_eq!(failure.provider_error_subtype, Some("invalid_rapt")); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Parked); + assert!( + failure + .status + .message() + .contains("interactive reauthorization") + ); + assert!( + !failure + .status + .message() + .contains("sensitive provider detail") + ); + } + + #[test] + fn oauth_invalid_grant_ignores_non_string_optional_subtype() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"invalid_grant","error_subtype":{"vendor":"value"}}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize + ); + assert_eq!(failure.failure_code, "oauth_invalid_grant"); + assert_eq!(failure.provider_error_subtype, None); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Parked); + } + + #[test] + fn oauth_noninteractive_invalid_grant_requires_configuration_fix() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"invalid_grant"}"#, + OAuthGrantKind::NonInteractive, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(failure.failure_code, "oauth_invalid_grant"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration); + } + + #[test] + fn oauth_admin_policy_subtype_requires_configuration_fix() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"access_denied","error_subtype":"admin_policy_enforced"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(failure.failure_code, "oauth_admin_policy_enforced"); + assert_eq!( + failure.provider_error_subtype, + Some("admin_policy_enforced") + ); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration); + } + + #[test] + fn oauth_access_denied_recovery_depends_on_grant_kind() { + let user_failure = classify_oauth_token_error( + reqwest::StatusCode::FORBIDDEN, + br#"{"error":"access_denied"}"#, + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + user_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize + ); + assert_eq!(user_failure.failure_code, "oauth_access_denied"); + assert_eq!(user_failure.retry_schedule, RefreshRetrySchedule::Parked); + + let service_failure = classify_oauth_token_error( + reqwest::StatusCode::FORBIDDEN, + br#"{"error":"access_denied"}"#, + OAuthGrantKind::NonInteractive, + ); + assert_eq!( + service_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(service_failure.failure_code, "oauth_access_denied"); + assert_eq!( + service_failure.retry_schedule, + RefreshRetrySchedule::Configuration + ); + } + + #[test] + fn oauth_server_failure_remains_retryable() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + br#"{"error":"invalid_grant"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Retry + ); + assert_eq!(failure.failure_code, "oauth_token_endpoint_retryable"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Short); + } + + #[test] + fn unrecognized_oauth_error_requests_investigation_without_echoing_body() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"vendor_secret_error","error_description":"do not expose me"}"#, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(failure.failure_code, "oauth_unrecognized_error"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Short); + assert!(!failure.status.message().contains("vendor_secret_error")); + assert!(!failure.status.message().contains("do not expose me")); + } + + #[test] + fn html_and_malformed_oauth_errors_are_investigated_without_echoing_body() { + for body in [b"issuer failure".as_slice(), b"{".as_slice()] { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + body, + OAuthGrantKind::UserRefreshToken, + ); + + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(failure.failure_code, "oauth_unrecognized_error_response"); + assert!(!failure.status.message().contains("issuer failure")); + } + } + + #[tokio::test] + async fn oversized_oauth_error_body_is_bounded_and_investigated() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/oversized")) + .respond_with(ResponseTemplate::new(400).set_body_bytes(vec![ + b'x'; + MAX_OAUTH_ERROR_RESPONSE_BYTES + + 512 + ])) + .mount(&mock_server) + .await; + + let response = reqwest::get(format!("{}/oversized", mock_server.uri())) + .await + .unwrap(); + let status = response.status(); + let body = read_bounded_oauth_error_body(response).await; + assert_eq!(body.len(), MAX_OAUTH_ERROR_RESPONSE_BYTES); + + let failure = classify_oauth_token_error(status, &body, OAuthGrantKind::UserRefreshToken); + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(failure.failure_code, "oauth_unrecognized_error_response"); + } + + #[tokio::test] + async fn invalid_local_oauth_configuration_retries_hourly() { + let store = test_store().await; + let provider = provider("invalid-local-config", "outlook"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("refresh_token".to_string(), "refresh-token".to_string()), + ]), + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: 0, + token_url: "not-an-absolute-url".to_string(), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let err = refresh_provider_credential( + &store, + "default", + &test_credentials(), + None, + "invalid-local-config", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + let stored = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "configuration_required"); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 + ); + assert_eq!(stored.failure_code, "refresh_configuration_invalid"); + assert_eq!( + stored.next_refresh_at_ms - stored.last_error_at_ms, + 60 * 60 * 1000 + ); + } + + #[tokio::test] + async fn oauth_invalid_grant_persists_terminal_reauthorization_status() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_grant", + "error_subtype": "invalid_rapt", + "error_description": "provider-controlled detail" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("expired-grant", "outlook"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ( + "refresh_token".to_string(), + "expired-refresh-token".to_string(), + ), + ]), + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let err = refresh_provider_credential( + &store, + "default", + &test_credentials(), + None, + "expired-grant", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + let stored = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "reauthorization_required"); + assert_eq!(stored.next_refresh_at_ms, i64::MAX); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize as i32 + ); + assert_eq!(stored.failure_code, "oauth_invalid_grant"); + assert_eq!(stored.provider_error_subtype, "invalid_rapt"); + assert!(stored.last_error_at_ms > 0); + assert!(!stored.last_error.contains("provider-controlled detail")); + } + + #[tokio::test] + async fn oauth2_client_credentials_refresh_mints_and_persists_access_token() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("grant_type=client_credentials")) + .and(body_string_contains("client_id=client-id")) + .and(body_string_contains( + "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "minted-graph-token", + "expires_in": 3600, + "token_type": "Bearer" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("my-graph", "outlook"); + store.put_message(&provider).await.unwrap(); + let before_refresh_ms = current_time_ms(); + let mut state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + additional_output_keys: HashMap::new(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), "client-secret".to_string()), + ]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: vec!["https://graph.microsoft.com/.default".to_string()], + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + }, + ) + .unwrap(); + state.status = "investigation_required".to_string(); + state.last_error = "safe prior error".to_string(); + state.recovery_action = ProviderCredentialRefreshRecoveryAction::Investigate as i32; + state.failure_code = "oauth_unrecognized_error".to_string(); + state.provider_error_subtype = "prior_subtype".to_string(); + state.last_error_at_ms = current_time_ms(); + put_refresh_state(&store, &state).await.unwrap(); + let authorization_epoch = state.authorization_epoch.clone(); + let credentials = test_credentials(); + + let refreshed = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "my-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap(); + assert_eq!(refreshed.authorization_epoch, authorization_epoch); + assert_eq!(refreshed.status, "refreshed"); + assert!(refreshed.expires_at_ms > 0); + assert!(refreshed.next_refresh_at_ms > 0); + assert!(refreshed.expires_at_ms <= before_refresh_ms + 120_000); + assert!(refreshed.last_error.is_empty()); + assert_eq!( + refreshed.recovery_action, + ProviderCredentialRefreshRecoveryAction::Unspecified as i32 + ); + assert!(refreshed.failure_code.is_empty()); + assert!(refreshed.provider_error_subtype.is_empty()); + assert_eq!(refreshed.last_error_at_ms, 0); + + let stored = store + .get_message_by_name::("default", "my-graph") + .await + .unwrap() + .unwrap(); + let resolved = credentials + .resolve_provider_handles(&stored, current_time_ms()) + .await + .unwrap(); + assert_eq!( + resolved.values.get("MS_GRAPH_ACCESS_TOKEN"), + Some(&"minted-graph-token".to_string()) + ); + assert_eq!( + stored.credential_expires_at_ms.get("MS_GRAPH_ACCESS_TOKEN"), + Some(&refreshed.expires_at_ms) + ); + } + + #[tokio::test] + async fn oauth2_client_credentials_refresh_stores_access_token_with_credential_runtime() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "stored-graph-token", + "expires_in": 3600, + "token_type": "Bearer" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("my-stored-graph", "outlook"); + store.put_message(&provider).await.unwrap(); + let mut state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), "client-secret".to_string()), + ]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + let config = Config::new(None).with_credential_drivers(["test-static"]); + let credentials = CredentialRuntime::from_config(&config).unwrap(); + state.secret_material_handles = credentials + .store_refresh_material_with_object_id( + refresh_material_scope(&state), + "configured-client-secret", + &HashMap::from([("client_secret".to_string(), "client-secret".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + state.material.remove("client_secret"); + put_refresh_state(&store, &state).await.unwrap(); + let authorization_epoch = state.authorization_epoch.clone(); + + let refreshed = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "my-stored-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap(); + assert_eq!(refreshed.authorization_epoch, authorization_epoch); + let stored_refresh = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert!(!stored_refresh.material.contains_key("client_secret")); + assert!( + stored_refresh + .secret_material_handles + .contains_key("client_secret") + ); + + let stored = store + .get_message_by_name::("default", "my-stored-graph") + .await + .unwrap() + .unwrap(); + assert!(!stored.credentials.contains_key("MS_GRAPH_ACCESS_TOKEN")); + let handle = stored + .credential_handles + .get("MS_GRAPH_ACCESS_TOKEN") + .unwrap(); + assert_eq!(handle.driver, "test-static"); + assert_eq!( + stored.credential_expires_at_ms.get("MS_GRAPH_ACCESS_TOKEN"), + Some(&refreshed.expires_at_ms) + ); + + let resolved = credentials + .resolve_provider_handles(&stored, current_time_ms()) + .await + .unwrap(); + assert_eq!( + resolved.values.get("MS_GRAPH_ACCESS_TOKEN"), + Some(&"stored-graph-token".to_string()) + ); + } + + #[tokio::test] + async fn refresh_rejects_minted_credential_key_collision_for_attached_sandbox() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "minted-graph-token", + "expires_in": 3600, + "token_type": "Bearer" + }))) + .mount(&mock_server) + .await; let store = test_store().await; let mut provider_a = provider("existing-graph", "outlook"); @@ -1282,10 +2812,13 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); let err = refresh_provider_credential( &store, "default", + &credentials, + None, "refreshing-graph", "MS_GRAPH_ACCESS_TOKEN", ) @@ -1303,7 +2836,16 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!(stored_state.status, "error"); + assert_eq!(stored_state.status, "configuration_required"); + assert_eq!( + stored_state.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 + ); + assert_eq!(stored_state.failure_code, "refresh_configuration_invalid"); + assert_eq!( + stored_state.next_refresh_at_ms - stored_state.last_error_at_ms, + 60 * 60 * 1000 + ); assert!(stored_state.last_error.contains("MS_GRAPH_ACCESS_TOKEN")); let stored_provider = store .get_message_by_name::("default", "refreshing-graph") @@ -1361,10 +2903,16 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = CredentialRuntime::from_config( + &Config::new(None).with_credential_drivers(["test-static"]), + ) + .unwrap(); let refreshed = refresh_provider_credential( &store, "default", + &credentials, + None, "my-delegated-graph", "MS_GRAPH_ACCESS_TOKEN", ) @@ -1378,9 +2926,15 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!( - stored_provider.credentials.get("MS_GRAPH_ACCESS_TOKEN"), - Some(&"delegated-graph-token".to_string()) + assert!( + !stored_provider + .credentials + .contains_key("MS_GRAPH_ACCESS_TOKEN") + ); + assert!( + stored_provider + .credential_handles + .contains_key("MS_GRAPH_ACCESS_TOKEN") ); assert_eq!( stored_provider @@ -1389,7 +2943,203 @@ mod tests { Some(&refreshed.expires_at_ms) ); - let stored_state = get_refresh_state( + let stored_state = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert!(!stored_state.material.contains_key("refresh_token")); + assert!( + stored_state + .secret_material_handles + .contains_key("refresh_token") + ); + assert!(stored_state.pending_secret_deletions.is_empty()); + assert_eq!( + credentials + .resolve_refresh_material( + refresh_material_scope(&stored_state), + &stored_state.secret_material_handles, + ) + .await + .unwrap() + .get("refresh_token"), + Some(&"rotated-refresh-token".to_string()) + ); + assert!( + stored_state + .secret_material_keys + .iter() + .any(|key| key == "refresh_token") + ); + assert_eq!( + credentials.stored_credential_count(), + Some(2), + "only the access token and current refresh token remain" + ); + } + + #[tokio::test] + async fn refresh_continues_when_pending_secret_cleanup_temporarily_fails() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "minted-after-cleanup-failure", + "expires_in": 3600, + "token_type": "Bearer" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("cleanup-retry", "outlook"); + store.put_message(&provider).await.unwrap(); + let credentials = test_credentials(); + let mut state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), "client-secret".to_string()), + ]), + secret_material_keys: vec!["client_secret".to_string()], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + state.secret_material_handles = credentials + .store_refresh_material_with_object_id( + refresh_material_scope(&state), + "current-refresh-object", + &HashMap::from([("client_secret".to_string(), "client-secret".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + state.material.remove("client_secret"); + let old = credentials + .store_refresh_material_with_object_id( + refresh_material_scope(&state), + "old-refresh-object", + &HashMap::from([("client_secret".to_string(), "obsolete".to_string())]), + &HashMap::new(), + ) + .await + .unwrap() + .remove("client_secret") + .unwrap(); + enqueue_pending_secret_deletion(&mut state, "client_secret", old); + put_refresh_state(&store, &state).await.unwrap(); + credentials.fail_next_delete(); + + let refreshed = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "cleanup-retry", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap(); + + assert_eq!(refreshed.status, "refreshed"); + assert!(refreshed.pending_secret_deletions.is_empty()); + let stored = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert!(stored.pending_secret_deletions.is_empty()); + assert_eq!( + credentials.stored_credential_count(), + Some(2), + "the current client secret and minted access token remain" + ); + } + + #[tokio::test] + async fn rotated_refresh_token_store_failure_requires_reauthorization() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "access-token-that-must-not-be-applied", + "refresh_token": "replacement-refresh-token", + "expires_in": 3600, + "token_type": "Bearer" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("rotation-store-failure", "outlook"); + store.put_message(&provider).await.unwrap(); + let credentials = test_credentials(); + let mut state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("refresh_token".to_string(), "old-refresh-token".to_string()), + ]), + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + state.secret_material_handles = credentials + .store_refresh_material_with_object_id( + refresh_material_scope(&state), + "original-grant", + &HashMap::from([("refresh_token".to_string(), "old-refresh-token".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + state.material.remove("refresh_token"); + put_refresh_state(&store, &state).await.unwrap(); + credentials.fail_next_store(); + + let err = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "rotation-store-failure", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("must be re-authorized")); + let stored = get_refresh_state( &store, "default", provider.object_id(), @@ -1398,16 +3148,35 @@ mod tests { .await .unwrap() .unwrap(); + assert_eq!(stored.status, "reauthorization_required"); + assert!(stored.last_error.contains("must be re-authorized")); + assert_eq!(stored.next_refresh_at_ms, i64::MAX); assert_eq!( - stored_state.material.get("refresh_token"), - Some(&"rotated-refresh-token".to_string()) + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize as i32 ); - assert!( - stored_state - .secret_material_keys - .iter() - .any(|key| key == "refresh_token") + assert_eq!( + stored.failure_code, + "oauth_rotated_refresh_token_store_failed" + ); + assert_eq!(credentials.stored_credential_count(), Some(1)); + assert_eq!( + credentials + .resolve_refresh_material( + refresh_material_scope(&stored), + &stored.secret_material_handles, + ) + .await + .unwrap() + .get("refresh_token"), + Some(&"old-refresh-token".to_string()) ); + let stored_provider = store + .get_message_by_name::("default", "rotation-store-failure") + .await + .unwrap() + .unwrap(); + assert!(stored_provider.credential_handles.is_empty()); } #[tokio::test] @@ -1454,11 +3223,18 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); - let refreshed = - refresh_provider_credential(&store, "default", "my-drive", "GOOGLE_DRIVE_ACCESS_TOKEN") - .await - .unwrap(); + let refreshed = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "my-drive", + "GOOGLE_DRIVE_ACCESS_TOKEN", + ) + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); @@ -1467,8 +3243,12 @@ mod tests { .await .unwrap() .unwrap(); + let resolved = credentials + .resolve_provider_handles(&stored, current_time_ms()) + .await + .unwrap(); assert_eq!( - stored.credentials.get("GOOGLE_DRIVE_ACCESS_TOKEN"), + resolved.values.get("GOOGLE_DRIVE_ACCESS_TOKEN"), Some(&"minted-drive-token".to_string()) ); } @@ -1497,7 +3277,7 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - run_refresh_worker_tick(&store).await.unwrap(); + run_refresh_worker_tick(&store, None, None).await.unwrap(); let stored_state = get_refresh_state( &store, @@ -1523,16 +3303,125 @@ mod tests { ); } + #[tokio::test] + async fn refresh_worker_skips_parked_reauthorization_state() { + let store = test_store().await; + let provider = provider("parked-refresh", "outlook"); + store.put_message(&provider).await.unwrap(); + let mut state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms: 0, + token_url: "https://issuer.example/token".to_string(), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + state.status = "reauthorization_required".to_string(); + state.last_error = "OAuth refresh grant is no longer usable".to_string(); + state.recovery_action = ProviderCredentialRefreshRecoveryAction::Reauthorize as i32; + state.failure_code = "oauth_invalid_grant".to_string(); + state.last_error_at_ms = current_time_ms(); + state.next_refresh_at_ms = i64::MAX; + put_refresh_state(&store, &state).await.unwrap(); + + run_refresh_worker_tick(&store, Some(&test_credentials()), None) + .await + .unwrap(); + + let stored = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "reauthorization_required"); + assert_eq!(stored.next_refresh_at_ms, i64::MAX); + assert_eq!(stored.failure_code, "oauth_invalid_grant"); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize as i32 + ); + } + + #[tokio::test] + async fn refresh_worker_finalizes_tombstoned_refresh_material() { + let store = test_store().await; + let provider = provider("tombstoned-refresh", "outlook"); + store.put_message(&provider).await.unwrap(); + let credentials = test_credentials(); + let mut state = new_refresh_state( + &provider, + "default", + "MS_GRAPH_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([("refresh_token".to_string(), "delete-me".to_string())]), + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: 0, + token_url: "https://issuer.example/token".to_string(), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + state.secret_material_handles = credentials + .store_refresh_material_with_object_id( + refresh_material_scope(&state), + "tombstoned-grant", + &state.material, + &HashMap::new(), + ) + .await + .unwrap(); + state.material.clear(); + state.metadata.as_mut().unwrap().deletion_timestamp_ms = current_time_ms(); + state.status = "deleting".to_string(); + put_refresh_state(&store, &state).await.unwrap(); + assert_eq!(credentials.stored_credential_count(), Some(1)); + + run_refresh_worker_tick(&store, Some(&credentials), None) + .await + .unwrap(); + + assert!( + get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .is_none() + ); + assert_eq!(credentials.stored_credential_count(), Some(0)); + } + /// The worker ticks on a timer with no inbound request, so without a span /// of its own its store reads export as anonymous single-span traces. #[tokio::test] + #[ignore = "flaky under concurrent test execution"] async fn refresh_worker_ticks_are_roots_and_store_operations_have_parents() { use crate::otel_tracing::test_exporter; let store = test_store().await; let traced = test_exporter::install_traced(); - run_refresh_worker_tick(&store).await.unwrap(); + run_refresh_worker_tick(&store, None, None).await.unwrap(); let spans = traced.finished_spans(); let root = spans @@ -1603,13 +3492,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-test", "aws"); store.put_message(&prov).await.unwrap(); @@ -1649,11 +3531,18 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); - let refreshed = - refresh_provider_credential(&store, "default", "aws-sts-test", "AWS_ACCESS_KEY_ID") - .await - .unwrap(); + let refreshed = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "aws-sts-test", + "AWS_ACCESS_KEY_ID", + ) + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); @@ -1662,16 +3551,20 @@ mod tests { .await .unwrap() .unwrap(); + let resolved = credentials + .resolve_provider_handles(&stored, current_time_ms()) + .await + .unwrap(); assert_eq!( - stored.credentials.get("AWS_ACCESS_KEY_ID"), + resolved.values.get("AWS_ACCESS_KEY_ID"), Some(&"ASIAMOCKKEY".to_string()) ); assert_eq!( - stored.credentials.get("AWS_SECRET_ACCESS_KEY"), + resolved.values.get("AWS_SECRET_ACCESS_KEY"), Some(&"MockSecretAccessKey123".to_string()) ); assert_eq!( - stored.credentials.get("AWS_SESSION_TOKEN"), + resolved.values.get("AWS_SESSION_TOKEN"), Some(&"MockSessionTokenXYZ".to_string()) ); } @@ -1697,13 +3590,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-custom", "aws"); store.put_message(&prov).await.unwrap(); @@ -1741,41 +3627,46 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); - refresh_provider_credential(&store, "default", "aws-sts-custom", "AWS_ACCESS_KEY_ID") - .await - .unwrap(); + refresh_provider_credential( + &store, + "default", + &credentials, + None, + "aws-sts-custom", + "AWS_ACCESS_KEY_ID", + ) + .await + .unwrap(); let stored = store .get_message_by_name::("default", "aws-sts-custom") .await .unwrap() .unwrap(); + let resolved = credentials + .resolve_provider_handles(&stored, current_time_ms()) + .await + .unwrap(); assert_eq!( - stored.credentials.get("AWS_ACCESS_KEY_ID"), + resolved.values.get("AWS_ACCESS_KEY_ID"), Some(&"ASIAMOCKKEY".to_string()) ); assert_eq!( - stored.credentials.get("CUSTOM_SECRET"), + resolved.values.get("CUSTOM_SECRET"), Some(&"MockSecretAccessKey123".to_string()) ); assert_eq!( - stored.credentials.get("CUSTOM_SESSION"), + resolved.values.get("CUSTOM_SESSION"), Some(&"MockSessionTokenXYZ".to_string()) ); - assert!(!stored.credentials.contains_key("AWS_SECRET_ACCESS_KEY")); + assert!(!resolved.values.contains_key("AWS_SECRET_ACCESS_KEY")); } #[tokio::test] async fn aws_sts_mint_rejects_partial_source_credentials() { let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-partial", "aws"); store.put_message(&prov).await.unwrap(); @@ -1811,11 +3702,18 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); - let err = - refresh_provider_credential(&store, "default", "aws-sts-partial", "AWS_ACCESS_KEY_ID") - .await - .unwrap_err(); + let err = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "aws-sts-partial", + "AWS_ACCESS_KEY_ID", + ) + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); assert!(err.message().contains("both be set or both omitted")); @@ -1853,9 +3751,17 @@ mod tests { ]), }; - apply_minted_credential(&store, "default", &prov, "AWS_ACCESS_KEY_ID", &minted) - .await - .unwrap(); + apply_minted_credential( + &store, + "default", + None, + None, + &prov, + "AWS_ACCESS_KEY_ID", + &minted, + ) + .await + .unwrap(); let stored = store .get_message_by_name::("default", "aws-test") @@ -1888,6 +3794,94 @@ mod tests { ); } + #[tokio::test] + async fn apply_minted_credential_replaces_only_refreshed_handles() { + use super::apply_minted_credential; + + let store = test_store().await; + let credentials = CredentialRuntime::from_config( + &Config::new(None).with_credential_drivers(["test-static"]), + ) + .unwrap(); + let mut prov = provider("stored-aws", "aws"); + let original_handles = credentials + .store_provider_credentials( + prov.object_name(), + prov.object_workspace(), + prov.object_id(), + &HashMap::from([ + ("AWS_ACCESS_KEY_ID".to_string(), "old-key".to_string()), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + "unchanged-secret".to_string(), + ), + ]), + &HashMap::new(), + ) + .await + .unwrap(); + prov.credential_handles.clone_from(&original_handles); + store.put_message(&prov).await.unwrap(); + + let minted = super::MintedCredential { + access_token: "new-key".to_string(), + expires_at_ms: 4_000_000_000_000, + refresh_token: None, + additional_credentials: HashMap::new(), + }; + + apply_minted_credential( + &store, + "default", + Some(&credentials), + None, + &prov, + "AWS_ACCESS_KEY_ID", + &minted, + ) + .await + .unwrap(); + + let stored = store + .get_message_by_name::("default", "stored-aws") + .await + .unwrap() + .unwrap(); + assert_ne!( + stored.credential_handles.get("AWS_ACCESS_KEY_ID"), + original_handles.get("AWS_ACCESS_KEY_ID") + ); + assert_eq!( + stored.credential_handles.get("AWS_SECRET_ACCESS_KEY"), + original_handles.get("AWS_SECRET_ACCESS_KEY") + ); + let resolved = credentials + .resolve_provider_handles(&stored, current_time_ms()) + .await + .unwrap(); + assert_eq!( + resolved.values.get("AWS_ACCESS_KEY_ID"), + Some(&"new-key".to_string()) + ); + assert_eq!( + resolved.values.get("AWS_SECRET_ACCESS_KEY"), + Some(&"unchanged-secret".to_string()) + ); + + let old_handle_provider = Provider { + credential_handles: HashMap::from([( + "AWS_ACCESS_KEY_ID".to_string(), + original_handles["AWS_ACCESS_KEY_ID"].clone(), + )]), + ..prov + }; + let err = credentials + .resolve_provider_handles(&old_handle_provider, current_time_ms()) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::NotFound); + } + #[tokio::test] async fn apply_minted_credential_validates_additional_keys_against_sandboxes() { use super::apply_minted_credential; @@ -1935,10 +3929,16 @@ mod tests { ("AWS_SESSION_TOKEN".to_string(), "session-token".to_string()), ]), }; + let credentials = CredentialRuntime::from_config( + &Config::new(None).with_credential_drivers(["test-static"]), + ) + .unwrap(); let err = apply_minted_credential( &store, "default", + Some(&credentials), + None, &refreshing_provider, "AWS_ACCESS_KEY_ID", &minted, @@ -1947,6 +3947,7 @@ mod tests { .unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); assert!(err.message().contains("AWS_SECRET_ACCESS_KEY")); + assert_eq!(credentials.stored_credential_count(), Some(0)); } // A wiremock responder that blocks the STS response until the test releases @@ -1990,13 +3991,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-session", "aws"); store.put_message(&prov).await.unwrap(); @@ -2043,19 +4037,30 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); - let refreshed = - refresh_provider_credential(&store, "default", "aws-sts-session", "AWS_ACCESS_KEY_ID") - .await - .unwrap(); + let refreshed = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "aws-sts-session", + "AWS_ACCESS_KEY_ID", + ) + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); let stored = store .get_message_by_name::("default", "aws-sts-session") .await .unwrap() .unwrap(); + let resolved = credentials + .resolve_provider_handles(&stored, current_time_ms()) + .await + .unwrap(); assert_eq!( - stored.credentials.get("AWS_ACCESS_KEY_ID"), + resolved.values.get("AWS_ACCESS_KEY_ID"), Some(&"ASIAMOCKKEY".to_string()) ); } @@ -2063,13 +4068,6 @@ mod tests { #[tokio::test] async fn aws_sts_mint_rejects_session_token_without_source_pair() { let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-lonesession", "aws"); store.put_message(&prov).await.unwrap(); @@ -2106,10 +4104,13 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); let err = refresh_provider_credential( &store, "default", + &credentials, + None, "aws-sts-lonesession", "AWS_ACCESS_KEY_ID", ) @@ -2145,13 +4146,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-race", "aws"); store.put_message(&prov).await.unwrap(); let provider_id = prov.object_id().to_string(); @@ -2191,9 +4185,16 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); - let rotate = - refresh_provider_credential(&store, "default", "aws-race", "AWS_ACCESS_KEY_ID"); + let rotate = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "aws-race", + "AWS_ACCESS_KEY_ID", + ); let interfere = async { // Wait until the rotation is inside the STS call (its state read has // already happened), then delete the refresh and release STS. @@ -2203,9 +4204,15 @@ mod tests { { return; } - delete_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID") - .await - .unwrap(); + delete_refresh_state_with_credentials( + &store, + &credentials, + "default", + &provider_id, + "AWS_ACCESS_KEY_ID", + ) + .await + .unwrap(); let _ = release_tx.send(()); }; let (rotate_result, ()) = tokio::join!(rotate, interfere); @@ -2260,13 +4267,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-superseded", "aws"); store.put_message(&prov).await.unwrap(); let provider_id = prov.object_id().to_string(); @@ -2306,9 +4306,16 @@ mod tests { ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); - let rotate = - refresh_provider_credential(&store, "default", "aws-superseded", "AWS_ACCESS_KEY_ID"); + let rotate = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "aws-superseded", + "AWS_ACCESS_KEY_ID", + ); let interfere = async { if tokio::time::timeout(std::time::Duration::from_secs(15), hit_rx) .await @@ -2370,6 +4377,7 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: "default".to_string(), + credential_handles: HashMap::new(), } } diff --git a/crates/openshell-server/src/sandbox_index.rs b/crates/openshell-server/src/sandbox_index.rs index 589f88fd88..c119ca6889 100644 --- a/crates/openshell-server/src/sandbox_index.rs +++ b/crates/openshell-server/src/sandbox_index.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! In-memory indexes for correlating Kubernetes objects back to sandbox ids. +//! In-memory indexes for correlating compute resources back to sandbox ids. use std::collections::HashMap; use std::sync::{Arc, RwLock}; diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index c3b4375b4f..3e80bc26f5 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -856,6 +856,9 @@ mod tests { key_path: "server.key".into(), client_ca_path: Some("ca.crt".into()), require_client_auth: false, + external_cert_path: None, + external_key_path: None, + external_server_names: Vec::new(), } } diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 490f0dbb25..12081c217d 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -7,11 +7,14 @@ use openshell_core::ObjectId; use openshell_core::proto::SshSession; use openshell_core::time::now_ms; use prost::Message; +use std::future::Future; use std::sync::Arc; use std::time::Duration; use tracing::{info, warn}; -use crate::persistence::{ObjectType, Store}; +use crate::persistence::{ObjectCursor, ObjectType, Store}; + +const SESSION_REAPER_PAGE_SIZE: u32 = 1000; impl ObjectType for SshSession { fn object_type() -> &'static str { @@ -34,37 +37,69 @@ pub fn spawn_session_reaper(store: Arc, interval: Duration) { } async fn reap_expired_sessions(store: &Store) -> Result<(), String> { - let now_ms = now_ms(); - - let records = store - .list_by_type(SshSession::object_type(), 1000, 0) - .await - .map_err(|e| e.to_string())?; + reap_expired_sessions_after_page(store, |_| std::future::ready(())).await +} - let mut reaped = 0u32; - for record in records { - let session: SshSession = match Message::decode(record.payload.as_slice()) { - Ok(s) => s, - Err(_) => continue, - }; +async fn reap_expired_sessions_after_page( + store: &Store, + mut after_page: F, +) -> Result<(), String> +where + F: FnMut(usize) -> Fut, + Fut: Future, +{ + let now_ms = now_ms(); + let started = std::time::Instant::now(); + let mut cursor = None; + let mut page_number = 0_usize; + let mut scanned = 0_usize; + let mut decode_failures = 0_usize; + let mut session_ids = Vec::new(); - let should_delete = - (session.expires_at_ms > 0 && now_ms > session.expires_at_ms) || session.revoked; + loop { + let records = store + .list_by_type_after( + SshSession::object_type(), + cursor.as_ref(), + SESSION_REAPER_PAGE_SIZE, + ) + .await + .map_err(|e| e.to_string())?; + let page_len = records.len(); + scanned += page_len; - if should_delete { - if let Err(e) = store - .delete(SshSession::object_type(), session.object_id()) - .await - { - warn!(session_id = %session.object_id(), error = %e, "Failed to reap SSH session"); - } else { - reaped += 1; + cursor = records.last().map(ObjectCursor::from); + for record in records { + let Ok(session) = SshSession::decode(record.payload.as_slice()) else { + decode_failures += 1; + continue; + }; + if (session.expires_at_ms > 0 && now_ms > session.expires_at_ms) || session.revoked { + session_ids.push(session.object_id().to_string()); } } + + if page_len < SESSION_REAPER_PAGE_SIZE as usize { + break; + } + page_number += 1; + after_page(page_number).await; } - if reaped > 0 { - info!(count = reaped, "SSH session reaper: cleaned up sessions"); + let matched = session_ids.len(); + let deleted = store + .delete_many(SshSession::object_type(), &session_ids) + .await + .map_err(|e| e.to_string())?; + if matched > 0 || decode_failures > 0 { + info!( + scanned, + matched, + deleted, + decode_failures, + elapsed_ms = started.elapsed().as_millis(), + "SSH session reaper sweep complete" + ); } Ok(()) } @@ -175,4 +210,71 @@ mod tests { "session with no expiry should be preserved" ); } + + #[tokio::test] + async fn reaper_batches_expired_and_revoked_sessions() { + let store = test_store().await; + let session_count = crate::persistence::DELETE_MANY_BATCH_SIZE + 9; + for idx in 0..session_count { + let session = make_session( + &format!("reap-{idx}"), + "sbx1", + if idx % 2 == 0 { now_ms() - 1 } else { 0 }, + idx % 2 != 0, + ); + store.put_message(&session).await.unwrap(); + } + let active = make_session("keep", "sbx1", now_ms() + 60_000, false); + store.put_message(&active).await.unwrap(); + + reap_expired_sessions(&store).await.unwrap(); + + assert_eq!( + store + .count_in_workspace(SshSession::object_type(), "default") + .await + .unwrap(), + 1 + ); + assert!( + store + .get_message::("keep") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn reaper_removes_every_expired_session_when_an_earlier_page_row_is_deleted() { + let store = test_store().await; + for idx in 0..=SESSION_REAPER_PAGE_SIZE { + let session = make_session(&format!("reap-{idx:04}"), "sbx1", now_ms() - 1, false); + store.put_message(&session).await.unwrap(); + } + + let delete_store = store.clone(); + reap_expired_sessions_after_page(&store, move |page_number| { + let delete_store = delete_store.clone(); + async move { + if page_number == 1 { + delete_store + .delete(SshSession::object_type(), "reap-0000") + .await + .unwrap(); + } + } + }) + .await + .unwrap(); + + assert_eq!( + store + .count_in_workspace(SshSession::object_type(), "default") + .await + .unwrap(), + 0, + "the reaper must not leave an expired session behind" + ); + } } diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index e6b8085151..c8491dc1eb 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::pin::Pin; +use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -13,8 +14,9 @@ use tracing::{debug, info, warn}; use uuid::Uuid; use openshell_core::proto::{ - GatewayMessage, RelayFrame, RelayInit, RelayOpen, Sandbox, SandboxPhase, SessionAccepted, - SshRelayTarget, SupervisorMessage, gateway_message, relay_open, supervisor_message, + GatewayMessage, RelayFrame, RelayInit, RelayOpen, ReportMainProcessExitRequest, + ReportMainProcessExitResponse, Sandbox, SandboxPhase, SessionAccepted, SshRelayTarget, + SupervisorMessage, gateway_message, relay_open, supervisor_message, }; use openshell_core::transport_errors::is_expected_transport_close_status; @@ -56,6 +58,9 @@ struct LiveSession { /// the old session's `tx` just before supersede could still enqueue a /// `RelayOpen` onto the stale stream and sit until the relay timeout. shutdown: oneshot::Sender<()>, + /// Set after the supervisor confirms that every expected foreground + /// attachment has closed and terminal output delivery is complete. + terminal_delivery_finalized: bool, #[allow(dead_code)] connected_at: Instant, } @@ -123,6 +128,7 @@ impl SupervisorSessionRegistry { session_id, tx, shutdown, + terminal_delivery_finalized: false, connected_at: Instant::now(), }, ); @@ -141,21 +147,37 @@ impl SupervisorSessionRegistry { self.sessions.lock().unwrap().remove(sandbox_id); } + /// Disconnect the current supervisor session for a sandbox. + /// + /// Lifecycle stop uses this to ensure a later start must establish + /// a fresh session before the sandbox can return to Ready. + pub fn disconnect(&self, sandbox_id: &str) -> bool { + let session = self.sessions.lock().unwrap().remove(sandbox_id); + if let Some(session) = session { + let _ = session.shutdown.send(()); + true + } else { + false + } + } + /// Remove the session only if its `session_id` matches the one we are /// cleaning up. Returns `true` if the entry was removed. /// /// This guards against the supersede race: an old session's task may /// finish long after a new session has taken its place. The old task's /// cleanup must not evict the new registration. - fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> bool { + fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> Option { let mut sessions = self.sessions.lock().unwrap(); let is_current = sessions .get(sandbox_id) .is_some_and(|s| s.session_id == session_id); if is_current { - sessions.remove(sandbox_id); + return sessions + .remove(sandbox_id) + .map(|session| session.terminal_delivery_finalized); } - is_current + None } /// Look up the sender for a supervisor session, waiting up to `timeout` @@ -194,6 +216,23 @@ impl SupervisorSessionRegistry { self.sessions.lock().unwrap().contains_key(sandbox_id) } + pub fn terminal_delivery_finalized(&self, sandbox_id: &str) -> bool { + self.sessions + .lock() + .unwrap() + .get(sandbox_id) + .is_some_and(|session| session.terminal_delivery_finalized) + } + + pub fn finalize_main_process_exit(&self, sandbox_id: &str) -> bool { + let mut sessions = self.sessions.lock().unwrap(); + let Some(session) = sessions.get_mut(sandbox_id) else { + return false; + }; + session.terminal_delivery_finalized = true; + true + } + pub fn is_current_session(&self, sandbox_id: &str, session_id: &str) -> bool { self.sessions .lock() @@ -651,9 +690,23 @@ async fn expected_transport_close_during_session_teardown( let session_no_longer_current = !state .supervisor_sessions .is_current_session(sandbox_id, session_id); + expected_transport_close_during_session_state( + status, + state.gateway_shutting_down.load(Ordering::Acquire), + session_no_longer_current, + sandbox_is_terminating_or_gone(state, sandbox_id).await, + ) +} + +fn expected_transport_close_during_session_state( + status: &Status, + gateway_shutting_down: bool, + session_no_longer_current: bool, + sandbox_terminating_or_gone: bool, +) -> bool { expected_transport_close_during_shutdown( status, - session_no_longer_current || sandbox_is_terminating_or_gone(state, sandbox_id).await, + gateway_shutting_down || session_no_longer_current || sandbox_terminating_or_gone, ) } @@ -699,7 +752,7 @@ pub async fn handle_connect_supervisor( "supervisor session: accepted" ); - // Step 2: Create the outbound channel and register the session. + // Step 2: Create and register the outbound channel. let (tx, rx) = mpsc::channel::(64); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let superseded = state.supervisor_sessions.register( @@ -741,7 +794,7 @@ pub async fn handle_connect_supervisor( if let Err(err) = state .compute - .supervisor_session_connected(&sandbox_id) + .supervisor_session_connected(&sandbox_id, &hello.instance_id) .await { warn!( @@ -767,17 +820,17 @@ pub async fn handle_connect_supervisor( shutdown_rx, ) .await; - let still_ours = state_clone + let terminal_finalized = state_clone .supervisor_sessions .remove_if_current(&sandbox_id_clone, &session_id); - if still_ours { + if let Some(terminal_finalized) = terminal_finalized { info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended"); state_clone .telemetry .sandbox_session_disconnected(&sandbox_id_clone); if let Err(err) = state_clone .compute - .supervisor_session_disconnected(&sandbox_id_clone) + .supervisor_session_disconnected(&sandbox_id_clone, terminal_finalized) .await { warn!( @@ -801,6 +854,62 @@ pub async fn handle_connect_supervisor( Ok(Response::new(stream)) } +pub async fn handle_report_main_process_exit( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = request.extensions().get::().cloned(); + let report = request.into_inner(); + if report.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if report.instance_id.is_empty() { + return Err(Status::invalid_argument("instance_id is required")); + } + if let Some(principal) = principal.as_ref() { + crate::auth::guard::ensure_sandbox_principal_scope(principal, &report.sandbox_id)?; + } + state + .compute + .report_main_process_exit(&report.sandbox_id, &report.instance_id, report.exit_code) + .await + .map_err(Status::failed_precondition)?; + Ok(Response::new(ReportMainProcessExitResponse {})) +} + +pub async fn handle_finalize_main_process_exit( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = request.extensions().get::().cloned(); + let report = request.into_inner(); + if report.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if report.instance_id.is_empty() { + return Err(Status::invalid_argument("instance_id is required")); + } + if let Some(principal) = principal.as_ref() { + crate::auth::guard::ensure_sandbox_principal_scope(principal, &report.sandbox_id)?; + } + state + .compute + .finalize_main_process_exit(&report.sandbox_id, &report.instance_id) + .await + .map_err(Status::failed_precondition)?; + if !state + .supervisor_sessions + .finalize_main_process_exit(&report.sandbox_id) + { + return Err(Status::failed_precondition( + "supervisor session is not connected", + )); + } + Ok(Response::new( + openshell_core::proto::FinalizeMainProcessExitResponse {}, + )) +} + async fn run_session_loop( state: &Arc, sandbox_id: &str, @@ -1055,7 +1164,7 @@ mod tests { let (tx, _rx) = mpsc::channel(1); registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown()); - assert!(registry.remove_if_current("sbx", "s1")); + assert_eq!(registry.remove_if_current("sbx", "s1"), Some(false)); assert!(!registry.sessions.lock().unwrap().contains_key("sbx")); } @@ -1081,7 +1190,7 @@ mod tests { // Cleanup from the old session task runs late. It must NOT evict the // newly registered session. - assert!(!registry.remove_if_current("sbx", "s-old")); + assert_eq!(registry.remove_if_current("sbx", "s-old"), None); let sessions = registry.sessions.lock().unwrap(); assert!( sessions.contains_key("sbx"), @@ -1093,7 +1202,18 @@ mod tests { #[test] fn remove_if_current_unknown_sandbox_is_noop() { let registry = SupervisorSessionRegistry::new(); - assert!(!registry.remove_if_current("sbx-does-not-exist", "s1")); + assert_eq!(registry.remove_if_current("sbx-does-not-exist", "s1"), None); + } + + #[test] + fn remove_if_current_returns_terminal_finalization_state() { + let registry = SupervisorSessionRegistry::new(); + let (tx, _rx) = mpsc::channel(1); + registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown()); + + assert!(registry.finalize_main_process_exit("sbx")); + assert!(registry.terminal_delivery_finalized("sbx")); + assert_eq!(registry.remove_if_current("sbx", "s1"), Some(true)); } // ---- open_relay: happy path and wait semantics ---- @@ -1409,6 +1529,16 @@ mod tests { assert!(!expected_transport_close_during_shutdown(&status, true)); } + #[test] + fn gateway_shutdown_makes_session_transport_close_nonfatal() { + let status = + Status::unknown("h2 protocol error: error reading a body from connection: broken pipe"); + + assert!(expected_transport_close_during_session_state( + &status, true, false, false, + )); + } + #[test] fn sandbox_proto_terminating_detects_deleting_phase() { let mut sandbox = sandbox_record("sbx-1", "sandbox-one"); diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index d1aa10da11..0e4c318192 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -8,9 +8,11 @@ use futures::{Stream, stream}; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DriverSandbox, GatewayListenerRequirement, GetCapabilitiesRequest, GetCapabilitiesResponse, - GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, - GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverSandbox, EnsureWorkspaceRequest, + EnsureWorkspaceResponse, GatewayListenerRequirement, GetCapabilitiesRequest, + GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, + GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, + ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, compute_driver_server::ComputeDriver, gateway_listener_requirement::Selector, @@ -51,6 +53,10 @@ pub enum FakeComputeDriverCall { sandbox_id: String, sandbox_name: String, }, + StartSandbox { + sandbox_id: String, + sandbox_name: String, + }, DeleteSandbox { sandbox_id: String, sandbox_name: String, @@ -65,9 +71,7 @@ pub struct FakeComputeDriver { #[derive(Debug)] struct FakeComputeDriverState { - driver_name: String, - driver_version: String, - default_image: String, + capabilities: GetCapabilitiesResponse, gateway_listener_requirements: Vec, gateway_listener_requirements_supported: bool, sandboxes: HashMap, @@ -86,9 +90,14 @@ impl FakeComputeDriver { pub fn new() -> Self { Self { state: Arc::new(Mutex::new(FakeComputeDriverState { - driver_name: "fake-compute-driver".to_string(), - driver_version: "test".to_string(), - default_image: "openshell/sandbox:test".to_string(), + capabilities: GetCapabilitiesResponse { + driver_name: "fake-compute-driver".to_string(), + driver_version: "test".to_string(), + default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, + }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, sandboxes: HashMap::new(), @@ -100,19 +109,25 @@ impl FakeComputeDriver { #[must_use] pub fn with_driver_name(self, driver_name: impl Into) -> Self { - self.with_state(|state| state.driver_name = driver_name.into()); + self.with_state(|state| state.capabilities.driver_name = driver_name.into()); self } #[must_use] pub fn with_driver_version(self, driver_version: impl Into) -> Self { - self.with_state(|state| state.driver_version = driver_version.into()); + self.with_state(|state| state.capabilities.driver_version = driver_version.into()); self } #[must_use] pub fn with_default_image(self, default_image: impl Into) -> Self { - self.with_state(|state| state.default_image = default_image.into()); + self.with_state(|state| state.capabilities.default_image = default_image.into()); + self + } + + #[must_use] + pub fn with_gateway_manages_lifecycle(self) -> Self { + self.with_state(|state| state.capabilities.gateway_manages_lifecycle = true); self } @@ -222,6 +237,16 @@ impl Stream for UnixIncoming { #[tonic::async_trait] impl ComputeDriver for FakeComputeDriver { + async fn authenticate_sandbox( + &self, + _request: Request, + ) -> Result, Status> + { + Err(Status::unimplemented( + "fake driver does not authenticate sandbox credentials", + )) + } + type WatchSandboxesStream = WatchStream; async fn get_capabilities( @@ -231,11 +256,7 @@ impl ComputeDriver for FakeComputeDriver { self.record_traceparent(request.metadata()); let response = self.with_state(|state| { state.calls.push(FakeComputeDriverCall::GetCapabilities); - GetCapabilitiesResponse { - driver_name: state.driver_name.clone(), - driver_version: state.driver_version.clone(), - default_image: state.default_image.clone(), - } + state.capabilities.clone() }); Ok(Response::new(response)) } @@ -264,11 +285,13 @@ impl ComputeDriver for FakeComputeDriver { request: Request, ) -> Result, Status> { self.record_traceparent(request.metadata()); - let sandbox = request.into_inner().sandbox; + let request = request.into_inner(); self.with_state(|state| { state .calls - .push(FakeComputeDriverCall::ValidateSandboxCreate { sandbox }); + .push(FakeComputeDriverCall::ValidateSandboxCreate { + sandbox: request.sandbox, + }); }); Ok(Response::new(ValidateSandboxCreateResponse {})) } @@ -317,7 +340,8 @@ impl ComputeDriver for FakeComputeDriver { request: Request, ) -> Result, Status> { self.record_traceparent(request.metadata()); - let sandbox = request.into_inner().sandbox; + let request = request.into_inner(); + let sandbox = request.sandbox; self.with_state(|state| { if let Some(sandbox) = sandbox.as_ref() { state.sandboxes.insert(sandbox.id.clone(), sandbox.clone()); @@ -344,6 +368,21 @@ impl ComputeDriver for FakeComputeDriver { Ok(Response::new(StopSandboxResponse {})) } + async fn start_sandbox( + &self, + request: Request, + ) -> Result, Status> { + self.record_traceparent(request.metadata()); + let request = request.into_inner(); + self.with_state(|state| { + state.calls.push(FakeComputeDriverCall::StartSandbox { + sandbox_id: request.sandbox_id, + sandbox_name: request.sandbox_name, + }); + }); + Ok(Response::new(StartSandboxResponse {})) + } + async fn delete_sandbox( &self, request: Request, @@ -380,4 +419,18 @@ impl ComputeDriver for FakeComputeDriver { self.with_state(|state| state.calls.push(FakeComputeDriverCall::WatchSandboxes)); Ok(Response::new(Box::pin(stream::empty()))) } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(DeleteWorkspaceResponse {})) + } } diff --git a/crates/openshell-server/src/tls.rs b/crates/openshell-server/src/tls.rs index 7c4a5a6602..aa627746ce 100644 --- a/crates/openshell-server/src/tls.rs +++ b/crates/openshell-server/src/tls.rs @@ -20,7 +20,8 @@ use openshell_ocsf::{ use rustls::ServerConfig; use rustls::crypto::ring::sign; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; -use rustls::server::WebPkiClientVerifier; +use rustls::server::{ClientHello, ResolvesServerCert, WebPkiClientVerifier}; +use rustls::sign::CertifiedKey; use tokio::sync::{mpsc, watch}; use tracing::{debug, info, warn}; @@ -38,6 +39,9 @@ pub struct TlsAcceptor { key_path: PathBuf, client_ca_path: Option, require_client_auth: bool, + external_cert_path: Option, + external_key_path: Option, + external_server_names: Vec, reload_spawned: Arc, } @@ -64,14 +68,28 @@ impl TlsAcceptor { key_path: &Path, client_ca_path: Option<&Path>, require_client_auth: bool, + external_cert_path: Option<&Path>, + external_key_path: Option<&Path>, + external_server_names: Vec, ) -> Result { - let config = build_server_config(cert_path, key_path, client_ca_path, require_client_auth)?; + let config = build_server_config( + cert_path, + key_path, + client_ca_path, + require_client_auth, + external_cert_path, + external_key_path, + &external_server_names, + )?; Ok(Self { config: Arc::new(ArcSwap::from(config)), cert_path: cert_path.to_path_buf(), key_path: key_path.to_path_buf(), client_ca_path: client_ca_path.map(Path::to_path_buf), require_client_auth, + external_cert_path: external_cert_path.map(Path::to_path_buf), + external_key_path: external_key_path.map(Path::to_path_buf), + external_server_names, reload_spawned: Arc::new(AtomicBool::new(false)), }) } @@ -87,6 +105,9 @@ impl TlsAcceptor { &self.key_path, self.client_ca_path.as_deref(), self.require_client_auth, + self.external_cert_path.as_deref(), + self.external_key_path.as_deref(), + &self.external_server_names, )?; self.config.store(new_config); @@ -144,10 +165,22 @@ impl TlsAcceptor { } if let Some(ref ca) = self.client_ca_path { let ca_dir = ca.parent().unwrap_or_else(|| Path::new(".")); - if ca_dir != cert_dir && ca_dir != key_dir { + if !dirs.contains(&ca_dir.to_path_buf()) { dirs.push(ca_dir.to_path_buf()); } } + if let Some(ref ext_cert) = self.external_cert_path { + let ext_dir = ext_cert.parent().unwrap_or_else(|| Path::new(".")); + if !dirs.contains(&ext_dir.to_path_buf()) { + dirs.push(ext_dir.to_path_buf()); + } + } + if let Some(ref ext_key) = self.external_key_path { + let ext_dir = ext_key.parent().unwrap_or_else(|| Path::new(".")); + if !dirs.contains(&ext_dir.to_path_buf()) { + dirs.push(ext_dir.to_path_buf()); + } + } let debounce = Duration::from_secs(1); @@ -244,12 +277,102 @@ impl TlsAcceptor { } } +/// SNI-based certificate resolver that presents an external (e.g. ACME) +/// certificate for configured hostnames and the internal (chart CA) certificate +/// for everything else, including connections with no SNI. +struct DualCertResolver { + internal: Arc, + external: Arc, + external_names: Vec, +} + +/// Check whether `sni` matches a configured external name. +/// +/// Supports exact matches and single-level wildcard matches per RFC 6125: +/// `*.example.com` matches `foo.example.com` but not `bar.foo.example.com` +/// or `example.com` itself. +fn sni_matches(pattern: &str, sni: &str) -> bool { + pattern.strip_prefix("*.").map_or(pattern == sni, |suffix| { + // Wildcard: SNI must have exactly one label before the suffix. + // e.g. "foo." for "foo.example.com" against "*.example.com" + sni.strip_suffix(suffix).is_some_and(|prefix| { + prefix.ends_with('.') && !prefix[..prefix.len() - 1].contains('.') + }) + }) +} + +impl ResolvesServerCert for DualCertResolver { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + if let Some(name) = client_hello.server_name() + && self.external_names.iter().any(|n| sni_matches(n, name)) + { + return Some(self.external.clone()); + } + Some(self.internal.clone()) + } +} + +impl std::fmt::Debug for DualCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DualCertResolver") + .field("external_names", &self.external_names) + .finish() + } +} + +/// Build a `CertifiedKey` from certificate and key file paths. +fn load_certified_key(cert_path: &Path, key_path: &Path) -> Result> { + let certs = load_certs(cert_path)?; + let key = load_key(key_path)?; + let signing_key = sign::any_supported_type(&key) + .map_err(|e| Error::tls(format!("unsupported private key type: {e}")))?; + Ok(Arc::new(CertifiedKey::new(certs, signing_key))) +} + +/// Build an SNI-based cert resolver when an external certificate is configured. +/// Returns `None` when no external cert is configured (single-cert mode). +fn build_cert_resolver( + cert_path: &Path, + key_path: &Path, + external_cert_path: Option<&Path>, + external_key_path: Option<&Path>, + external_server_names: &[String], +) -> Result>> { + match (external_cert_path, external_key_path) { + (None, None) => Ok(None), + (Some(_), None) => Err(Error::tls( + "external_cert_path is set but external_key_path is missing", + )), + (None, Some(_)) => Err(Error::tls( + "external_key_path is set but external_cert_path is missing", + )), + (Some(ext_cert_path), Some(ext_key_path)) => { + if external_server_names.is_empty() { + return Err(Error::tls( + "external certificate is configured but external_server_names is empty — \ + the external cert would never be served", + )); + } + let internal = load_certified_key(cert_path, key_path)?; + let external = load_certified_key(ext_cert_path, ext_key_path)?; + Ok(Some(Arc::new(DualCertResolver { + internal, + external, + external_names: external_server_names.to_vec(), + }))) + } + } +} + /// Build a `ServerConfig` from certificate, key, and optional client CA files. fn build_server_config( cert_path: &Path, key_path: &Path, client_ca_path: Option<&Path>, require_client_auth: bool, + external_cert_path: Option<&Path>, + external_key_path: Option<&Path>, + external_server_names: &[String], ) -> Result> { let certs = load_certs(cert_path)?; let key = load_key(key_path)?; @@ -259,6 +382,14 @@ fn build_server_config( sign::any_supported_type(&key) .map_err(|e| Error::tls(format!("unsupported private key type: {e}")))?; + let resolver = build_cert_resolver( + cert_path, + key_path, + external_cert_path, + external_key_path, + external_server_names, + )?; + let mut config = if let Some(ca_path) = client_ca_path { let ca_certs = load_certs(ca_path)?; let mut root_store = rustls::RootCertStore::empty(); @@ -277,15 +408,23 @@ fn build_server_config( .build() .map_err(|e| Error::tls(format!("failed to build client verifier: {e}")))?; - ServerConfig::builder() - .with_client_cert_verifier(verifier) - .with_single_cert(certs, key) - .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? + let builder = ServerConfig::builder().with_client_cert_verifier(verifier); + if let Some(resolver) = resolver { + builder.with_cert_resolver(resolver) + } else { + builder + .with_single_cert(certs, key) + .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? + } } else { - ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key) - .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? + let builder = ServerConfig::builder().with_no_client_auth(); + if let Some(resolver) = resolver { + builder.with_cert_resolver(resolver) + } else { + builder + .with_single_cert(certs, key) + .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? + } }; config @@ -402,6 +541,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + &[], ) .expect("failed to build server config"); @@ -420,6 +562,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + Vec::new(), ) .expect("failed to build acceptor"); @@ -438,6 +583,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + Vec::new(), ) .expect("failed to build acceptor"); @@ -468,6 +616,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + Vec::new(), ) .expect("failed to build acceptor"); @@ -560,6 +711,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + Vec::new(), ) .expect("failed to build acceptor"); @@ -633,6 +787,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + Vec::new(), ) .expect("failed to build acceptor"); @@ -664,6 +821,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, + None, + None, + Vec::new(), ) .expect("failed to build acceptor"); @@ -752,6 +912,9 @@ mod tests { &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), true, // require mTLS + None, + None, + Vec::new(), ) .expect("failed to build acceptor with mTLS"); @@ -905,4 +1068,275 @@ mod tests { server_task.await.expect("server task failed"); } + + /// Generate a cert+key pair with given SANs, signed by the provided CA, + /// and write them to the specified files in `dir`. + fn generate_named_cert( + ca_cert: &rcgen::Certificate, + ca_key: &KeyPair, + dir: &Path, + cert_file: &str, + key_file: &str, + san: &str, + ) { + let params = + CertificateParams::new(vec![san.to_string()]).expect("failed to create cert params"); + let key = KeyPair::generate().expect("failed to generate key"); + let cert = params + .signed_by(&key, ca_cert, ca_key) + .expect("failed to sign cert"); + write_test_file(dir, cert_file, cert.pem().as_bytes()); + write_test_file(dir, key_file, key.serialize_pem().as_bytes()); + } + + #[test] + fn test_sni_matches_exact() { + assert!(sni_matches("example.com", "example.com")); + assert!(!sni_matches("example.com", "other.com")); + assert!(!sni_matches("example.com", "sub.example.com")); + } + + #[test] + fn test_sni_matches_wildcard() { + assert!(sni_matches("*.example.com", "foo.example.com")); + assert!(sni_matches("*.example.com", "bar.example.com")); + // Must not match bare domain. + assert!(!sni_matches("*.example.com", "example.com")); + // Must not match nested subdomains (RFC 6125). + assert!(!sni_matches("*.example.com", "sub.foo.example.com")); + // Must not match unrelated domain with same suffix. + assert!(!sni_matches("*.example.com", "notexample.com")); + } + + #[test] + fn test_build_cert_resolver_returns_none_when_no_external() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + generate_test_certs_with_ca(dir.path()); + + let result = build_cert_resolver( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + None, + None, + &[], + ) + .expect("build_cert_resolver should succeed"); + assert!(result.is_none(), "should return None when no external cert"); + } + + #[test] + fn test_build_cert_resolver_errors_on_cert_without_key() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + generate_test_certs_with_ca(dir.path()); + + let result = build_cert_resolver( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("server-cert.pem")), + None, + &["example.com".to_string()], + ); + let err = result.expect_err("should error when key is missing"); + assert!( + err.to_string().contains("external_key_path is missing"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_build_cert_resolver_errors_on_key_without_cert() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + generate_test_certs_with_ca(dir.path()); + + let result = build_cert_resolver( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + None, + Some(&dir.path().join("server-key.pem")), + &["example.com".to_string()], + ); + let err = result.expect_err("should error when cert is missing"); + assert!( + err.to_string().contains("external_cert_path is missing"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_build_cert_resolver_errors_on_empty_server_names() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path()); + generate_named_cert( + &ca_cert, + &ca_key, + dir.path(), + "ext-cert.pem", + "ext-key.pem", + "external.example.com", + ); + + let result = build_cert_resolver( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ext-cert.pem")), + Some(&dir.path().join("ext-key.pem")), + &[], + ); + let err = result.expect_err("should error when server names are empty"); + assert!( + err.to_string().contains("external_server_names is empty"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_dual_cert_resolver_returns_external_on_sni_match() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path()); + generate_named_cert( + &ca_cert, + &ca_key, + dir.path(), + "ext-cert.pem", + "ext-key.pem", + "external.example.com", + ); + + let internal = load_certified_key( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + ) + .expect("load internal"); + let external = load_certified_key( + &dir.path().join("ext-cert.pem"), + &dir.path().join("ext-key.pem"), + ) + .expect("load external"); + + let internal_der = internal.cert[0].as_ref().to_vec(); + let external_der = external.cert[0].as_ref().to_vec(); + + // `ClientHello` cannot be constructed directly in tests, so + // SNI-based selection is exercised in the async integration test + // below. Here we verify the certs are distinct so the integration + // test's DER comparisons are meaningful. + assert_ne!( + internal_der, external_der, + "internal and external certs should be distinct" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_dual_cert_resolver_sni_selects_correct_cert() { + install_rustls_provider(); + + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path()); + generate_named_cert( + &ca_cert, + &ca_key, + dir.path(), + "ext-cert.pem", + "ext-key.pem", + "external.example.com", + ); + + // Snapshot the DER of the internal and external leaf certs. + let internal_der = load_certs(&dir.path().join("server-cert.pem")) + .expect("load internal certs")[0] + .as_ref() + .to_vec(); + let external_der = load_certs(&dir.path().join("ext-cert.pem")) + .expect("load external certs")[0] + .as_ref() + .to_vec(); + + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + Some(&dir.path().join("ext-cert.pem")), + Some(&dir.path().join("ext-key.pem")), + vec!["external.example.com".to_string()], + ) + .expect("failed to build acceptor"); + + let client_config = build_test_client_config(&dir.path().join("ca.pem")); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let listen_addr = listener.local_addr().expect("failed to get local addr"); + + // --- Connection 1: SNI matches external name → external cert --- + let acceptor_srv = acceptor.clone(); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept failed"); + acceptor_srv + .acceptor() + .accept(stream) + .await + .expect("TLS accept failed") + }); + + let connector = tokio_rustls::TlsConnector::from(client_config.clone()); + let tcp = TcpStream::connect(listen_addr) + .await + .expect("connect failed"); + let server_name = "external.example.com" + .try_into() + .expect("invalid server name"); + let tls = connector + .connect(server_name, tcp) + .await + .expect("TLS connect failed"); + + assert_eq!( + peer_cert_der(&tls), + external_der, + "SNI matching external name should serve external cert" + ); + drop(tls); + let _ = server_task.await; + + // --- Connection 2: SNI = "localhost" → internal cert --- + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let listen_addr = listener.local_addr().expect("failed to get local addr"); + + let acceptor_srv = acceptor.clone(); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept failed"); + acceptor_srv + .acceptor() + .accept(stream) + .await + .expect("TLS accept failed") + }); + + let connector = tokio_rustls::TlsConnector::from(client_config); + let tcp = TcpStream::connect(listen_addr) + .await + .expect("connect failed"); + let server_name = "localhost".try_into().expect("invalid server name"); + let tls = connector + .connect(server_name, tcp) + .await + .expect("TLS connect failed"); + + assert_eq!( + peer_cert_der(&tls), + internal_der, + "SNI not matching external name should serve internal cert" + ); + drop(tls); + let _ = server_task.await; + } } diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index 321edefafe..ed3f56c6e2 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -12,11 +12,12 @@ use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; use crate::config_file::OtlpConfig; -use crate::otel_tracing::SetupError; +use crate::otel_tracing::{GatewayResourceAttributes, SetupError}; use crate::tracing_bus::TracingLogBus; pub struct TracingHandle { tracer_provider: Option, + driver_tracer_provider: Option, } impl TracingHandle { @@ -26,6 +27,11 @@ impl TracingHandle { { tracing::warn!(error = %err, "OTLP tracer provider shutdown failed"); } + if let Some(provider) = &self.driver_tracer_provider + && let Err(err) = provider.shutdown() + { + tracing::warn!(error = %err, "compute-driver OTLP tracer provider shutdown failed"); + } } } @@ -33,15 +39,48 @@ pub fn install( env_filter: EnvFilter, tracing_log_bus: &TracingLogBus, otlp_config: Option<&OtlpConfig>, + driver: Option, + gateway: GatewayResourceAttributes<'_>, ) -> (TracingHandle, Option) { - let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config); + let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config, gateway); + let driver_endpoint = driver + .is_some() + .then_some(otlp_config) + .flatten() + .map(|config| config.endpoint.as_str()); + let (driver_tracer_provider, driver_setup_error) = driver.map_or_else( + || (None, None), + |descriptor| { + descriptor.provider_for( + driver_endpoint, + openshell_core::VERSION, + gateway.name(), + gateway.compute_driver(), + ) + }, + ); tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer()) .with(tracing_log_bus.layer()) - .with(tracer_provider.as_ref().map(crate::otel_tracing::layer)) + .with( + tracer_provider + .as_ref() + .map(|provider| crate::otel_tracing::layer(provider, driver)), + ) + .with(driver_tracer_provider.as_ref().map(|provider| { + driver + .expect("a driver provider requires a selected driver") + .in_process_layer(provider) + })) .init(); - (TracingHandle { tracer_provider }, setup_error) + ( + TracingHandle { + tracer_provider, + driver_tracer_provider, + }, + setup_error.or(driver_setup_error), + ) } diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index cfd7faa3d6..a60fc8696b 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -16,8 +16,9 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, + GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, ListProvidersRequest, @@ -52,6 +53,20 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -83,6 +98,48 @@ impl OpenShell for TestOpenShell { Ok(Response::new(SandboxResponse::default())) } + async fn create_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn get_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn list_sandbox_templates( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn delete_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn stop_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn start_sandbox( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_sandbox( &self, _request: tonic::Request, @@ -198,6 +255,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index 4df221f117..be70e45675 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -168,6 +168,9 @@ async fn mtls_valid_client_cert_accepted() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); @@ -209,6 +212,9 @@ async fn no_client_cert_accepted_with_ca_configured() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); @@ -252,6 +258,9 @@ async fn bearer_header_reaches_server_without_client_cert() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); @@ -283,6 +292,9 @@ async fn rogue_cert_rejected() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); @@ -329,6 +341,9 @@ async fn https_only_no_client_cert_required() { &temp.path().join("server-key.pem"), None, false, + None, + None, + Vec::new(), ) .unwrap(); diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index 4e17fdef97..3447aad517 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -62,6 +62,9 @@ async fn serves_grpc_and_http_over_tls_on_same_port() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); @@ -101,6 +104,9 @@ async fn mtls_valid_client_cert_accepted() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); @@ -129,6 +135,9 @@ async fn no_client_cert_accepted_with_ca() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); @@ -165,6 +174,9 @@ async fn no_client_cert_rejected_when_required() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), true, + None, + None, + Vec::new(), ) .unwrap(); @@ -202,6 +214,9 @@ async fn mtls_wrong_ca_client_cert_rejected() { &temp.path().join("server-key.pem"), Some(temp.path().join("ca.pem").as_path()), false, + None, + None, + Vec::new(), ) .unwrap(); diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index be1af8f48c..91ac50dcbd 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -48,6 +48,20 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn finalize_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -68,6 +82,34 @@ impl OpenShell for RelayGateway { // ------ unused stubs ------ + async fn create_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn get_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn list_sandbox_templates( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn delete_sandbox_template( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + type ConnectSupervisorStream = ReceiverStream>; async fn connect_supervisor( &self, @@ -130,6 +172,18 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn stop_sandbox( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn start_sandbox( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } async fn get_sandbox( &self, _: tonic::Request, @@ -224,6 +278,12 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn exchange_provider_subject_token( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } async fn create_provider( &self, _: tonic::Request, diff --git a/crates/openshell-supervisor-middleware-builtins/BUILD.bazel b/crates/openshell-supervisor-middleware-builtins/BUILD.bazel deleted file mode 100644 index ee0933425d..0000000000 --- a/crates/openshell-supervisor-middleware-builtins/BUILD.bazel +++ /dev/null @@ -1,29 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") -load("@workspace_version//:version.bzl", "WORKSPACE_VERSION") - -rust_library( - name = "openshell-supervisor-middleware-builtins", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - version = WORKSPACE_VERSION, - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-supervisor-middleware-builtins_test", - crate = ":openshell-supervisor-middleware-builtins", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-supervisor-middleware-builtins", - ":openshell-supervisor-middleware-builtins_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-supervisor-middleware-builtins/Cargo.toml b/crates/openshell-supervisor-middleware-builtins/Cargo.toml index f892c718fd..8e38a05ad2 100644 --- a/crates/openshell-supervisor-middleware-builtins/Cargo.toml +++ b/crates/openshell-supervisor-middleware-builtins/Cargo.toml @@ -13,15 +13,15 @@ rust-version.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +async-trait = "0.1" miette = { workspace = true } prost-types = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tonic = { workspace = true, features = ["server"] } - -[dev-dependencies] tokio = { workspace = true } +tokio-stream = { workspace = true } +tonic = { workspace = true, features = ["server"] } [lints] workspace = true diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs index e23a228f05..f87afafc3c 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/lib.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -8,21 +8,24 @@ mod regex; use std::sync::Arc; use miette::{Result, miette}; -use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; +use openshell_core::middleware::{HttpRequestView, InProcessMiddleware, WebSocketResponseStream}; use openshell_core::proto::{ - HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, - ValidateConfigResponse, + HttpRequestResult, MiddlewareManifest, SupervisorMiddlewarePhase, WebSocketPreflightAction, + WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, + web_socket_message, web_socket_session_event, web_socket_session_event_result, }; -use tonic::{Request, Response, Status}; +use tokio_stream::{Stream, StreamExt}; +use tonic::Status; pub use regex::{NAME as BUILTIN_REGEX, RegexConfig, RegexMode}; /// Return the first-party services that the gateway and supervisor install. -pub fn services() -> Vec> { +pub fn services() -> Vec> { vec![Arc::new(BuiltinMiddlewareService)] } -/// Validate configuration for a first-party binding. +/// Resolve and validate a first-party config before the supervisor has selected +/// its service endpoint. pub fn validate_config(implementation: &str, config: &prost_types::Struct) -> Result<()> { match implementation { BUILTIN_REGEX => regex::validate_config(config), @@ -32,59 +35,189 @@ pub fn validate_config(implementation: &str, config: &prost_types::Struct) -> Re } } -fn evaluate_http_request(evaluation: &HttpRequestEvaluation) -> Result { - match evaluation.middleware_name.as_str() { - BUILTIN_REGEX => regex::evaluate_http_request(evaluation), +fn evaluate_http_request(request: HttpRequestView<'_>) -> Result { + match request.middleware_name() { + BUILTIN_REGEX => regex::evaluate_http_request(request.config(), request.body()), other => Err(miette!( "middleware implementation '{other}' is not a registered OpenShell built-in" )), } } -/// Built-in regex service exposed through the standard middleware contract. +/// Aggregate service exposing first-party middleware through the borrowed in-process contract. #[derive(Debug, Default)] pub struct BuiltinMiddlewareService; +impl BuiltinMiddlewareService { + fn websocket_stream(mut requests: S) -> WebSocketResponseStream + where + S: Stream> + + Send + + Unpin + + 'static, + { + let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + let mut config = None; + let mut started = false; + let mut sequence_lower_bound = Some(1u64); + + while let Some(request) = requests.next().await { + let request = match request { + Ok(request) => request, + Err(error) => { + let _ = responses_tx.send(Err(error)).await; + break; + } + }; + let response = match request.event { + Some(web_socket_session_event::Event::Preflight(preflight)) + if config.is_none() && !started => + { + if preflight.phase == SupervisorMiddlewarePhase::PreCredentials as i32 { + let selected_config = preflight.config.unwrap_or_default(); + match regex::validate_config(&selected_config) { + Ok(()) => { + config = Some(selected_config); + Ok(Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::PreflightDecision( + WebSocketPreflightDecision { + action: WebSocketPreflightAction::Inspect + as i32, + ..Default::default() + }, + ), + ), + })) + } + Err(error) => Err(Status::invalid_argument(error.to_string())), + } + } else { + Err(Status::invalid_argument( + "unsupported built-in WebSocket binding", + )) + } + } + Some(web_socket_session_event::Event::SessionStart(_)) + if config.is_some() && !started => + { + started = true; + Ok(None) + } + Some(web_socket_session_event::Event::Message(message)) if started => { + if let Err(error) = advance_sequence_lower_bound( + &mut sequence_lower_bound, + message.sequence, + ) { + Err(error) + } else { + match message.payload { + Some(web_socket_message::Payload::Text(payload)) => { + let selected_config = + config.as_ref().expect("started stream has config"); + match regex::evaluate_websocket_text( + message.sequence, + &payload, + selected_config, + ) { + Ok(result) => Ok(Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::MessageResult( + result, + ), + ), + })), + Err(error) => { + Err(Status::invalid_argument(error.to_string())) + } + } + } + Some(web_socket_message::Payload::Binary(_)) | None => { + Err(Status::invalid_argument( + "openshell/regex supports only client-to-upstream WebSocket text messages", + )) + } + } + } + } + Some(web_socket_session_event::Event::SessionEnd(_)) if config.is_some() => { + break; + } + _ => Err(Status::failed_precondition( + "invalid built-in WebSocket session lifecycle", + )), + }; + + match response { + Ok(Some(response)) => { + if responses_tx.send(Ok(response)).await.is_err() { + break; + } + } + Ok(None) => {} + Err(error) => { + let _ = responses_tx.send(Err(error)).await; + break; + } + } + } + }); + Box::pin(tokio_stream::wrappers::ReceiverStream::new(responses_rx)) + } +} + +fn advance_sequence_lower_bound( + lower_bound: &mut Option, + sequence: u64, +) -> std::result::Result<(), Status> { + let Some(current_lower_bound) = *lower_bound else { + return Err(Status::invalid_argument( + "WebSocket message sequence must be strictly increasing", + )); + }; + if sequence < current_lower_bound { + return Err(Status::invalid_argument( + "WebSocket message sequence must be strictly increasing", + )); + } + *lower_bound = sequence.checked_add(1); + Ok(()) +} + #[tonic::async_trait] -impl SupervisorMiddleware for BuiltinMiddlewareService { - async fn describe( - &self, - _request: Request<()>, - ) -> Result, Status> { - Ok(Response::new(MiddlewareManifest { +impl InProcessMiddleware for BuiltinMiddlewareService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { name: BUILTIN_REGEX.into(), service_version: env!("CARGO_PKG_VERSION").into(), - bindings: vec![regex::describe()], - })) + bindings: regex::describe(), + expected_audience: String::new(), + } } async fn validate_config( &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - let config = request.config.unwrap_or_default(); - Ok(Response::new( - match validate_config(&request.middleware_name, &config) { - Ok(()) => ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - Err(error) => ValidateConfigResponse { - valid: false, - reason: error.to_string(), - }, - }, - )) + middleware_name: &str, + config: &prost_types::Struct, + ) -> Result<()> { + validate_config(middleware_name, config) } async fn evaluate_http_request( &self, - request: Request, - ) -> Result, Status> { - evaluate_http_request(&request.into_inner()) - .map(Response::new) - .map_err(|error| Status::invalid_argument(error.to_string())) + request: HttpRequestView<'_>, + ) -> Result { + evaluate_http_request(request) + } + + async fn open_websocket_session( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + Ok(Self::websocket_stream( + tokio_stream::wrappers::ReceiverStream::new(receiver).map(Ok), + )) } } @@ -92,7 +225,8 @@ impl SupervisorMiddleware for BuiltinMiddlewareService { mod tests { use super::*; use openshell_core::proto::{ - Decision, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, + Decision, HttpRequestTarget, RequestContext, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, WebSocketPreflight, }; fn string_config(key: &str, value: &str) -> prost_types::Struct { @@ -107,14 +241,24 @@ mod tests { } } + fn evaluate_body(body: &[u8], config: &prost_types::Struct) -> Result { + let context = RequestContext::default(); + let target = HttpRequestTarget::default(); + evaluate_http_request(HttpRequestView::new( + SupervisorMiddlewarePhase::PreCredentials, + &context, + config, + &target, + &[], + body, + BUILTIN_REGEX, + )) + } + #[tokio::test] async fn service_describes_regex_binding() { - let manifest = BuiltinMiddlewareService - .describe(Request::new(())) - .await - .expect("describe") - .into_inner(); - assert_eq!(manifest.bindings.len(), 1); + let manifest = InProcessMiddleware::describe(&BuiltinMiddlewareService).await; + assert_eq!(manifest.bindings.len(), 2); assert_eq!( manifest.bindings[0].operation, SupervisorMiddlewareOperation::HttpRequest as i32 @@ -123,7 +267,16 @@ mod tests { manifest.bindings[0].phase, SupervisorMiddlewarePhase::PreCredentials as i32 ); - assert_eq!(manifest.bindings[0].max_body_bytes, 256 * 1024); + assert_eq!(manifest.bindings[0].max_payload_bytes, 256 * 1024); + assert_eq!( + manifest.bindings[1].operation, + SupervisorMiddlewareOperation::WebsocketMessage as i32 + ); + assert_eq!( + manifest.bindings[1].phase, + SupervisorMiddlewarePhase::PreCredentials as i32 + ); + assert_eq!(manifest.bindings[1].max_payload_bytes, 256 * 1024); } #[test] @@ -157,14 +310,23 @@ mod tests { } } + #[test] + fn registry_rejects_unknown_builtin_name() { + let error = validate_config("openshell/unknown", &prost_types::Struct::default()) + .expect_err("unknown built-in"); + assert!( + error + .to_string() + .contains("is not a registered OpenShell built-in") + ); + } + #[test] fn regex_replacement_evaluates_through_binding() { - let result = evaluate_http_request(&HttpRequestEvaluation { - middleware_name: BUILTIN_REGEX.into(), - body: br#"{"password":"top-secret","token":"sk-ABCDEFGHIJKLMNOP"}"#.to_vec(), - config: Some(prost_types::Struct::default()), - ..Default::default() - }) + let result = evaluate_body( + br#"{"password":"top-secret","token":"sk-ABCDEFGHIJKLMNOP"}"#, + &prost_types::Struct::default(), + ) .expect("evaluate regex binding"); assert_eq!(result.decision, Decision::Allow as i32); @@ -180,23 +342,131 @@ mod tests { ); } + #[tokio::test] + async fn service_validates_builtin_config() { + InProcessMiddleware::validate_config( + &BuiltinMiddlewareService, + BUILTIN_REGEX, + &prost_types::Struct::default(), + ) + .await + .expect("validate config"); + } + + #[tokio::test] + async fn websocket_preflight_does_not_dispatch_on_registration_name() { + let requests = tokio_stream::iter([Ok::<_, Status>(WebSocketSessionEvent { + event: Some(web_socket_session_event::Event::Preflight( + WebSocketPreflight { + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + middleware_name: "operator-assigned-name".into(), + config: Some(prost_types::Struct::default()), + ..Default::default() + }, + )), + })]); + let mut responses = BuiltinMiddlewareService::websocket_stream(requests); + let response = responses + .next() + .await + .expect("preflight response") + .expect("valid preflight"); + + assert!(matches!( + response.result, + Some(web_socket_session_event_result::Result::PreflightDecision( + WebSocketPreflightDecision { action, .. } + )) if action == WebSocketPreflightAction::Inspect as i32 + )); + } + #[test] fn regex_replacement_does_not_parse_keyword_assignments() { let body = concat!( r#"{"password":"alpha beta","secret":"alpha,beta","api_key":"alpha\"beta"}"#, "\npassword=alpha\nnotpassword=omega" ); - let result = evaluate_http_request(&HttpRequestEvaluation { - middleware_name: BUILTIN_REGEX.into(), - body: body.as_bytes().to_vec(), - config: Some(prost_types::Struct::default()), - ..Default::default() - }) - .expect("evaluate regex binding"); + let result = evaluate_body(body.as_bytes(), &prost_types::Struct::default()) + .expect("evaluate regex binding"); assert_eq!(result.decision, Decision::Allow as i32); assert!(!result.has_body); - assert_eq!(result.body, body.as_bytes()); + assert!(result.body.is_empty()); assert!(result.findings.is_empty()); } + + #[test] + fn regex_websocket_text_reuses_findings_and_metadata_semantics() { + let payload = + r#"{"type":"response.create","input":"sk-ABCDEFGHIJKLMNOP sk-QRSTUVWXYZabcdef"}"#; + let result = regex::evaluate_websocket_text(7, payload, &prost_types::Struct::default()) + .expect("evaluate WebSocket text"); + + assert_eq!(result.sequence, 7); + assert_eq!(result.decision, Decision::Allow as i32); + assert_eq!( + result.replacement, + Some( + openshell_core::proto::web_socket_message_result::Replacement::Text( + r#"{"type":"response.create","input":"[REDACTED] [REDACTED]"}"#.into() + ) + ) + ); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].r#type, "regex.openai"); + assert_eq!(result.findings[0].count, 2); + assert_eq!( + result.metadata.get("regex_matches_replaced"), + Some(&"2".to_string()) + ); + } + + #[test] + fn regex_websocket_no_match_returns_no_replacement_or_findings() { + let result = regex::evaluate_websocket_text( + 1, + r#"{"type":"response.create","input":"public"}"#, + &prost_types::Struct::default(), + ) + .expect("evaluate WebSocket text"); + + assert!(result.replacement.is_none()); + assert!(result.findings.is_empty()); + assert!(result.metadata.is_empty()); + } + + #[test] + fn regex_websocket_rejects_oversize_messages() { + assert!( + regex::evaluate_websocket_text( + 1, + &"a".repeat(256 * 1024 + 1), + &prost_types::Struct::default(), + ) + .is_err() + ); + } + + #[test] + fn websocket_sequence_lower_bound_accepts_gaps_and_rejects_reuse() { + let mut lower_bound = Some(1); + advance_sequence_lower_bound(&mut lower_bound, 2).expect("first delivered sequence"); + assert_eq!(lower_bound, Some(3)); + assert!(advance_sequence_lower_bound(&mut lower_bound, 2).is_err()); + assert!(advance_sequence_lower_bound(&mut lower_bound, 1).is_err()); + + advance_sequence_lower_bound(&mut lower_bound, 7).expect("forward gap"); + assert_eq!(lower_bound, Some(8)); + + advance_sequence_lower_bound(&mut lower_bound, u64::MAX).expect("last sequence"); + assert_eq!(lower_bound, None); + assert!(advance_sequence_lower_bound(&mut lower_bound, u64::MAX).is_err()); + } + + #[test] + fn regex_rejects_non_utf8_borrowed_body() { + let error = + evaluate_body(&[0xff], &prost_types::Struct::default()).expect_err("non-UTF-8 body"); + assert!(error.to_string().contains("requires UTF-8 request bodies")); + } } diff --git a/crates/openshell-supervisor-middleware-builtins/src/regex.rs b/crates/openshell-supervisor-middleware-builtins/src/regex.rs index 34e727430a..a5c2df882a 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/regex.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/regex.rs @@ -2,25 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 //! Example built-in middleware that applies a fixed set of regular-expression -//! replacements to UTF-8 request bodies. +//! replacements to UTF-8 HTTP request bodies and WebSocket text messages. //! //! This is intentionally a best-effort text transformation, not a secret //! scanner or a parser-aware redactor. It provides no guarantee that sensitive //! values will be detected or fully removed. +use std::borrow::Cow; use std::collections::HashMap; use std::sync::LazyLock; use miette::{Result, miette}; use openshell_core::proto::{ - Decision, Finding, HttpRequestEvaluation, HttpRequestResult, MiddlewareBinding, - SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, + Decision, Finding, HttpRequestResult, MiddlewareBinding, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, WebSocketMessageResult, web_socket_message_result, }; use regex::Regex; use serde::Deserialize; pub const NAME: &str = "openshell/regex"; -const MAX_BODY_BYTES: u64 = 256 * 1024; +const MAX_PAYLOAD_BYTES: u64 = 256 * 1024; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -37,6 +38,7 @@ pub enum RegexMode { } impl RegexConfig { + /// Parse and validate regex middleware configuration from protobuf form. pub fn from_struct(config: &prost_types::Struct) -> Result { serde_json::from_value(openshell_core::proto_struct::struct_to_json_value(config)).map_err( |error| { @@ -46,13 +48,22 @@ impl RegexConfig { } } -pub fn describe() -> MiddlewareBinding { - MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_body_bytes: MAX_BODY_BYTES, - timeout: String::new(), - } +/// Describe the HTTP request and WebSocket message bindings supported by the regex middleware. +pub fn describe() -> Vec { + vec![ + MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: MAX_PAYLOAD_BYTES, + timeout: String::new(), + }, + MiddlewareBinding { + operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: MAX_PAYLOAD_BYTES, + timeout: String::new(), + }, + ] } struct ReplacementPattern { @@ -75,58 +86,104 @@ impl ReplacementPattern { static REPLACEMENT_PATTERNS: LazyLock<[ReplacementPattern; 1]> = LazyLock::new(|| [ReplacementPattern::new("openai", r"sk-[A-Za-z0-9_-]{16,}")]); +/// Validate one regex middleware configuration. pub fn validate_config(config: &prost_types::Struct) -> Result<()> { RegexConfig::from_struct(config).map(|_| ()) } -pub fn evaluate_http_request(evaluation: &HttpRequestEvaluation) -> Result { - let default_config = prost_types::Struct::default(); - validate_config(evaluation.config.as_ref().unwrap_or(&default_config))?; - let text = String::from_utf8(evaluation.body.clone()) - .map_err(|_| miette!("{NAME} requires UTF-8 request bodies"))?; - let (body, matches) = apply_replacements(&text); - let total: u32 = matches - .iter() - .fold(0u32, |acc, (_, count)| acc.saturating_add(*count)); - let mut result = HttpRequestResult { +/// Evaluate a borrowed HTTP body and return a replacement only when a pattern matches. +pub fn evaluate_http_request( + config: &prost_types::Struct, + body: &[u8], +) -> Result { + validate_config(config)?; + let text = + std::str::from_utf8(body).map_err(|_| miette!("{NAME} requires UTF-8 request bodies"))?; + let (body, matches) = apply_replacements(text); + let (findings, metadata) = findings_and_metadata(&matches); + let has_body = !matches.is_empty(); + let result = HttpRequestResult { decision: Decision::Allow as i32, reason: String::new(), - body: body.into_bytes(), - has_body: !matches.is_empty(), + body: match body { + Cow::Borrowed(_) => Vec::new(), + Cow::Owned(body) => body.into_bytes(), + }, + has_body, header_mutations: Vec::new(), - findings: Vec::new(), - metadata: HashMap::new(), + findings, + metadata, reason_code: String::new(), }; - for (kind, count) in &matches { - result.findings.push(Finding { + Ok(result) +} + +pub fn evaluate_websocket_text( + sequence: u64, + payload: &str, + config: &prost_types::Struct, +) -> Result { + validate_config(config)?; + let payload_bytes = u64::try_from(payload.len()) + .map_err(|_| miette!("{NAME} WebSocket text message length is not representable"))?; + if payload_bytes > MAX_PAYLOAD_BYTES { + return Err(miette!( + "{NAME} WebSocket text message exceeds {MAX_PAYLOAD_BYTES} bytes" + )); + } + let (replacement, matches) = apply_replacements(payload); + let (findings, metadata) = findings_and_metadata(&matches); + let replacement = (!matches.is_empty()) + .then(|| web_socket_message_result::Replacement::Text(replacement.into_owned())); + Ok(WebSocketMessageResult { + sequence, + decision: Decision::Allow as i32, + replacement, + reason: String::new(), + findings, + metadata, + reason_code: String::new(), + }) +} + +fn findings_and_metadata( + matches: &[(&'static str, u32)], +) -> (Vec, HashMap) { + let findings = matches + .iter() + .map(|(kind, count)| Finding { r#type: format!("regex.{kind}"), label: format!("{kind} regex match"), count: *count, confidence: "medium".into(), severity: "medium".into(), - }); - } + }) + .collect(); + let mut metadata = HashMap::new(); if !matches.is_empty() { - result - .metadata - .insert("regex_matches_replaced".into(), total.to_string()); + let total = matches + .iter() + .fold(0u32, |acc, (_, count)| acc.saturating_add(*count)); + metadata.insert("regex_matches_replaced".into(), total.to_string()); } - Ok(result) + (findings, metadata) } -fn apply_replacements(input: &str) -> (String, Vec<(&'static str, u32)>) { - let mut output = input.to_string(); +fn apply_replacements(input: &str) -> (Cow<'_, str>, Vec<(&'static str, u32)>) { + let mut output = Cow::Borrowed(input); let mut matches = Vec::new(); for pattern in REPLACEMENT_PATTERNS.iter() { - let count = u32::try_from(pattern.regex.find_iter(&output).count()).unwrap_or(u32::MAX); + let count = + u32::try_from(pattern.regex.find_iter(output.as_ref()).count()).unwrap_or(u32::MAX); if count > 0 { matches.push((pattern.kind, count)); + output = Cow::Owned( + pattern + .regex + .replace_all(output.as_ref(), "[REDACTED]") + .into_owned(), + ); } - output = pattern - .regex - .replace_all(&output, "[REDACTED]") - .into_owned(); } (output, matches) } diff --git a/crates/openshell-supervisor-middleware/BUILD.bazel b/crates/openshell-supervisor-middleware/BUILD.bazel deleted file mode 100644 index 7e858ed0d0..0000000000 --- a/crates/openshell-supervisor-middleware/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-supervisor-middleware", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-supervisor-middleware_test", - crate = ":openshell-supervisor-middleware", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-supervisor-middleware", - ":openshell-supervisor-middleware_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-supervisor-middleware/Cargo.toml b/crates/openshell-supervisor-middleware/Cargo.toml index 9cdc53febb..453520c3eb 100644 --- a/crates/openshell-supervisor-middleware/Cargo.toml +++ b/crates/openshell-supervisor-middleware/Cargo.toml @@ -12,11 +12,14 @@ rust-version.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-extension-core = { path = "../openshell-extension-core" } miette = { workspace = true } +futures = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } tokio = { workspace = true } +tokio-stream = { workspace = true } tonic = { workspace = true, features = ["channel", "server", "tls-native-roots"] } [dev-dependencies] diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index 655cf4790a..454b27c2aa 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -1,21 +1,31 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Validation and logical application of middleware request-header mutations. +//! Validation and logical application of HTTP middleware header mutations. -use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, header_mutation}; +use openshell_core::{ + proto::{ExistingHeaderAction, HeaderMutation, HttpHeader, header_mutation}, + secrets::header_value_contains_reserved_credential_marker, +}; pub const MAX_HEADER_MUTATIONS: usize = 64; pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024; +/// Selects the protected-header rules for the HTTP message being mutated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeaderAuthority { + Request, + Response, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum HeaderMutationError { TooMany { count: usize }, InvalidName { name: String }, Protected { name: String }, HopByHop { name: String }, - WriteNamespace { name: String }, UnsafeValue { name: String }, + CredentialPlaceholder { name: String }, TooLarge, InvalidExistingAction, MissingExistingAction { name: String }, @@ -31,8 +41,8 @@ impl HeaderMutationError { Self::InvalidName { .. } => "header_mutation_invalid_name", Self::Protected { .. } => "header_mutation_protected_header", Self::HopByHop { .. } => "header_mutation_hop_by_hop_header", - Self::WriteNamespace { .. } => "header_mutation_write_namespace", Self::UnsafeValue { .. } => "header_mutation_unsafe_value", + Self::CredentialPlaceholder { .. } => "header_mutation_credential_placeholder", Self::TooLarge => "header_mutation_bytes_over_capacity", Self::InvalidExistingAction => "header_mutation_invalid_existing_action", Self::MissingExistingAction { .. } => "header_mutation_missing_existing_action", @@ -67,16 +77,16 @@ impl std::fmt::Display for HeaderMutationError { "middleware cannot mutate hop-by-hop header '{name}'" ) } - Self::WriteNamespace { name } => write!( - formatter, - "middleware can only write request headers prefixed with x-openshell-middleware- and cannot write '{name}'" - ), Self::UnsafeValue { name } => { write!( formatter, "middleware cannot write header '{name}' with an unsafe value" ) } + Self::CredentialPlaceholder { name } => write!( + formatter, + "middleware cannot write credential placeholder in header '{name}'" + ), Self::TooLarge => write!( formatter, "middleware header mutations exceed {MAX_HEADER_MUTATION_BYTES} bytes" @@ -105,10 +115,11 @@ impl std::error::Error for HeaderMutationError {} /// state observed by the next middleware. Repeated values and wire order are /// preserved; comparisons are case-insensitive. pub fn apply( - existing_headers: &[(String, String)], + authority: HeaderAuthority, + existing_headers: &[HttpHeader], connection_nominated_headers: &[String], mutations: &[HeaderMutation], -) -> Result, HeaderMutationError> { +) -> Result, HeaderMutationError> { if mutations.len() > MAX_HEADER_MUTATIONS { return Err(HeaderMutationError::TooMany { count: mutations.len(), @@ -121,18 +132,19 @@ pub fn apply( match mutation.operation.as_ref() { Some(header_mutation::Operation::Write(write)) => { let name = validate_name(&write.name)?; + validate_authority(authority, MutationKind::Write, &write.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { name: write.name.clone(), }); } - if !name.starts_with("x-openshell-middleware-") { - return Err(HeaderMutationError::WriteNamespace { + if !is_safe_value(&write.value) { + return Err(HeaderMutationError::UnsafeValue { name: write.name.clone(), }); } - if !is_safe_value(&write.value) { - return Err(HeaderMutationError::UnsafeValue { + if header_value_contains_reserved_credential_marker(&write.value) { + return Err(HeaderMutationError::CredentialPlaceholder { name: write.name.clone(), }); } @@ -148,18 +160,25 @@ pub fn apply( name: write.name.clone(), }); } - let exists = headers.iter().any(|(existing, _)| *existing == name); + let exists = headers.iter().any(|existing| existing.name == name); if !exists || action == ExistingHeaderAction::Append { - headers.push((name, write.value.clone())); + headers.push(HttpHeader { + name, + value: write.value.clone(), + }); } else if action == ExistingHeaderAction::Overwrite { - headers.retain(|(existing, _)| *existing != name); - headers.push((name, write.value.clone())); + headers.retain(|existing| existing.name != name); + headers.push(HttpHeader { + name, + value: write.value.clone(), + }); } else if action != ExistingHeaderAction::Skip { return Err(HeaderMutationError::UnsupportedExistingAction); } } Some(header_mutation::Operation::Remove(remove)) => { let name = validate_name(&remove.name)?; + validate_authority(authority, MutationKind::Remove, &remove.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { name: remove.name.clone(), @@ -167,7 +186,7 @@ pub fn apply( } mutation_bytes = mutation_bytes.saturating_add(name.len()); enforce_size_limit(mutation_bytes)?; - headers.retain(|(existing, _)| *existing != name); + headers.retain(|existing| existing.name != name); } None => return Err(HeaderMutationError::Empty), } @@ -189,12 +208,34 @@ fn validate_name(name: &str) -> Result { name: name.to_string(), }); } - if is_protected(&lower) { + Ok(lower) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MutationKind { + Write, + Remove, +} + +fn validate_authority( + authority: HeaderAuthority, + kind: MutationKind, + original_name: &str, + normalized_name: &str, +) -> Result<(), HeaderMutationError> { + let protected = match authority { + HeaderAuthority::Request => is_request_protected(normalized_name), + HeaderAuthority::Response => { + is_response_protected(normalized_name) + || (kind == MutationKind::Write && is_response_remove_only(normalized_name)) + } + }; + if protected { return Err(HeaderMutationError::Protected { - name: name.to_string(), + name: original_name.to_string(), }); } - Ok(lower) + Ok(()) } fn is_name_token_byte(byte: u8) -> bool { @@ -227,7 +268,7 @@ fn is_safe_value(value: &str) -> bool { .all(|byte| byte == b'\t' || (0x20..=0x7e).contains(&byte) || byte >= 0x80) } -fn is_protected(name: &str) -> bool { +fn is_request_protected(name: &str) -> bool { matches!( name, "authorization" @@ -247,6 +288,42 @@ fn is_protected(name: &str) -> bool { || name.starts_with("x-openshell-credential") } +fn is_response_protected(name: &str) -> bool { + matches!( + name, + "authentication-info" + | "connection" + | "content-encoding" + | "content-length" + | "content-range" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authentication-info" + | "proxy-authorization" + | "proxy-connection" + | "set-cookie" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "www-authenticate" + ) || name.starts_with("x-openshell-credential") +} + +fn is_response_remove_only(name: &str) -> bool { + matches!( + name, + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) +} + fn is_connection_nominated(connection_nominated_headers: &[String], name: &str) -> bool { connection_nominated_headers .iter() @@ -276,9 +353,17 @@ mod tests { } } + fn header(name: &str, value: &str) -> HttpHeader { + HttpHeader { + name: name.into(), + value: value.into(), + } + } + #[test] fn protected_header_write_is_rejected() { let error = apply( + HeaderAuthority::Request, &[], &[], &[write( @@ -298,6 +383,7 @@ mod tests { #[test] fn unsafe_header_value_is_rejected() { let error = apply( + HeaderAuthority::Request, &[], &[], &[write( @@ -310,13 +396,65 @@ mod tests { assert!(error.to_string().contains("unsafe value")); } + #[test] + fn credential_placeholder_header_values_are_rejected() { + for value in [ + "openshell:resolve:env:API_KEY", + "Bearer openshell:resolve:env:API_KEY", + "provider-OPENSHELL-RESOLVE-ENV-API_KEY", + "openshell%3Aresolve%3Aenv%3AAPI_KEY", + "Basic dXNlcjpvcGVuc2hlbGw6cmVzb2x2ZTplbnY6QVBJX0tFWQ==", + ] { + for authority in [HeaderAuthority::Request, HeaderAuthority::Response] { + let error = apply( + authority, + &[], + &[], + &[write("x-api-key", value, ExistingHeaderAction::Overwrite)], + ) + .expect_err("credential placeholder write"); + assert_eq!( + error, + HeaderMutationError::CredentialPlaceholder { + name: "x-api-key".to_string() + } + ); + } + } + } + + #[test] + fn existing_credential_placeholder_header_value_is_preserved() { + let existing = [header("x-api-key", "openshell:resolve:env:API_KEY")]; + let updated = apply( + HeaderAuthority::Request, + &existing, + &[], + &[write( + "cache-control", + "no-store", + ExistingHeaderAction::Overwrite, + )], + ) + .expect("ordinary mutation beside an existing placeholder"); + + assert_eq!( + updated, + vec![ + header("x-api-key", "openshell:resolve:env:API_KEY"), + header("cache-control", "no-store"), + ] + ); + } + #[test] fn existing_header_write_obeys_collision_action() { let existing = [ - ("x-openshell-middleware-tag".to_string(), "one".to_string()), - ("accept".to_string(), "application/json".to_string()), + header("x-openshell-middleware-tag", "one"), + header("accept", "application/json"), ]; let appended = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -329,13 +467,14 @@ mod tests { assert_eq!( appended, vec![ - ("x-openshell-middleware-tag".into(), "one".into()), - ("accept".into(), "application/json".into()), - ("x-openshell-middleware-tag".into(), "two".into()), + header("x-openshell-middleware-tag", "one"), + header("accept", "application/json"), + header("x-openshell-middleware-tag", "two"), ] ); let overwritten = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -348,12 +487,13 @@ mod tests { assert_eq!( overwritten, vec![ - ("accept".into(), "application/json".into()), - ("x-openshell-middleware-tag".into(), "two".into()), + header("accept", "application/json"), + header("x-openshell-middleware-tag", "two"), ] ); let skipped = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -369,17 +509,29 @@ mod tests { #[test] fn remove_drops_every_case_insensitive_value() { let existing = [ - ("x-trace".to_string(), "one".to_string()), - ("accept".to_string(), "application/json".to_string()), - ("x-trace".to_string(), "two".to_string()), + header("x-trace", "one"), + header("accept", "application/json"), + header("x-trace", "two"), ]; - let updated = apply(&existing, &[], &[remove("X-Trace")]).expect("remove visible header"); - assert_eq!(updated, vec![("accept".into(), "application/json".into())]); + let updated = apply( + HeaderAuthority::Request, + &existing, + &[], + &[remove("X-Trace")], + ) + .expect("remove visible header"); + assert_eq!(updated, vec![header("accept", "application/json")]); } #[test] fn protected_header_remove_is_rejected_even_when_not_visible() { - let error = apply(&[], &[], &[remove("Authorization")]).expect_err("protected removal"); + let error = apply( + HeaderAuthority::Request, + &[], + &[], + &[remove("Authorization")], + ) + .expect_err("protected removal"); assert!( error .to_string() @@ -391,6 +543,7 @@ mod tests { fn connection_nominated_header_is_protected() { let nominated = vec!["x-openshell-middleware-tag".to_string()]; let write_error = apply( + HeaderAuthority::Request, &[], &nominated, &[write( @@ -406,12 +559,95 @@ mod tests { .contains("hop-by-hop header 'X-OpenShell-Middleware-Tag'") ); - let remove_error = apply(&[], &nominated, &[remove("X-OpenShell-Middleware-Tag")]) - .expect_err("hop-by-hop removal"); + let remove_error = apply( + HeaderAuthority::Request, + &[], + &nominated, + &[remove("X-OpenShell-Middleware-Tag")], + ) + .expect_err("hop-by-hop removal"); assert!( remove_error .to_string() .contains("hop-by-hop header 'X-OpenShell-Middleware-Tag'") ); } + + #[test] + fn request_write_accepts_end_to_end_header_without_namespace() { + let updated = apply( + HeaderAuthority::Request, + &[], + &[], + &[write( + "Cache-Control", + "no-store", + ExistingHeaderAction::Overwrite, + )], + ) + .expect("ordinary end-to-end request header"); + + assert_eq!(updated, vec![header("cache-control", "no-store")]); + } + + #[test] + fn response_authority_allows_end_to_end_writes_and_integrity_removal() { + let existing = [header("etag", "old"), header("content-type", "text/plain")]; + let updated = apply( + HeaderAuthority::Response, + &existing, + &[], + &[ + write("Cache-Control", "private", ExistingHeaderAction::Overwrite), + remove("ETag"), + ], + ) + .expect("permitted response mutations"); + + assert_eq!( + updated, + vec![ + header("content-type", "text/plain"), + header("cache-control", "private"), + ] + ); + } + + #[test] + fn response_authority_rejects_framing_and_integrity_writes() { + for mutation in [ + remove("Content-Length"), + write("ETag", "new", ExistingHeaderAction::Overwrite), + ] { + let error = apply(HeaderAuthority::Response, &[], &[], &[mutation]) + .expect_err("protected response mutation"); + assert!(matches!(error, HeaderMutationError::Protected { .. })); + } + } + + #[test] + fn response_authority_protects_credential_headers_from_writes_and_removals() { + let existing = [header("set-cookie", "session=upstream")]; + for name in [ + "Set-Cookie", + "WWW-Authenticate", + "Authentication-Info", + "Proxy-Authentication-Info", + "X-OpenShell-Credential-Token", + ] { + for mutation in [ + write(name, "planted", ExistingHeaderAction::Overwrite), + remove(name), + ] { + let error = apply(HeaderAuthority::Response, &existing, &[], &[mutation]) + .expect_err("credential response header mutation"); + assert_eq!( + error, + HeaderMutationError::Protected { + name: name.to_string() + } + ); + } + } + } } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index fe0f15f0a6..c3a1f6f71a 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -3,12 +3,18 @@ //! Supervisor middleware registration and chain execution. -mod headers; +pub mod headers; mod remote; +mod websocket; -#[cfg(test)] -use std::collections::HashMap; -use std::collections::{BTreeMap, HashSet}; +pub use websocket::{ + WebSocketCoverage, WebSocketCoverageState, WebSocketInvocation, WebSocketInvocationOutcome, + WebSocketMessageAdmission, WebSocketMessageOutcome, WebSocketMessageType, + WebSocketPreflightInput, WebSocketPreflightResult, WebSocketSession, + WebSocketSessionStartOutcome, +}; + +use std::collections::{BTreeMap, HashMap, HashSet}; use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -21,20 +27,247 @@ use openshell_core::proto::{ Decision, Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, MiddlewareBinding, MiddlewareManifest, NetworkMiddlewareConfig, RequestContext, SandboxPolicy, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, SupervisorMiddlewareService, - ValidateConfigRequest, + ValidateConfigRequest, ValidateConfigResponse, +}; +use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; +use tonic::{Request, Response as TonicResponse, Status as TonicStatus}; + +pub use openshell_core::middleware::{ + HttpRequestView, InProcessMiddleware, SupervisorMiddlewareEndpoint, WebSocketResponseStream, }; -use tokio::sync::OnceCell; -use tonic::Request; +pub type MiddlewareService = + dyn SupervisorMiddleware; + +struct GeneratedMiddlewareEndpoint { + service: Arc, +} + +#[tonic::async_trait] +impl SupervisorMiddlewareEndpoint for GeneratedMiddlewareEndpoint { + async fn describe( + &self, + request: Request<()>, + ) -> std::result::Result, TonicStatus> { + self.service.describe(request).await + } + + async fn validate_config( + &self, + request: Request, + ) -> std::result::Result, TonicStatus> { + self.service.validate_config(request).await + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> std::result::Result, TonicStatus> + { + self.service.evaluate_http_request(request).await + } + + async fn open_websocket_session( + &self, + _receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + Err(TonicStatus::unimplemented( + "middleware service does not expose an in-process WebSocket stream", + )) + } +} + +#[tonic::async_trait] +impl InProcessMiddleware for GeneratedMiddlewareEndpoint { + async fn describe(&self) -> MiddlewareManifest { + self.service + .describe(Request::new(())) + .await + .expect("generated in-process Describe failed") + .into_inner() + } + + async fn validate_config( + &self, + middleware_name: &str, + config: &prost_types::Struct, + ) -> Result<()> { + let response = self + .service + .validate_config(Request::new(ValidateConfigRequest { + config: Some(config.clone()), + middleware_name: middleware_name.to_string(), + })) + .await + .map_err(|error| miette!("{error}"))? + .into_inner(); + if response.valid { + Ok(()) + } else { + Err(miette!("{}", response.reason)) + } + } + + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> Result { + self.service + .evaluate_http_request(Request::new(request_view_to_evaluation(request))) + .await + .map(tonic::Response::into_inner) + .map_err(|error| miette!("{error}")) + } +} + +/// Adapt a generated HTTP-only service to the borrowed in-process contract. +/// +/// This compatibility adapter is intended for tests and downstream HTTP-only +/// implementations. First-party built-ins implement [`InProcessMiddleware`] +/// directly so their HTTP path remains allocation-free. +pub fn http_only_endpoint(service: Arc) -> Arc { + Arc::new(GeneratedMiddlewareEndpoint { service }) +} + +struct EndpointInProcessAdapter { + endpoint: Arc, +} + +#[tonic::async_trait] +impl InProcessMiddleware for EndpointInProcessAdapter { + async fn describe(&self) -> MiddlewareManifest { + self.endpoint + .describe(Request::new(())) + .await + .expect("in-process endpoint Describe failed") + .into_inner() + } + + async fn validate_config( + &self, + middleware_name: &str, + config: &prost_types::Struct, + ) -> Result<()> { + let response = self + .endpoint + .validate_config(Request::new(ValidateConfigRequest { + config: Some(config.clone()), + middleware_name: middleware_name.to_string(), + })) + .await + .map_err(|error| miette!("{error}"))? + .into_inner(); + if response.valid { + Ok(()) + } else { + Err(miette!("{}", response.reason)) + } + } + + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> Result { + self.endpoint + .evaluate_http_request(Request::new(request_view_to_evaluation(request))) + .await + .map(tonic::Response::into_inner) + .map_err(|error| miette!("{error}")) + } + + async fn open_websocket_session( + &self, + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + self.endpoint.open_websocket_session(requests).await + } +} + +/// Adapt a transport-neutral endpoint to the in-process registry contract. +/// +/// Prefer implementing [`InProcessMiddleware`] directly. This compatibility +/// path materializes an owned HTTP request, but preserves direct WebSocket +/// streams for endpoint implementations that predate the borrowed contract. +pub fn in_process_endpoint( + endpoint: Arc, +) -> Arc { + Arc::new(EndpointInProcessAdapter { endpoint }) +} + +/// Maximum short-lived middleware work items allowed to wait for active +/// capacity. +/// +/// Waiters do not buffer request or message bodies, so the queue can absorb a +/// larger burst without increasing the active payload-memory bound. +pub const MAX_QUEUED_MIDDLEWARE_WORK: usize = MAX_CONCURRENT_MIDDLEWARE_WORK * 2; + +/// One slot in the shared middleware work budget. +/// +/// Callers that buffer request or message bodies acquire this guard first and +/// retain it through evaluation, bounding aggregate buffered middleware input. +#[derive(Debug)] +pub struct MiddlewareWorkAdmission { + _work: OwnedSemaphorePermit, + saturated: bool, +} + +impl MiddlewareWorkAdmission { + pub fn saturated(&self) -> bool { + self.saturated + } +} + +/// Result of attempting to enter the bounded middleware work queue. +/// +/// Active-capacity saturation is ordinary backpressure: callers that obtain a +/// waiter slot eventually receive [`Self::Admitted`]. [`Self::QueueExhausted`] +/// is immediate load shedding after both active capacity and the waiter queue +/// are full. +#[derive(Debug)] +pub enum MiddlewareWorkAdmissionOutcome { + Admitted(MiddlewareWorkAdmission), + QueueExhausted, +} + +impl MiddlewareWorkAdmissionOutcome { + /// Preserve the existing failure behavior for protocols whose outer layer + /// already translates middleware admission errors into a stable response + /// or typed termination. + pub fn into_admission(self) -> Result { + match self { + Self::Admitted(admission) => Ok(admission), + Self::QueueExhausted => Err(miette!( + "middleware admission queue is full; refusing additional buffered work" + )), + } + } +} + +/// One slot in the shared persistent middleware session budget. +/// +/// Protocol-specific session runners retain this guard while at least one +/// streaming stage remains active. Registry replacement preserves the shared +/// admission state so future streaming HTTP middleware can use the same +/// process-wide bound. +#[derive(Debug)] +struct MiddlewareSessionPermit { + _session: OwnedSemaphorePermit, +} + +enum MiddlewareSessionAdmission { + Admitted(MiddlewareSessionPermit), + AtCapacity, +} pub use openshell_core::middleware::{ - DEFAULT_MIDDLEWARE_TIMEOUT, MAX_MIDDLEWARE_CHAIN_FINDINGS, MAX_MIDDLEWARE_CHAIN_STAGES, - MAX_MIDDLEWARE_CONFIGS, MAX_MIDDLEWARE_FINDINGS_PER_STAGE, MAX_MIDDLEWARE_SELECTOR_PATTERNS, - MAX_MIDDLEWARE_TIMEOUT, MIN_MIDDLEWARE_TIMEOUT, middleware_timeout_or_default, - parse_middleware_timeout, + DEFAULT_MIDDLEWARE_TIMEOUT, MAX_CONCURRENT_MIDDLEWARE_SESSIONS, MAX_CONCURRENT_MIDDLEWARE_WORK, + MAX_MIDDLEWARE_CHAIN_FINDINGS, MAX_MIDDLEWARE_CHAIN_STAGES, MAX_MIDDLEWARE_CHAIN_TIMEOUT, + MAX_MIDDLEWARE_CONFIGS, MAX_MIDDLEWARE_FINDINGS_PER_STAGE, MAX_MIDDLEWARE_PREFLIGHT_TIMEOUT, + MAX_MIDDLEWARE_SELECTOR_PATTERNS, MAX_MIDDLEWARE_TIMEOUT, MIN_MIDDLEWARE_TIMEOUT, + middleware_timeout_or_default, parse_middleware_timeout, }; -/// Largest request or replacement body accepted by the middleware platform. -pub const MAX_MIDDLEWARE_BODY_BYTES: usize = 4 * 1024 * 1024; +/// Largest logical payload or replacement accepted by the middleware platform. +pub const MAX_MIDDLEWARE_PAYLOAD_BYTES: usize = 4 * 1024 * 1024; /// Largest encoded service-specific configuration attached to one evaluation. pub const MAX_MIDDLEWARE_CONFIG_BYTES: usize = 64 * 1024; /// Largest encoded request identity context attached to one evaluation. @@ -69,22 +302,23 @@ const MAX_MIDDLEWARE_RESPONSE_ENVELOPE_BYTES: usize = MAX_MIDDLEWARE_REASON_BYTE + MAX_MIDDLEWARE_FINDINGS_PER_STAGE * MAX_MIDDLEWARE_FINDING_BYTES + MAX_MIDDLEWARE_METADATA_BYTES + MAX_MIDDLEWARE_PROTOBUF_OVERHEAD_BYTES; -/// gRPC envelope headroom derived from every bounded non-body component. +/// gRPC envelope headroom derived from every bounded non-payload component. pub const MIDDLEWARE_GRPC_ENVELOPE_BYTES: usize = if MAX_MIDDLEWARE_REQUEST_ENVELOPE_BYTES > MAX_MIDDLEWARE_RESPONSE_ENVELOPE_BYTES { MAX_MIDDLEWARE_REQUEST_ENVELOPE_BYTES } else { MAX_MIDDLEWARE_RESPONSE_ENVELOPE_BYTES }; -/// gRPC message limit derived from the body and bounded protobuf components. +/// gRPC message limit derived from the payload and bounded protobuf components. pub const MIDDLEWARE_GRPC_MESSAGE_BYTES: usize = - MAX_MIDDLEWARE_BODY_BYTES + MIDDLEWARE_GRPC_ENVELOPE_BYTES; + MAX_MIDDLEWARE_PAYLOAD_BYTES + MIDDLEWARE_GRPC_ENVELOPE_BYTES; +const MAX_STABLE_IDENTIFIER_BYTES: usize = 128; +const EXTERNAL_FINDING_LABEL: &str = "External middleware finding"; +#[cfg(test)] const HTTP_REQUEST_OPERATION: SupervisorMiddlewareOperation = SupervisorMiddlewareOperation::HttpRequest; const PRE_CREDENTIALS_PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; -const MAX_STABLE_IDENTIFIER_BYTES: usize = 128; -const EXTERNAL_FINDING_LABEL: &str = "External middleware finding"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OnError { FailClosed, @@ -136,20 +370,28 @@ impl TryFrom<(&str, &NetworkMiddlewareConfig)> for ChainEntry { } /// A policy-selected middleware config joined with metadata reported by its -/// service's `Describe` call. A missing binding is retained so `on_error` can -/// decide whether the request fails open or closed. +/// service's `Describe` call. +/// +/// An unregistered implementation is retained so `on_error` can decide whether +/// the request fails open or closed. A registered implementation without the +/// requested binding is not part of this chain. #[derive(Clone)] pub struct DescribedChainEntry { entry: ChainEntry, service: Option>, binding: Option, - max_body_bytes: usize, + max_payload_bytes: usize, timeout: Duration, } +struct DescribedChain { + entries: Vec, + unbound: Vec, +} + impl DescribedChainEntry { - pub fn max_body_bytes(&self) -> usize { - self.max_body_bytes + pub fn max_payload_bytes(&self) -> usize { + self.max_payload_bytes } pub fn on_error(&self) -> OnError { @@ -163,7 +405,7 @@ impl DescribedChainEntry { /// True when this entry resolved to a registered binding and will be /// evaluated. When false, the binding is absent from the current registry /// and the entry is handled entirely by its `on_error` policy, so it - /// imposes no body-buffering limit on the chain. + /// imposes no payload-buffering limit on the chain. pub fn is_resolved(&self) -> bool { self.binding.is_some() } @@ -195,6 +437,8 @@ pub enum TransformedBodyPolicy<'a> { pub struct HttpRequestInput { pub request_id: String, pub sandbox_id: String, + pub sandbox_name: String, + pub workspace: String, pub scheme: String, pub host: String, pub port: u16, @@ -293,20 +537,98 @@ fn apply_on_error( } } +fn request_view_to_evaluation(request: HttpRequestView<'_>) -> HttpRequestEvaluation { + HttpRequestEvaluation { + phase: request.phase() as i32, + context: Some(request.context().clone()), + config: Some(request.config().clone()), + target: Some(request.target().clone()), + headers: request.headers().to_vec(), + body: request.body().to_vec(), + middleware_name: request.middleware_name().to_string(), + } +} + #[derive(Clone)] pub struct ChainRunner { registry: Arc, } +#[derive(Clone)] +enum MiddlewareDispatch { + /// Built-ins borrow the current request state and never construct protobuf. + InProcess(Arc), + /// Operator services receive an owned protobuf through the gRPC adapter. + Grpc(remote::GrpcMiddlewareService), +} + +impl MiddlewareDispatch { + async fn describe( + &self, + ) -> std::result::Result, tonic::Status> { + match self { + Self::InProcess(service) => Ok(tonic::Response::new(service.describe().await)), + Self::Grpc(service) => service.describe().await, + } + } + + async fn validate_config( + &self, + middleware_name: &str, + config: &prost_types::Struct, + ) -> std::result::Result, tonic::Status> { + match self { + Self::InProcess(service) => Ok(tonic::Response::new( + match service.validate_config(middleware_name, config).await { + Ok(()) => ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + Err(error) => ValidateConfigResponse { + valid: false, + reason: error.to_string(), + }, + }, + )), + Self::Grpc(service) => service.validate_config(middleware_name, config).await, + } + } + + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> std::result::Result, tonic::Status> + { + match self { + Self::InProcess(service) => service + .evaluate_http_request(request) + .await + .map(tonic::Response::new) + .map_err(|error| tonic::Status::invalid_argument(error.to_string())), + Self::Grpc(service) => service.evaluate_http_request(request).await, + } + } + + async fn open_websocket_session( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + match self { + Self::InProcess(service) => service.open_websocket_session(receiver).await, + Self::Grpc(service) => service.open_websocket_session(receiver).await, + } + } +} + struct MiddlewareServiceState { /// Policy-facing built-in name or operator-owned registration name. The /// single-service test constructor leaves this empty and uses the manifest /// name after Describe. attachment_name: Option, - service: Arc, + service: MiddlewareDispatch, manifest: OnceCell, diagnostic_policy: MiddlewareDiagnosticPolicy, - operator_max_body_bytes: Option, + operator_max_payload_bytes: Option, operator_timeout: Duration, } @@ -375,6 +697,9 @@ pub struct MiddlewareRegistry { services: Arc>>, registered_services: Arc>, middleware_names: Arc>, + work_admission: Arc, + work_admission_waiters: Arc, + session_admission: Arc, } impl std::fmt::Debug for MiddlewareRegistry { @@ -384,6 +709,14 @@ impl std::fmt::Debug for MiddlewareRegistry { .field("service_count", &self.services.len()) .field("registered_service_count", &self.registered_services.len()) .field("middleware_count", &self.middleware_names.len()) + .field( + "available_work_permits", + &self.work_admission.available_permits(), + ) + .field( + "available_session_permits", + &self.session_admission.available_permits(), + ) .finish() } } @@ -399,6 +732,9 @@ impl Default for MiddlewareRegistry { services: Arc::new(Vec::new()), registered_services: Arc::new(Vec::new()), middleware_names: Arc::new(HashSet::new()), + work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), + work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), + session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), } } } @@ -423,15 +759,9 @@ fn validate_registration(registration: &SupervisorMiddlewareService) -> Result MAX_MIDDLEWARE_PAYLOAD_BYTES as u64 { return Err(miette!( - "middleware registration '{}' max_body_bytes must be greater than zero", - registration.name - )); - } - if registration.max_body_bytes > MAX_MIDDLEWARE_BODY_BYTES as u64 { - return Err(miette!( - "middleware registration '{}' max_body_bytes exceeds the platform maximum of {MAX_MIDDLEWARE_BODY_BYTES}", + "middleware registration '{}' max_payload_bytes exceeds the platform maximum of {MAX_MIDDLEWARE_PAYLOAD_BYTES}", registration.name )); } @@ -477,23 +807,54 @@ fn middleware_denial_reason(config_name: &str, reason_code: Option<&str>) -> Str ) } -fn validate_body_limit(source: &str, binding: &MiddlewareBinding) -> Result { - if binding.max_body_bytes == 0 { - return Err(miette!("{source} must advertise a non-zero body limit")); +fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result { + if binding.max_payload_bytes == 0 { + return Err(miette!("{source} must advertise a non-zero payload limit")); } - if binding.max_body_bytes > MAX_MIDDLEWARE_BODY_BYTES as u64 { + if binding.max_payload_bytes > MAX_MIDDLEWARE_PAYLOAD_BYTES as u64 { return Err(miette!( - "{source} body limit exceeds the platform maximum of {MAX_MIDDLEWARE_BODY_BYTES}" + "{source} payload limit exceeds the platform maximum of {MAX_MIDDLEWARE_PAYLOAD_BYTES}" )); } - usize::try_from(binding.max_body_bytes) - .map_err(|_| miette!("{source} reports a body limit too large for this platform")) + usize::try_from(binding.max_payload_bytes) + .map_err(|_| miette!("{source} reports a payload limit too large for this platform")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SupportedBinding { + HttpPreCredentials, + WebSocketPreCredentials, +} + +fn supported_binding(source: &str, binding: &MiddlewareBinding) -> Result { + match ( + SupervisorMiddlewareOperation::try_from(binding.operation).ok(), + SupervisorMiddlewarePhase::try_from(binding.phase).ok(), + ) { + ( + Some(SupervisorMiddlewareOperation::HttpRequest), + Some(SupervisorMiddlewarePhase::PreCredentials), + ) => Ok(SupportedBinding::HttpPreCredentials), + ( + Some(SupervisorMiddlewareOperation::WebsocketMessage), + Some(SupervisorMiddlewarePhase::PreCredentials), + ) => Ok(SupportedBinding::WebSocketPreCredentials), + ( + Some(SupervisorMiddlewareOperation::WebsocketMessage), + Some(SupervisorMiddlewarePhase::PreReturn), + ) => Err(miette!( + "{source} advertises WEBSOCKET_MESSAGE/PRE_RETURN, which is reserved for PR 2" + )), + _ => Err(miette!( + "{source} advertises an unsupported middleware operation/phase pair" + )), + } } fn validate_manifest_bindings( source: &str, manifest: &MiddlewareManifest, - operator_max_body_bytes: Option, + operator_max_payload_bytes: Option, ) -> Result<()> { if manifest.bindings.is_empty() { return Err(miette!("{source} describes no bindings")); @@ -501,27 +862,26 @@ fn validate_manifest_bindings( let mut described_pairs = HashSet::with_capacity(manifest.bindings.len()); for binding in &manifest.bindings { - if binding.operation != HTTP_REQUEST_OPERATION as i32 - || binding.phase != PRE_CREDENTIALS_PHASE as i32 - { - return Err(miette!( - "{source} must support HTTP_REQUEST/PRE_CREDENTIALS" - )); - } + supported_binding(source, binding)?; if !described_pairs.insert((binding.operation, binding.phase)) { return Err(miette!( - "{source} describes more than one binding for HTTP_REQUEST/PRE_CREDENTIALS" + "{source} describes a duplicate middleware operation/phase pair" )); } - let advertised = validate_body_limit(source, binding)?; + let advertised = validate_payload_limit(source, binding)?; if !binding.timeout.trim().is_empty() { parse_middleware_timeout(&binding.timeout) .map_err(|reason| miette!("{source} has invalid timeout for binding: {reason}"))?; } - if operator_max_body_bytes.is_some_and(|limit| limit > advertised) { + if operator_max_payload_bytes.is_some_and(|limit| limit > advertised) { + return Err(miette!( + "{source} max_payload_bytes ({}) exceeds the binding capability ({advertised})", + operator_max_payload_bytes.expect("operator limit checked above") + )); + } + if operator_max_payload_bytes == Some(0) { return Err(miette!( - "{source} max_body_bytes ({}) exceeds the binding capability ({advertised})", - operator_max_body_bytes.expect("operator limit checked above") + "{source} must configure max_payload_bytes for every payload-bearing binding" )); } } @@ -531,15 +891,46 @@ fn validate_manifest_bindings( fn validate_external_manifest( registration: &SupervisorMiddlewareService, manifest: &MiddlewareManifest, - operator_max_body_bytes: usize, + operator_max_payload_bytes: usize, + authenticated: bool, ) -> Result<()> { validate_manifest_bindings( &format!("external middleware registration '{}'", registration.name), manifest, - Some(operator_max_body_bytes), + Some(operator_max_payload_bytes), + )?; + validate_expected_audience( + ®istration.name, + ®istration.audience, + &manifest.expected_audience, + authenticated && !registration.allow_insecure_transport, ) } +/// After authenticated Describe succeeds, reject a registration whose +/// configured audience differs from the one the service says it verifies. +/// +/// This is a post-authentication consistency assertion, not audience discovery: +/// a strict verifier may reject an incorrect audience before returning its +/// manifest. A service that does not advertise an audience is accepted unchanged. +fn validate_expected_audience( + registration_name: &str, + configured: &str, + advertised: &str, + authenticated: bool, +) -> Result<()> { + if !authenticated || advertised.is_empty() { + return Ok(()); + } + if advertised != configured { + return Err(miette!( + "middleware registration '{registration_name}' expects audience \ + '{advertised}' but OpenShell is configured to mint '{configured}'" + )); + } + Ok(()) +} + /// External diagnostic text is untrusted and may contain request data. Keep /// only values derived from the validated, operator-owned registration name /// and numeric finding counts; do not carry per-request free-form text into @@ -573,52 +964,35 @@ fn normalize_untrusted_diagnostics( } } -fn validate_request_envelope( - evaluation: &HttpRequestEvaluation, -) -> std::result::Result<(), &'static str> { - if evaluation.body.len() > MAX_MIDDLEWARE_BODY_BYTES { +fn validate_request_view(request: HttpRequestView<'_>) -> std::result::Result<(), &'static str> { + if request.body().len() > MAX_MIDDLEWARE_PAYLOAD_BYTES { return Err("request_body_over_capacity"); } - if evaluation - .config - .as_ref() - .is_some_and(|config| config.encoded_len() > MAX_MIDDLEWARE_CONFIG_BYTES) - { + if request.config().encoded_len() > MAX_MIDDLEWARE_CONFIG_BYTES { return Err("request_config_over_capacity"); } - if evaluation - .context - .as_ref() - .is_some_and(|context| context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES) - { + if request.context().encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { return Err("request_context_over_capacity"); } - if evaluation - .target - .as_ref() - .is_some_and(|target| target.encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES) - { + if request.target().encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES { return Err("request_target_over_capacity"); } - if evaluation.headers.len() > MAX_MIDDLEWARE_HEADERS { + if request.headers().len() > MAX_MIDDLEWARE_HEADERS { return Err("request_header_count_over_capacity"); } - let header_bytes = evaluation.headers.iter().fold(0usize, |total, header| { + let header_bytes = request.headers().iter().fold(0usize, |total, header| { total.saturating_add(header.encoded_len()) }); if header_bytes > MAX_MIDDLEWARE_HEADER_BYTES { return Err("request_header_bytes_over_capacity"); } - if evaluation.encoded_len() > MIDDLEWARE_GRPC_MESSAGE_BYTES { - return Err("request_envelope_over_capacity"); - } Ok(()) } fn validate_response_envelope( result: &openshell_core::proto::HttpRequestResult, ) -> std::result::Result<(), &'static str> { - if result.body.len() > MAX_MIDDLEWARE_BODY_BYTES { + if result.body.len() > MAX_MIDDLEWARE_PAYLOAD_BYTES { return Err("response_body_over_capacity"); } if result.reason.len() > MAX_MIDDLEWARE_REASON_BYTES { @@ -668,27 +1042,44 @@ impl MiddlewareRegistry { /// Describe in-process services, then connect and validate every /// operator-provided service registration. pub async fn connect_services( - in_process_services: Vec>, + in_process_services: Vec>, registrations: Vec, + ) -> Result { + Self::connect_services_inner(in_process_services, registrations, None).await + } + + /// Connect services with optional refreshable credentials keyed by + /// operator registration name. A configured credential is shared by all + /// generated client clones and can rotate without rebuilding the registry. + pub async fn connect_services_authenticated( + in_process_services: Vec>, + registrations: Vec, + credentials: &HashMap, + ) -> Result { + Self::connect_services_inner(in_process_services, registrations, Some(credentials)).await + } + + async fn connect_services_inner( + in_process_services: Vec>, + registrations: Vec, + credentials: Option<&HashMap>, ) -> Result { let mut services = Vec::with_capacity(in_process_services.len() + registrations.len()); let mut registered_services = Vec::with_capacity(registrations.len()); let mut middleware_names = HashSet::new(); for service in in_process_services { - let manifest = call_with_timeout( - DEFAULT_MIDDLEWARE_TIMEOUT, - "Describe", - service.describe(Request::new(())), - ) - .await - .map(tonic::Response::into_inner) - .map_err(|error| { - miette!( - "in-process middleware Describe failed: {}", - safe_reason(&error.to_string()) - ) - })?; + let service = MiddlewareDispatch::InProcess(service); + let manifest = + call_with_timeout(DEFAULT_MIDDLEWARE_TIMEOUT, "Describe", service.describe()) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "in-process middleware Describe failed: {}", + safe_reason(&error.to_string()) + ) + })?; let source = if manifest.name.trim().is_empty() { "in-process middleware service".to_string() } else { @@ -716,7 +1107,7 @@ impl MiddlewareRegistry { service, manifest: manifest_cell, diagnostic_policy: MiddlewareDiagnosticPolicy::Preserve, - operator_max_body_bytes: None, + operator_max_payload_bytes: None, operator_timeout: DEFAULT_MIDDLEWARE_TIMEOUT, })); } @@ -730,35 +1121,54 @@ impl MiddlewareRegistry { )); } - let operator_max_body_bytes = - usize::try_from(registration.max_body_bytes).map_err(|_| { + let operator_max_payload_bytes = usize::try_from(registration.max_payload_bytes) + .map_err(|_| { miette!( - "middleware registration '{}' body limit is too large for this platform", + "middleware registration '{}' payload limit is too large for this platform", registration.name ) })?; - let service = Arc::new( - remote::RemoteMiddlewareService::connect( + // A registration the operator opted out of extension + // authentication carries no credential by design. Every other + // registration must have one, or the connection fails closed + // rather than silently downgrading to an unauthenticated call. + let bearer = credentials + .filter(|_| !registration.allow_insecure_transport) + .map(|credentials| { + credentials.get(®istration.name).cloned().ok_or_else(|| { + miette!( + "middleware registration '{}' is missing its extension credential", + registration.name + ) + }) + }) + .transpose()?; + let authenticated = bearer.is_some(); + let service = MiddlewareDispatch::Grpc( + remote::GrpcMiddlewareService::connect( ®istration.name, ®istration.grpc_endpoint, + ®istration.tls_ca_cert_pem, + bearer, ) .await?, ); - let manifest = call_with_timeout( - operator_timeout, - "Describe", - service.describe(Request::new(())), - ) - .await - .map(tonic::Response::into_inner) - .map_err(|error| { - miette!( - "middleware registration '{}' Describe failed: {}", - registration.name, - safe_reason(&error.to_string()) - ) - })?; - validate_external_manifest(®istration, &manifest, operator_max_body_bytes)?; + let manifest = call_with_timeout(operator_timeout, "Describe", service.describe()) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "middleware registration '{}' Describe failed: {}", + registration.name, + safe_reason(&error.to_string()) + ) + })?; + validate_external_manifest( + ®istration, + &manifest, + operator_max_payload_bytes, + authenticated, + )?; let manifest_cell = OnceCell::new(); manifest_cell .set(manifest) @@ -768,7 +1178,7 @@ impl MiddlewareRegistry { service, manifest: manifest_cell, diagnostic_policy: MiddlewareDiagnosticPolicy::Normalize, - operator_max_body_bytes: Some(operator_max_body_bytes), + operator_max_payload_bytes: Some(operator_max_payload_bytes), operator_timeout, })); registered_services.push(RegisteredMiddlewareService { registration }); @@ -778,6 +1188,9 @@ impl MiddlewareRegistry { services: Arc::new(services), registered_services: Arc::new(registered_services), middleware_names: Arc::new(middleware_names), + work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), + work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), + session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), }) } @@ -846,7 +1259,19 @@ impl Default for ChainRunner { } impl ChainRunner { - pub fn new(service: Arc) -> Self { + /// Construct a runner around one in-process middleware implementation. + #[must_use] + pub fn new(service: Arc) -> Self { + Self::from_service(MiddlewareDispatch::InProcess(service)) + } + + /// Construct a runner around a legacy transport-neutral in-process endpoint. + #[must_use] + pub fn from_endpoint(endpoint: Arc) -> Self { + Self::new(in_process_endpoint(endpoint)) + } + + fn from_service(service: MiddlewareDispatch) -> Self { Self { registry: Arc::new(MiddlewareRegistry { services: Arc::new(vec![Arc::new(MiddlewareServiceState { @@ -854,40 +1279,109 @@ impl ChainRunner { service, manifest: OnceCell::new(), diagnostic_policy: MiddlewareDiagnosticPolicy::Preserve, - operator_max_body_bytes: None, + operator_max_payload_bytes: None, operator_timeout: DEFAULT_MIDDLEWARE_TIMEOUT, })]), registered_services: Arc::new(Vec::new()), middleware_names: Arc::new(HashSet::new()), + work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), + work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), + session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), }), } } + #[cfg(test)] + fn new_protobuf_for_tests(service: Arc) -> Self { + let endpoint: Arc = + Arc::new(GeneratedMiddlewareEndpoint { service }); + Self::from_service(MiddlewareDispatch::Grpc( + remote::GrpcMiddlewareService::from_service(endpoint), + )) + } + pub fn from_registry(registry: MiddlewareRegistry) -> Self { Self { registry: Arc::new(registry), } } + /// Build a runner for a replacement registry while preserving process-wide + /// admission budgets across registry generations. + #[must_use] + pub fn with_replacement_registry(&self, mut registry: MiddlewareRegistry) -> Self { + registry.work_admission = Arc::clone(&self.registry.work_admission); + registry.work_admission_waiters = Arc::clone(&self.registry.work_admission_waiters); + registry.session_admission = Arc::clone(&self.registry.session_admission); + Self::from_registry(registry) + } + + /// Reserve one unit of short-lived middleware work. + /// + /// The bounded waiter queue provides backpressure for work expected to + /// complete promptly, such as HTTP evaluations, WebSocket messages, and + /// streaming-session preflight. + pub async fn reserve_middleware_work(&self) -> Result { + if let Ok(permit) = Arc::clone(&self.registry.work_admission).try_acquire_owned() { + Ok(MiddlewareWorkAdmissionOutcome::Admitted( + MiddlewareWorkAdmission { + _work: permit, + saturated: false, + }, + )) + } else { + let Ok(waiter) = Arc::clone(&self.registry.work_admission_waiters).try_acquire_owned() + else { + return Ok(MiddlewareWorkAdmissionOutcome::QueueExhausted); + }; + let permit = Arc::clone(&self.registry.work_admission) + .acquire_owned() + .await + .map_err(|_| miette!("middleware admission semaphore closed"))?; + drop(waiter); + Ok(MiddlewareWorkAdmissionOutcome::Admitted( + MiddlewareWorkAdmission { + _work: permit, + saturated: true, + }, + )) + } + } + + /// Reserve middleware work for a caller whose established external + /// behavior treats queue exhaustion as a middleware processing failure. + pub async fn reserve_middleware_work_admission(&self) -> Result { + self.reserve_middleware_work().await?.into_admission() + } + + /// Attempt to reserve one persistent middleware session without waiting. + /// + /// Long-lived sessions have no useful queueing bound because their release + /// time is unrelated to middleware latency. Protocol-specific runners apply + /// their own `on_error` semantics when the shared session budget is full. + fn try_reserve_middleware_session(&self) -> MiddlewareSessionAdmission { + Arc::clone(&self.registry.session_admission) + .try_acquire_owned() + .map_or(MiddlewareSessionAdmission::AtCapacity, |permit| { + MiddlewareSessionAdmission::Admitted(MiddlewareSessionPermit { _session: permit }) + }) + } + async fn manifests(&self) -> Result, MiddlewareManifest)>> { let mut manifests = Vec::with_capacity(self.registry.services.len()); for state in self.registry.services.iter() { let manifest = state .manifest .get_or_try_init(|| async { - call_with_timeout( - state.operator_timeout, - "Describe", - state.service.describe(Request::new(())), - ) - .await - .map(tonic::Response::into_inner) - .map_err(|error| { - miette!( - "middleware Describe failed: {}", - safe_reason(&error.to_string()) - ) - }) + call_with_timeout(state.operator_timeout, "Describe", state.service.describe()) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "middleware Describe failed: {}", + safe_reason(&error.to_string()) + ) + }) }) .await?; manifests.push((Arc::clone(state), manifest.clone())); @@ -905,62 +1399,89 @@ impl ChainRunner { .unwrap_or(manifest.name.as_str()) } - fn http_pre_credentials_binding(manifest: &MiddlewareManifest) -> Option<&MiddlewareBinding> { - manifest.bindings.iter().find(|binding| { - binding.operation == HTTP_REQUEST_OPERATION as i32 - && binding.phase == PRE_CREDENTIALS_PHASE as i32 - }) + fn binding( + manifest: &MiddlewareManifest, + operation: SupervisorMiddlewareOperation, + phase: SupervisorMiddlewarePhase, + ) -> Option<&MiddlewareBinding> { + manifest + .bindings + .iter() + .find(|binding| binding.operation == operation as i32 && binding.phase == phase as i32) } pub async fn describe_chain(&self, entries: &[ChainEntry]) -> Result> { + Ok(self + .describe_chain_for( + entries, + SupervisorMiddlewareOperation::HttpRequest, + SupervisorMiddlewarePhase::PreCredentials, + ) + .await? + .entries) + } + + pub async fn describe_websocket_chain( + &self, + entries: &[ChainEntry], + ) -> Result> { + Ok(self + .describe_chain_for( + entries, + SupervisorMiddlewareOperation::WebsocketMessage, + SupervisorMiddlewarePhase::PreCredentials, + ) + .await? + .entries) + } + + async fn describe_chain_for( + &self, + entries: &[ChainEntry], + operation: SupervisorMiddlewareOperation, + phase: SupervisorMiddlewarePhase, + ) -> Result { ensure_chain_capacity(entries.len())?; let manifests = self.manifests().await?; let mut entries = entries.to_vec(); sort_chain_entries(&mut entries); - entries - .iter() - .map(|entry| { - let described = manifests - .iter() - .find(|(state, manifest)| { - Self::attachment_name(state, manifest) == entry.implementation - }) - .and_then(|(state, manifest)| { - Self::http_pre_credentials_binding(manifest) - .cloned() - .map(|binding| (Arc::clone(state), binding)) - }); - let (service, binding) = described.map_or((None, None), |(service, binding)| { - (Some(service), Some(binding)) + let mut described_entries = Vec::with_capacity(entries.len()); + let mut unbound = Vec::new(); + for entry in entries { + let Some((state, manifest)) = manifests.iter().find(|(state, manifest)| { + Self::attachment_name(state, manifest) == entry.implementation + }) else { + described_entries.push(DescribedChainEntry { + entry, + service: None, + binding: None, + max_payload_bytes: 0, + timeout: DEFAULT_MIDDLEWARE_TIMEOUT, }); - let max_body_bytes = binding - .as_ref() - .map(|binding| { - let advertised = validate_body_limit("middleware manifest", binding)?; - Ok::<_, miette::Report>( - service - .as_ref() - .and_then(|state| state.operator_max_body_bytes) - .unwrap_or(advertised), - ) - }) - .transpose()? - .unwrap_or(0); - let timeout = service - .as_ref() - .zip(binding.as_ref()) - .map(|(state, binding)| state.timeout_for_binding(binding)) - .transpose()? - .unwrap_or(DEFAULT_MIDDLEWARE_TIMEOUT); - Ok(DescribedChainEntry { - entry: entry.clone(), - service, - binding, - max_body_bytes, - timeout, - }) - }) - .collect() + continue; + }; + let Some(binding) = Self::binding(manifest, operation, phase).cloned() else { + // The config remains globally ordered, but it does not + // participate in this exact operation/phase chain. + unbound.push(entry); + continue; + }; + let timeout = state.timeout_for_binding(&binding)?; + let advertised = validate_payload_limit("middleware manifest", &binding)?; + let max_payload_bytes = state.operator_max_payload_bytes.unwrap_or(advertised); + described_entries.push(DescribedChainEntry { + entry, + service: Some(Arc::clone(state)), + binding: Some(binding), + max_payload_bytes, + timeout, + }); + } + ensure_chain_capacity(described_entries.len())?; + Ok(DescribedChain { + entries: described_entries, + unbound, + }) } pub async fn validate_config( @@ -974,23 +1495,16 @@ impl ChainRunner { )); } let manifests = self.manifests().await?; - let Some((state, binding)) = manifests.iter().find_map(|(state, manifest)| { - (Self::attachment_name(state, manifest) == middleware_name) - .then(|| Self::http_pre_credentials_binding(manifest)) - .flatten() - .map(|binding| (state, binding)) - }) else { + let Some((state, _manifest)) = manifests + .iter() + .find(|(state, manifest)| Self::attachment_name(state, manifest) == middleware_name) + else { return Err(miette!("middleware '{middleware_name}' is not registered")); }; let response = call_with_timeout( - state.timeout_for_binding(binding)?, + state.operator_timeout, "ValidateConfig", - state - .service - .validate_config(Request::new(ValidateConfigRequest { - config: Some(config), - middleware_name: middleware_name.into(), - })), + state.service.validate_config(middleware_name, &config), ) .await .map(tonic::Response::into_inner) @@ -1041,17 +1555,78 @@ impl ChainRunner { entries: &[DescribedChainEntry], input: HttpRequestInput, transformed_body_policy: TransformedBodyPolicy<'_>, + ) -> Result { + let admission = if entries.is_empty() { + None + } else { + Some(self.reserve_middleware_work_admission().await?) + }; + self.evaluate_described_with_policy_admitted( + entries, + input, + transformed_body_policy, + admission, + ) + .await + } + + /// Evaluate a chain using capacity reserved before its request body was + /// buffered. The guard is retained until the ordered chain completes. + pub async fn evaluate_described_with_policy_admitted( + &self, + entries: &[DescribedChainEntry], + input: HttpRequestInput, + transformed_body_policy: TransformedBodyPolicy<'_>, + admission: Option, ) -> Result { ensure_chain_capacity(entries.len())?; - let mut headers = input.headers.clone(); - let mut body = input.body.clone(); + let HttpRequestInput { + request_id, + sandbox_id, + sandbox_name, + workspace, + scheme, + host, + port, + method, + path, + query, + headers, + connection_nominated_headers, + body, + } = input; + // The request envelope is moved into one stable chain state. Built-ins + // borrow these values for every stage; only the gRPC adapter clones them + // when an operator service requires an owned protobuf message. + let context = RequestContext { + request_id, + sandbox_id, + sandbox_name, + workspace, + originating_process: None, + }; + let target = HttpRequestTarget { + scheme, + host, + port: u32::from(port), + method, + path, + query, + }; + let mut headers: Vec = headers + .into_iter() + .map(|(name, value)| HttpHeader { name, value }) + .collect(); + let mut body = body; let mut header_mutations = Vec::new(); let mut findings = Vec::new(); let mut metadata = BTreeMap::new(); let mut applied = Vec::new(); + let _admission = admission; + let chain_deadline = tokio::time::Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; for entry in entries { - let Some(binding) = entry.binding.as_ref() else { + let Some(_binding) = entry.binding.as_ref() else { match apply_on_error(entry, "binding_not_described", &mut applied) { OnErrorAction::FailOpen => continue, OnErrorAction::FailClosed(reason) => { @@ -1068,7 +1643,7 @@ impl ChainRunner { } } }; - if body.len() > entry.max_body_bytes { + if body.len() > entry.max_payload_bytes { match apply_on_error(entry, "request_body_over_capacity", &mut applied) { OnErrorAction::FailOpen => continue, OnErrorAction::FailClosed(reason) => { @@ -1085,8 +1660,16 @@ impl ChainRunner { } } } - let evaluation = build_evaluation(entry, binding, &input, &headers, &body); - if let Err(reason) = validate_request_envelope(&evaluation) { + let request = HttpRequestView::new( + PRE_CREDENTIALS_PHASE, + &context, + &entry.entry.config, + &target, + &headers, + &body, + &entry.entry.implementation, + ); + if let Err(reason) = validate_request_view(request) { match apply_on_error(entry, reason, &mut applied) { OnErrorAction::FailOpen => continue, OnErrorAction::FailClosed(reason) => { @@ -1106,12 +1689,28 @@ impl ChainRunner { let Some(service) = entry.service.as_ref() else { unreachable!("described binding always has a service") }; + let remaining = chain_deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + match apply_on_error(entry, "middleware_chain_timeout", &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + denial: None, + }); + } + } + } let mut result = match call_with_timeout( - entry.timeout, + entry.timeout.min(remaining), "EvaluateHttpRequest", - service - .service - .evaluate_http_request(Request::new(evaluation)), + service.service.evaluate_http_request(request), ) .await { @@ -1224,7 +1823,7 @@ impl ChainRunner { }); } - if result.has_body && result.body.len() > entry.max_body_bytes { + if result.has_body && result.body.len() > entry.max_payload_bytes { match apply_on_error(entry, "response_body_over_capacity", &mut applied) { OnErrorAction::FailOpen => continue, OnErrorAction::FailClosed(reason) => { @@ -1245,40 +1844,49 @@ impl ChainRunner { // Validate and apply the entire stage atomically. Under fail-open, // one malformed mutation must not leave earlier mutations from the // same response visible to later middleware. - let updated_headers = match headers::apply( - &headers, - &input.connection_nominated_headers, - &result.header_mutations, - ) { - Ok(updated) => updated, - Err(error) => { - let reason = service - .diagnostic_policy - .header_mutation_error_reason(&error); - match apply_on_error(entry, &reason, &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); + let updated_headers = if result.header_mutations.is_empty() { + None + } else { + match headers::apply( + headers::HeaderAuthority::Request, + &headers, + &connection_nominated_headers, + &result.header_mutations, + ) { + Ok(updated) => Some(updated), + Err(error) => { + let reason = service + .diagnostic_policy + .header_mutation_error_reason(&error); + match apply_on_error(entry, &reason, &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + denial: None, + }); + } } } } }; - let headers_transformed = updated_headers != headers; - headers = updated_headers; - header_mutations.extend(result.header_mutations.iter().cloned()); + let headers_transformed = updated_headers + .as_ref() + .is_some_and(|updated| updated != &headers); + if let Some(updated) = updated_headers { + headers = updated; + } + header_mutations.extend(std::mem::take(&mut result.header_mutations)); let body_transformed = result.has_body; if body_transformed { - result.body.clone_into(&mut body); + body = std::mem::take(&mut result.body); } for finding in result.findings { findings.push(NamespacedFinding { @@ -1289,7 +1897,7 @@ impl ChainRunner { if !result.metadata.is_empty() { metadata.insert( entry.entry.name.clone(), - result.metadata.clone().into_iter().collect(), + result.metadata.into_iter().collect(), ); } applied.push(MiddlewareInvocation { @@ -1370,41 +1978,6 @@ fn ensure_chain_capacity(count: usize) -> Result<()> { Ok(()) } -fn build_evaluation( - entry: &DescribedChainEntry, - binding: &MiddlewareBinding, - input: &HttpRequestInput, - headers: &[(String, String)], - body: &[u8], -) -> HttpRequestEvaluation { - HttpRequestEvaluation { - phase: binding.phase, - context: Some(RequestContext { - request_id: input.request_id.clone(), - sandbox_id: input.sandbox_id.clone(), - originating_process: None, - }), - config: Some(entry.entry.config.clone()), - target: Some(HttpRequestTarget { - scheme: input.scheme.clone(), - host: input.host.clone(), - port: u32::from(input.port), - method: input.method.clone(), - path: input.path.clone(), - query: input.query.clone(), - }), - headers: headers - .iter() - .map(|(name, value)| HttpHeader { - name: name.clone(), - value: value.clone(), - }) - .collect(), - body: body.to_vec(), - middleware_name: entry.entry.implementation.clone(), - } -} - pub(crate) fn safe_reason(reason: &str) -> String { reason .chars() @@ -1416,13 +1989,41 @@ pub(crate) fn safe_reason(reason: &str) -> String { #[cfg(test)] mod tests { use super::*; + use futures::{FutureExt, Stream, StreamExt}; use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ SupervisorMiddleware, SupervisorMiddlewareServer, }; use openshell_core::proto::{ExistingHeaderAction, header_mutation}; use openshell_supervisor_middleware_builtins::{BUILTIN_REGEX, services}; + use tokio_stream::wrappers::TcpListenerStream; + #[test] + fn advertised_audience_mismatch_fails_registration() { + let configured = "urn:openshell:extension:middleware:content-guard"; + + // Matching and unadvertised audiences both pass. + validate_expected_audience("content-guard", configured, "", true) + .expect("unadvertised audience is accepted"); + validate_expected_audience("content-guard", configured, configured, true) + .expect("matching audience is accepted"); + + // Once authenticated Describe succeeds, reject a manifest that + // contradicts the operator-owned audience configuration. + let error = + validate_expected_audience("content-guard", configured, "urn:example:stale", true) + .expect_err("mismatched audience must fail closed"); + let message = error.to_string(); + assert!(message.contains("urn:example:stale")); + assert!(message.contains(configured)); + + // The check does not apply where no credential is attached at all, + // whether because the registration opted out or because the gateway + // has no signing key configured. + validate_expected_audience("content-guard", configured, "urn:example:stale", false) + .expect("an unauthenticated call has no audience to mismatch"); + } + fn builtin_runner() -> ChainRunner { ChainRunner::new( services() @@ -1453,7 +2054,9 @@ mod tests { fn input(body: &str) -> HttpRequestInput { HttpRequestInput { request_id: "req".into(), - sandbox_id: "sbx".into(), + sandbox_id: "sbx-id".into(), + sandbox_name: "sbx-name".into(), + workspace: "wrks-default".into(), scheme: "https".into(), host: "api.example.com".into(), port: 443, @@ -1478,30 +2081,325 @@ mod tests { } } + #[derive(Debug, Clone, PartialEq, Eq)] + struct RequestAddresses { + phase: SupervisorMiddlewarePhase, + context: usize, + request_id: usize, + config: usize, + target: usize, + host: usize, + headers: usize, + first_header_name: usize, + body: usize, + originating_process_present: bool, + middleware_name: String, + } + + /// Records borrowed addresses so the test can detect an owned envelope + /// being reconstructed between otherwise no-op in-process stages. + struct BorrowedRecordingService { + manifest_name: String, + received: std::sync::Mutex>, + } + + #[tonic::async_trait] + impl InProcessMiddleware for BorrowedRecordingService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: self.manifest_name.clone(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> Result { + let addresses = RequestAddresses { + phase: request.phase(), + context: std::ptr::from_ref(request.context()).addr(), + request_id: request.context().request_id.as_ptr().addr(), + config: std::ptr::from_ref(request.config()).addr(), + target: std::ptr::from_ref(request.target()).addr(), + host: request.target().host.as_ptr().addr(), + headers: request.headers().as_ptr().addr(), + first_header_name: request + .headers() + .first() + .map_or(0, |header| header.name.as_ptr().addr()), + body: request.body().as_ptr().addr(), + originating_process_present: request.context().originating_process.is_some(), + middleware_name: request.middleware_name().to_string(), + }; + self.received + .lock() + .expect("borrowed request recorder lock") + .push(addresses); + Ok(allow_result()) + } + } + #[tokio::test] - async fn phase_one_evaluation_omits_originating_process() { - let entries = builtin_runner() - .describe_chain(&[entry("redact", OnError::FailClosed)]) + async fn in_process_stages_share_one_borrowed_request_envelope() { + let service = Arc::new(BorrowedRecordingService { + manifest_name: "acme/redactor".into(), + received: std::sync::Mutex::new(Vec::new()), + }); + let runner = ChainRunner::new(service.clone()); + let entries = [ + ChainEntry { + name: "first".into(), + implementation: "acme/redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "second".into(), + implementation: "acme/redactor".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ]; + let described = runner + .describe_chain(&entries) .await .expect("describe chain"); - let entry = &entries[0]; - let binding = entry.binding.as_ref().expect("described binding"); - let input = input("payload"); - let evaluation = build_evaluation(entry, binding, &input, &[], b"payload"); + let expected_configs: Vec<_> = described + .iter() + .map(|entry| std::ptr::from_ref(&entry.entry.config).addr()) + .collect(); + let mut request = input("payload"); + request.headers = vec![("x-test".into(), "value".into())]; + let expected_body = request.body.as_ptr().addr(); + let expected_request_id = request.request_id.as_ptr().addr(); + let expected_host = request.host.as_ptr().addr(); + let expected_header_name = request.headers[0].0.as_ptr().addr(); - assert_eq!( - evaluation.phase, - SupervisorMiddlewarePhase::PreCredentials as i32 - ); + let outcome = runner + .evaluate_described(&described, request) + .await + .expect("evaluate borrowed chain"); + let received = service.received.lock().expect("borrowed requests"); + + assert!(outcome.allowed); + assert_eq!(outcome.body.as_ptr().addr(), expected_body); + assert_eq!(received.len(), 2); + assert_eq!(received[0].phase, SupervisorMiddlewarePhase::PreCredentials); + assert!(!received[0].originating_process_present); + assert_eq!(received[0].request_id, expected_request_id); + assert_eq!(received[0].host, expected_host); + assert_eq!(received[0].first_header_name, expected_header_name); + assert_eq!(received[0].body, expected_body); + assert_eq!(received[0].config, expected_configs[0]); + assert_eq!(received[1].config, expected_configs[1]); + assert_eq!(received[0].context, received[1].context); + assert_eq!(received[0].target, received[1].target); + assert_eq!(received[0].headers, received[1].headers); + assert_eq!(received[0].body, received[1].body); assert!( - evaluation - .context - .expect("request context") - .originating_process - .is_none() + received + .iter() + .all(|request| request.middleware_name == "acme/redactor") ); } + const TEST_REPLACEMENT_BODY: &[u8] = b"stage-one-replacement"; + + /// Records both sides of a successful body replacement so the test can + /// distinguish ownership transfer from a content-preserving body copy. + #[derive(Debug, Default)] + struct ReplacementTransferRecord { + invocations: usize, + returned_body: Option, + second_body: Option, + second_body_bytes: Vec, + } + + /// Replaces the first request body and observes the body borrowed by the + /// second stage without replacing it again. + struct ReplacementTransferService { + record: std::sync::Mutex, + } + + #[tonic::async_trait] + impl InProcessMiddleware for ReplacementTransferService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/replacement-transfer".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> Result { + let mut record = self.record.lock().expect("replacement transfer record"); + let invocation = record.invocations; + record.invocations += 1; + + if invocation == 0 { + let replacement = TEST_REPLACEMENT_BODY.to_vec(); + record.returned_body = Some(replacement.as_ptr().addr()); + let mut result = allow_result(); + result.body = replacement; + result.has_body = true; + Ok(result) + } else { + record.second_body = Some(request.body().as_ptr().addr()); + record.second_body_bytes = request.body().to_vec(); + Ok(allow_result()) + } + } + } + + #[tokio::test] + async fn replacement_body_allocation_moves_through_next_stage_and_outcome() { + let service = Arc::new(ReplacementTransferService { + record: std::sync::Mutex::new(ReplacementTransferRecord::default()), + }); + let runner = ChainRunner::new(service.clone()); + let entries = [ + ChainEntry { + name: "replace".into(), + implementation: "test/replacement-transfer".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "observe".into(), + implementation: "test/replacement-transfer".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ]; + + let outcome = runner + .evaluate(&entries, input("original-body")) + .await + .expect("evaluate replacement transfer chain"); + let record = service.record.lock().expect("replacement transfer record"); + let returned_body = record + .returned_body + .expect("first-stage replacement pointer"); + + assert!(outcome.allowed); + assert_eq!(record.invocations, 2); + assert_eq!(record.second_body_bytes, TEST_REPLACEMENT_BODY); + assert_eq!(record.second_body, Some(returned_body)); + assert_eq!(outcome.body, TEST_REPLACEMENT_BODY); + assert_eq!(outcome.body.as_ptr().addr(), returned_body); + } + + /// An in-process service that yields forever so the runtime must enforce + /// the binding timeout around borrowed validation and evaluation futures. + struct PendingInProcessService; + + #[tonic::async_trait] + impl InProcessMiddleware for PendingInProcessService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/pending".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + timeout: "10ms".into(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + std::future::pending().await + } + + async fn evaluate_http_request( + &self, + _request: HttpRequestView<'_>, + ) -> Result { + std::future::pending().await + } + } + + #[tokio::test] + async fn in_process_evaluation_remains_interruptible_by_stage_timeout() { + let runner = ChainRunner::new(Arc::new(PendingInProcessService)); + let entry = |on_error| ChainEntry { + name: "pending".into(), + implementation: "test/pending".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + }; + let closed = runner + .evaluate(&[entry(OnError::FailClosed)], input("payload")) + .await + .expect("timed-out in-process evaluation"); + let open = runner + .evaluate(&[entry(OnError::FailOpen)], input("payload")) + .await + .expect("fail-open timed-out in-process evaluation"); + + assert!(!closed.allowed); + assert_eq!(closed.reason, "middleware_failed: middleware_timeout"); + assert!(closed.applied[0].failed); + assert!(open.allowed); + assert!(open.applied[0].failed); + } + + #[tokio::test] + async fn in_process_validation_remains_interruptible_by_binding_timeout() { + let runner = ChainRunner::new(Arc::new(PendingInProcessService)); + let error = runner + .validate_config("test/pending", prost_types::Struct::default()) + .await + .expect_err("timed-out in-process validation"); + + assert!(error.to_string().contains("ValidateConfig failed")); + assert!(error.to_string().contains("timed out")); + } + #[tokio::test] async fn applies_fixed_regex_replacements() { let outcome = builtin_runner() @@ -1535,6 +2433,13 @@ mod tests { r#"token="[REDACTED]""# ); assert_eq!(outcome.applied.len(), 2); + assert_eq!( + [ + outcome.applied[0].transformed, + outcome.applied[1].transformed, + ], + [true, false] + ); } #[tokio::test] @@ -1644,15 +2549,13 @@ mod tests { #[tokio::test] async fn injected_services_cannot_duplicate_middleware_names() { - let first: Arc = Arc::new(ScriptedService { + let first: Arc = Arc::new(BorrowedRecordingService { manifest_name: "openshell/test".into(), - max_body_bytes: 1024, - result: allow_result(), + received: std::sync::Mutex::new(Vec::new()), }); - let second: Arc = Arc::new(ScriptedService { + let second: Arc = Arc::new(BorrowedRecordingService { manifest_name: "openshell/test".into(), - max_body_bytes: 1024, - result: allow_result(), + received: std::sync::Mutex::new(Vec::new()), }); let error = MiddlewareRegistry::connect_services(vec![first, second], Vec::new()) @@ -1676,6 +2579,16 @@ mod tests { #[tonic::async_trait] impl SupervisorMiddleware for ScriptedService { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn evaluate_web_socket_session( + &self, + _request: Request>, + ) -> std::result::Result, tonic::Status> + { + Err(tonic::Status::unimplemented("HTTP-only test middleware")) + } + async fn describe( &self, _request: Request<()>, @@ -1686,25 +2599,21 @@ mod tests { bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_body_bytes: self.max_body_bytes, + max_payload_bytes: self.max_body_bytes, timeout: String::new(), }], + expected_audience: String::new(), })) } async fn validate_config( &self, _request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new( - openshell_core::proto::ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - )) + ) -> std::result::Result, tonic::Status> { + Ok(tonic::Response::new(ValidateConfigResponse { + valid: true, + reason: String::new(), + })) } async fn evaluate_http_request( @@ -1725,6 +2634,16 @@ mod tests { #[tonic::async_trait] impl SupervisorMiddleware for SlowService { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn evaluate_web_socket_session( + &self, + _request: Request>, + ) -> std::result::Result, tonic::Status> + { + Err(tonic::Status::unimplemented("HTTP-only test middleware")) + } + async fn describe( &self, _request: Request<()>, @@ -1735,26 +2654,22 @@ mod tests { bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_body_bytes: 4096, + max_payload_bytes: 4096, timeout: self.binding_timeout.clone(), }], + expected_audience: String::new(), })) } async fn validate_config( &self, _request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { + ) -> std::result::Result, tonic::Status> { tokio::time::sleep(self.delay).await; - Ok(tonic::Response::new( - openshell_core::proto::ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - )) + Ok(tonic::Response::new(ValidateConfigResponse { + valid: true, + reason: String::new(), + })) } async fn evaluate_http_request( @@ -1778,6 +2693,16 @@ mod tests { #[tonic::async_trait] impl SupervisorMiddleware for TwoStageService { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn evaluate_web_socket_session( + &self, + _request: Request>, + ) -> std::result::Result, tonic::Status> + { + Err(tonic::Status::unimplemented("HTTP-only test middleware")) + } + async fn describe( &self, _request: Request<()>, @@ -1788,25 +2713,21 @@ mod tests { bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_body_bytes: 256 * 1024, + max_payload_bytes: 256 * 1024, timeout: String::new(), }], + expected_audience: String::new(), })) } async fn validate_config( &self, _request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new( - openshell_core::proto::ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - )) + ) -> std::result::Result, tonic::Status> { + Ok(tonic::Response::new(ValidateConfigResponse { + valid: true, + reason: String::new(), + })) } async fn evaluate_http_request( @@ -1842,10 +2763,10 @@ mod tests { // must stop there: the second stage never runs, so it never sees a // payload the policy would reject. let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { + let service: Arc = Arc::new(TwoStageService { second_ran: Arc::clone(&second_ran), }); - let runner = ChainRunner::new(service); + let runner = ChainRunner::new_protobuf_for_tests(service); let transform = ChainEntry { name: "transform".into(), implementation: "test/two-stage".into(), @@ -1904,10 +2825,10 @@ mod tests { // A validator that accepts every body lets both stages run; the second // stage sees the first stage's output. let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { + let service: Arc = Arc::new(TwoStageService { second_ran: Arc::clone(&second_ran), }); - let runner = ChainRunner::new(service); + let runner = ChainRunner::new_protobuf_for_tests(service); let transform = ChainEntry { name: "transform".into(), implementation: "test/two-stage".into(), @@ -1956,10 +2877,10 @@ mod tests { #[tokio::test] async fn per_stage_validator_error_becomes_structured_denial() { let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { + let service: Arc = Arc::new(TwoStageService { second_ran: Arc::clone(&second_ran), }); - let runner = ChainRunner::new(service); + let runner = ChainRunner::new_protobuf_for_tests(service); let entries = [ ChainEntry { name: "transform".into(), @@ -2042,6 +2963,16 @@ mod tests { #[tonic::async_trait] impl SupervisorMiddleware for RecordingService { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn evaluate_web_socket_session( + &self, + _request: Request>, + ) -> std::result::Result, tonic::Status> + { + Err(tonic::Status::unimplemented("HTTP-only test middleware")) + } + async fn describe( &self, _request: Request<()>, @@ -2052,29 +2983,25 @@ mod tests { bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_body_bytes: 4096, + max_payload_bytes: 4096, timeout: String::new(), }], + expected_audience: String::new(), })) } async fn validate_config( &self, request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { + ) -> std::result::Result, tonic::Status> { self.validated .lock() .expect("validated config lock") .push(request.into_inner()); - Ok(tonic::Response::new( - openshell_core::proto::ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - )) + Ok(tonic::Response::new(ValidateConfigResponse { + valid: true, + reason: String::new(), + })) } async fn evaluate_http_request( @@ -2099,8 +3026,68 @@ mod tests { received: std::sync::Mutex>, } + struct InProcessHeaderChainService { + received: std::sync::Mutex>>, + } + + #[tonic::async_trait] + impl InProcessMiddleware for InProcessHeaderChainService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/in-process-header-chain".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> Result { + let invocation = { + let mut received = self.received.lock().expect("in-process header chain lock"); + let invocation = received.len(); + received.push(request.headers().to_vec()); + invocation + }; + let mut result = allow_result(); + if invocation == 0 { + result.header_mutations.push(write_header( + "cache-control", + "no-store", + ExistingHeaderAction::Overwrite, + )); + } + Ok(result) + } + } + #[tonic::async_trait] impl SupervisorMiddleware for HeaderChainService { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn evaluate_web_socket_session( + &self, + _request: Request>, + ) -> std::result::Result, tonic::Status> + { + Err(tonic::Status::unimplemented("HTTP-only test middleware")) + } + async fn describe( &self, _request: Request<()>, @@ -2111,25 +3098,21 @@ mod tests { bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_body_bytes: 4096, + max_payload_bytes: 4096, timeout: String::new(), }], + expected_audience: String::new(), })) } async fn validate_config( &self, _request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new( - openshell_core::proto::ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - )) + ) -> std::result::Result, tonic::Status> { + Ok(tonic::Response::new(ValidateConfigResponse { + valid: true, + reason: String::new(), + })) } async fn evaluate_http_request( @@ -2149,13 +3132,13 @@ mod tests { let mut result = allow_result(); if invocation == 0 { result.header_mutations.push(write_header( - "x-openshell-middleware-chain", + "cache-control", "first", ExistingHeaderAction::Overwrite, )); } else if invocation == 1 { result.header_mutations.push(write_header( - "x-openshell-middleware-chain", + "cache-control", "second", self.second_action, )); @@ -2175,7 +3158,7 @@ mod tests { second_action: action, received: std::sync::Mutex::new(Vec::new()), }); - let runner = ChainRunner::new(service.clone()); + let runner = ChainRunner::new_protobuf_for_tests(service.clone()); let entries = [ ChainEntry { name: "first".into(), @@ -2209,13 +3192,56 @@ mod tests { let observed: Vec<&str> = received[2] .headers .iter() - .filter(|header| header.name == "x-openshell-middleware-chain") + .filter(|header| header.name == "cache-control") .map(|header| header.value.as_str()) .collect(); assert_eq!(observed, expected, "action {action:?}"); } } + #[tokio::test] + async fn in_process_request_middleware_writes_end_to_end_header_without_namespace() { + let service = Arc::new(InProcessHeaderChainService { + received: std::sync::Mutex::new(Vec::new()), + }); + let runner = ChainRunner::new(service.clone()); + let entries = [ + ChainEntry { + name: "writer".into(), + implementation: "test/in-process-header-chain".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "observer".into(), + implementation: "test/in-process-header-chain".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ]; + + let outcome = runner + .evaluate(&entries, input("payload")) + .await + .expect("evaluate in-process header chain"); + let received = service + .received + .lock() + .expect("recorded in-process headers"); + + assert!(outcome.allowed); + assert_eq!( + received[1] + .iter() + .filter(|header| header.name == "cache-control") + .map(|header| header.value.as_str()) + .collect::>(), + vec!["no-store"] + ); + } + #[tokio::test] async fn repeated_request_headers_reach_middleware_in_wire_order() { // A map contract would collapse repeated header names to one value @@ -2226,17 +3252,35 @@ mod tests { validated: std::sync::Mutex::new(Vec::new()), received: std::sync::Mutex::new(Vec::new()), }); - let recorder: Arc = service.clone(); - let runner = ChainRunner::new(recorder); + let recorder: Arc = service.clone(); + let runner = ChainRunner::new_protobuf_for_tests(recorder); + let validation_config = prost_types::Struct { + fields: std::iter::once(( + "required".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("present".into())), + }, + )) + .collect(), + }; runner - .validate_config("test/recorder", prost_types::Struct::default()) + .validate_config("test/recorder", validation_config.clone()) .await .expect("validate recorder config"); + let evaluation_config = prost_types::Struct { + fields: std::iter::once(( + "evaluation".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("preserved".into())), + }, + )) + .collect(), + }; let recorder_entry = ChainEntry { name: "recorder".into(), implementation: "test/recorder".into(), order: 0, - config: prost_types::Struct::default(), + config: evaluation_config.clone(), on_error: OnError::FailClosed, }; let mut request = input("payload"); @@ -2245,6 +3289,8 @@ mod tests { ("accept".into(), "application/json".into()), ("x-api-key".into(), "second-value".into()), ]; + request.query = "page=2".into(); + let original_body = request.body.as_ptr().addr(); let outcome = runner .evaluate(&[recorder_entry], request) @@ -2255,11 +3301,33 @@ mod tests { let validated = service.validated.lock().expect("validated configs"); assert_eq!(validated.len(), 1); assert_eq!(validated[0].middleware_name, "test/recorder"); + assert_eq!(validated[0].config.as_ref(), Some(&validation_config)); drop(validated); let received = service.received.lock().expect("recorded evaluations"); assert_eq!(received.len(), 1); + assert_eq!(outcome.body.as_ptr().addr(), original_body); + assert_ne!(received[0].body.as_ptr().addr(), original_body); + assert_eq!(received[0].body, b"payload"); + assert_eq!( + received[0].phase, + SupervisorMiddlewarePhase::PreCredentials as i32 + ); assert_eq!(received[0].middleware_name, "test/recorder"); + assert_eq!(received[0].config.as_ref(), Some(&evaluation_config)); + let context = received[0].context.as_ref().expect("request context"); + assert_eq!(context.request_id, "req"); + assert_eq!(context.sandbox_id, "sbx-id"); + assert_eq!(context.sandbox_name, "sbx-name"); + assert_eq!(context.workspace, "wrks-default"); + assert!(context.originating_process.is_none()); + let target = received[0].target.as_ref().expect("request target"); + assert_eq!(target.scheme, "https"); + assert_eq!(target.host, "api.example.com"); + assert_eq!(target.port, 443); + assert_eq!(target.method, "POST"); + assert_eq!(target.path, "/v1"); + assert_eq!(target.query, "page=2"); let headers: Vec<(&str, &str)> = received[0] .headers .iter() @@ -2275,28 +3343,24 @@ mod tests { ); } - fn external_registration(max_body_bytes: u64) -> SupervisorMiddlewareService { + fn external_registration(max_payload_bytes: u64) -> SupervisorMiddlewareService { SupervisorMiddlewareService { name: "local-guard-service".into(), grpc_endpoint: "http://127.0.0.1:50051".into(), - max_body_bytes, + max_payload_bytes, ..Default::default() } } async fn registry_with_external( - service: Arc, + service: Arc, registration: SupervisorMiddlewareService, ) -> MiddlewareRegistry { let builtin_service = services() .into_iter() .next() .expect("built-in middleware service"); - let builtin_manifest = builtin_service - .describe(Request::new(())) - .await - .expect("describe built-in service") - .into_inner(); + let builtin_manifest = builtin_service.describe().await; validate_manifest_bindings("test built-in service", &builtin_manifest, None) .expect("valid built-in manifest"); let builtin_name = builtin_manifest.name.clone(); @@ -2310,9 +3374,9 @@ mod tests { .await .expect("describe test service") .into_inner(); - let operator_max_body_bytes = usize::try_from(registration.max_body_bytes).unwrap(); + let operator_max_payload_bytes = usize::try_from(registration.max_payload_bytes).unwrap(); let operator_timeout = validate_registration(®istration).expect("valid registration"); - validate_external_manifest(®istration, &manifest, operator_max_body_bytes) + validate_external_manifest(®istration, &manifest, operator_max_payload_bytes, false) .expect("valid external manifest"); let manifest_cell = OnceCell::new(); manifest_cell.set(manifest).expect("manifest cache"); @@ -2321,23 +3385,28 @@ mod tests { services: Arc::new(vec![ Arc::new(MiddlewareServiceState { attachment_name: Some(builtin_name.clone()), - service: builtin_service, + service: MiddlewareDispatch::InProcess(builtin_service), manifest: builtin_manifest_cell, diagnostic_policy: MiddlewareDiagnosticPolicy::Preserve, - operator_max_body_bytes: None, + operator_max_payload_bytes: None, operator_timeout: DEFAULT_MIDDLEWARE_TIMEOUT, }), Arc::new(MiddlewareServiceState { attachment_name: Some(registration_name.clone()), - service, + service: MiddlewareDispatch::Grpc(remote::GrpcMiddlewareService::from_service( + Arc::new(GeneratedMiddlewareEndpoint { service }), + )), manifest: manifest_cell, diagnostic_policy: MiddlewareDiagnosticPolicy::Normalize, - operator_max_body_bytes: Some(operator_max_body_bytes), + operator_max_payload_bytes: Some(operator_max_payload_bytes), operator_timeout, }), ]), registered_services: Arc::new(vec![RegisteredMiddlewareService { registration }]), middleware_names: Arc::new(HashSet::from([builtin_name, registration_name])), + work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), + work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), + session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), } } @@ -2357,13 +3426,13 @@ mod tests { // The built-in resolves and reports its real limit; the missing binding // does not resolve and must not contribute a body limit. assert!(described[0].is_resolved()); - assert_eq!(described[0].max_body_bytes(), 256 * 1024); + assert_eq!(described[0].max_payload_bytes(), 256 * 1024); assert!(!described[1].is_resolved()); } #[tokio::test] async fn descriptors_are_resolved_from_any_middleware_service() { - let runner = ChainRunner::new(Arc::new(ScriptedService { + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, result: allow_result(), @@ -2380,7 +3449,7 @@ mod tests { .describe_chain(std::slice::from_ref(&entry)) .await .expect("describe external middleware"); - assert_eq!(described[0].max_body_bytes(), 4096); + assert_eq!(described[0].max_payload_bytes(), 4096); assert_eq!( described[0] .binding @@ -2419,8 +3488,8 @@ mod tests { .describe_chain(&entries) .await .expect("describe chain"); - assert_eq!(described[0].max_body_bytes(), 256 * 1024); - assert_eq!(described[1].max_body_bytes(), 1024); + assert_eq!(described[0].max_payload_bytes(), 256 * 1024); + assert_eq!(described[1].max_payload_bytes(), 1024); let outcome = runner .evaluate_described(&described, input(r#"token="sk-ABCDEFGHIJKLMNOP""#)) @@ -2526,25 +3595,26 @@ mod tests { bindings: vec![MiddlewareBinding { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, - max_body_bytes: 4096, + max_payload_bytes: 4096, timeout: String::new(), }], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4097) + let error = validate_external_manifest(®istration, &manifest, 4097, false) .expect_err("operator limit must fit capability"); assert!(error.to_string().contains("exceeds")); } #[test] - fn external_registration_rejects_body_limit_above_platform_maximum() { + fn external_registration_rejects_payload_limit_above_platform_maximum() { let registration = external_registration(u64::MAX); let error = validate_registration(®istration) - .expect_err("extreme body limit must be rejected before allocation"); + .expect_err("extreme payload limit must be rejected before allocation"); assert!(error.to_string().contains("platform maximum")); } #[test] - fn manifest_rejects_body_limit_above_platform_maximum() { + fn manifest_rejects_payload_limit_above_platform_maximum() { let registration = external_registration(4096); let manifest = MiddlewareManifest { name: "example/service".into(), @@ -2552,12 +3622,13 @@ mod tests { bindings: vec![MiddlewareBinding { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, - max_body_bytes: u64::MAX, + max_payload_bytes: u64::MAX, timeout: String::new(), }], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4096) - .expect_err("extreme advertised body limit must be rejected"); + let error = validate_external_manifest(®istration, &manifest, 4096, false) + .expect_err("extreme advertised payload limit must be rejected"); assert!(error.to_string().contains("platform maximum")); } @@ -2567,24 +3638,92 @@ mod tests { let binding = || MiddlewareBinding { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, - max_body_bytes: 4096, + max_payload_bytes: 4096, timeout: String::new(), }; let manifest = MiddlewareManifest { name: "example/service".into(), service_version: "test".into(), bindings: vec![binding(), binding()], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4096) + let error = validate_external_manifest(®istration, &manifest, 4096, false) .expect_err("one service cannot advertise two bindings for the same pair"); assert!( error .to_string() - .contains("more than one binding for HTTP_REQUEST/PRE_CREDENTIALS") + .contains("duplicate middleware operation/phase pair") + ); + } + + #[test] + fn manifest_accepts_forward_websocket_binding_and_reserves_return_phase() { + let binding = |phase| MiddlewareBinding { + operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, + phase: phase as i32, + max_payload_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, + timeout: "500ms".into(), + }; + let mut manifest = MiddlewareManifest { + name: "example/websocket".into(), + service_version: "test".into(), + bindings: vec![binding(SupervisorMiddlewarePhase::PreCredentials)], + expected_audience: String::new(), + }; + validate_manifest_bindings("test WebSocket service", &manifest, None) + .expect("forward WebSocket binding is supported"); + + manifest.bindings = vec![binding(SupervisorMiddlewarePhase::PreReturn)]; + let error = validate_manifest_bindings("test WebSocket service", &manifest, None) + .expect_err("return-path binding stays reserved for PR 2"); + assert!(error.to_string().contains("reserved for PR 2")); + } + + #[test] + fn external_websocket_binding_requires_operator_payload_limit() { + let registration = external_registration(0); + let manifest = MiddlewareManifest { + name: "example/websocket".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + }; + + let error = validate_external_manifest(®istration, &manifest, 0, false) + .expect_err("WebSocket bindings require an operator payload ceiling"); + assert!( + error + .to_string() + .contains("must configure max_payload_bytes") ); } + #[test] + fn external_websocket_binding_rejects_operator_limit_above_capability() { + let registration = external_registration(4097); + let manifest = MiddlewareManifest { + name: "example/websocket".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + }; + + let error = validate_external_manifest(®istration, &manifest, 4097, false) + .expect_err("operator payload limit must fit WebSocket capability"); + assert!(error.to_string().contains("exceeds")); + } + #[test] fn external_registration_accepts_http_and_https_grpc_endpoints() { for grpc_endpoint in [ @@ -2648,11 +3787,12 @@ mod tests { bindings: vec![MiddlewareBinding { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, - max_body_bytes: 4096, + max_payload_bytes: 4096, timeout: timeout.into(), }], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4096) + let error = validate_external_manifest(®istration, &manifest, 4096, false) .expect_err("out-of-bounds binding timeout must be rejected"); assert!(error.to_string().contains("invalid timeout")); } @@ -2887,11 +4027,11 @@ mod tests { .add_service( SupervisorMiddlewareServer::new(ScriptedService { manifest_name: "test/middleware".into(), - max_body_bytes: MAX_MIDDLEWARE_BODY_BYTES as u64, + max_body_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, result: openshell_core::proto::HttpRequestResult { reason: "r".repeat(MAX_MIDDLEWARE_REASON_BYTES - 128), reason_code: "r".repeat(MAX_MIDDLEWARE_REASON_CODE_BYTES), - body: vec![b'x'; MAX_MIDDLEWARE_BODY_BYTES], + body: vec![b'x'; MAX_MIDDLEWARE_PAYLOAD_BYTES], has_body: true, header_mutations: vec![write_header( "x-openshell-middleware-envelope", @@ -2915,7 +4055,7 @@ mod tests { }); let server_task = tokio::spawn(server); - let mut registration = external_registration(MAX_MIDDLEWARE_BODY_BYTES as u64); + let mut registration = external_registration(MAX_MIDDLEWARE_PAYLOAD_BYTES as u64); registration.grpc_endpoint = format!("http://{address}"); let registry = MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) .await @@ -2939,7 +4079,7 @@ mod tests { "x-large-envelope".into(), "v".repeat(MAX_MIDDLEWARE_HEADER_BYTES - 256), )]; - request.body = vec![b'b'; MAX_MIDDLEWARE_BODY_BYTES]; + request.body = vec![b'b'; MAX_MIDDLEWARE_PAYLOAD_BYTES]; let outcome = ChainRunner::from_registry(registry) .evaluate( &[ChainEntry { @@ -2955,7 +4095,7 @@ mod tests { .expect("maximum bounded envelopes should fit configured transport limit"); assert!(outcome.allowed); - assert_eq!(outcome.body.len(), MAX_MIDDLEWARE_BODY_BYTES); + assert_eq!(outcome.body.len(), MAX_MIDDLEWARE_PAYLOAD_BYTES); assert_eq!(outcome.header_mutations.len(), 1); assert_eq!(outcome.findings.len(), MAX_MIDDLEWARE_FINDINGS_PER_STAGE); let _ = shutdown_tx.send(()); @@ -2970,7 +4110,7 @@ mod tests { assert_eq!(MIDDLEWARE_GRPC_ENVELOPE_BYTES, 292 * 1024 + 64); assert_eq!( MIDDLEWARE_GRPC_MESSAGE_BYTES, - MAX_MIDDLEWARE_BODY_BYTES + 292 * 1024 + 64 + MAX_MIDDLEWARE_PAYLOAD_BYTES + 292 * 1024 + 64 ); } @@ -3032,7 +4172,7 @@ mod tests { #[tokio::test] async fn invalid_reason_code_is_a_middleware_failure() { - let runner = ChainRunner::new(Arc::new(scripted_service( + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( openshell_core::proto::HttpRequestResult { decision: Decision::Deny as i32, reason_code: "Secret value!".into(), @@ -3096,6 +4236,53 @@ mod tests { assert!(!format!("{outcome:?}").contains(secret)); } + #[tokio::test] + async fn credential_placeholder_header_mutation_follows_on_error() { + let placeholder = "openshell:resolve:env:API_KEY"; + let service = Arc::new(ScriptedService { + manifest_name: "test/middleware".into(), + max_body_bytes: 4096, + result: openshell_core::proto::HttpRequestResult { + header_mutations: vec![write_header( + "x-api-key", + placeholder, + ExistingHeaderAction::Overwrite, + )], + ..allow_result() + }, + }); + let registry = registry_with_external(service, external_registration(4096)).await; + let runner = ChainRunner::from_registry(registry); + + for (on_error, allowed) in [(OnError::FailClosed, false), (OnError::FailOpen, true)] { + let outcome = runner + .evaluate( + &[ChainEntry { + name: "guard".into(), + implementation: "local-guard-service".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + }], + input("hello"), + ) + .await + .expect("evaluate credential placeholder mutation"); + + assert_eq!(outcome.allowed, allowed); + assert!(outcome.header_mutations.is_empty()); + assert_eq!(outcome.applied.len(), 1); + assert!(outcome.applied[0].failed); + assert!(!format!("{outcome:?}").contains(placeholder)); + if !allowed { + assert_eq!( + outcome.reason, + "middleware_failed: header_mutation_credential_placeholder" + ); + } + } + } + #[tokio::test] async fn connection_nominated_write_and_remove_are_rejected_after_filtering() { let mutations = [ @@ -3192,7 +4379,7 @@ mod tests { #[tokio::test] async fn maximum_chain_retains_findings_from_every_stage() { - let runner = ChainRunner::new(Arc::new(ScriptedService { + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, result: openshell_core::proto::HttpRequestResult { @@ -3242,7 +4429,7 @@ mod tests { #[tokio::test] async fn deny_decision_short_circuits_chain() { - let runner = ChainRunner::new(Arc::new(scripted_service( + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( openshell_core::proto::HttpRequestResult { decision: Decision::Deny as i32, reason: "blocked_by_policy".into(), @@ -3277,7 +4464,7 @@ mod tests { #[tokio::test] async fn deny_decision_ignores_unsafe_mutations_under_fail_open() { - let runner = ChainRunner::new(Arc::new(scripted_service( + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( openshell_core::proto::HttpRequestResult { decision: Decision::Deny as i32, reason: "blocked_by_policy".into(), @@ -3305,7 +4492,7 @@ mod tests { #[tokio::test] async fn deny_decision_ignores_oversized_replacement_under_fail_open() { - let runner = ChainRunner::new(Arc::new(ScriptedService { + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: BUILTIN_REGEX.into(), max_body_bytes: 4, result: openshell_core::proto::HttpRequestResult { @@ -3333,7 +4520,7 @@ mod tests { #[tokio::test] async fn metadata_and_findings_are_namespaced_per_config() { - let runner = ChainRunner::new(Arc::new(scripted_service( + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( openshell_core::proto::HttpRequestResult { findings: vec![Finding { r#type: "pii.email".into(), @@ -3390,7 +4577,7 @@ mod tests { #[tokio::test] async fn malformed_response_headers_fail_closed_denies() { - let runner = ChainRunner::new(Arc::new(unsafe_header_service())); + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(unsafe_header_service())); let outcome = runner .evaluate(&[entry("redact", OnError::FailClosed)], input("hello")) .await @@ -3412,7 +4599,7 @@ mod tests { #[tokio::test] async fn malformed_response_headers_fail_open_continues() { - let runner = ChainRunner::new(Arc::new(unsafe_header_service())); + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(unsafe_header_service())); let outcome = runner .evaluate(&[entry("redact", OnError::FailOpen)], input("hello")) .await @@ -3426,7 +4613,7 @@ mod tests { #[tokio::test] async fn oversized_replacement_body_honors_on_error() { - let runner = ChainRunner::new(Arc::new(ScriptedService { + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: BUILTIN_REGEX.into(), max_body_bytes: 4, result: openshell_core::proto::HttpRequestResult { @@ -3461,7 +4648,7 @@ mod tests { #[tokio::test] async fn oversized_request_body_honors_on_error() { - let runner = ChainRunner::new(Arc::new(ScriptedService { + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: BUILTIN_REGEX.into(), max_body_bytes: 4, result: allow_result(), @@ -3492,7 +4679,7 @@ mod tests { #[tokio::test] async fn unspecified_decision_uses_fail_closed() { - let runner = ChainRunner::new(Arc::new(scripted_service( + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( openshell_core::proto::HttpRequestResult { decision: Decision::Unspecified as i32, ..allow_result() @@ -3511,4 +4698,1338 @@ mod tests { ); assert!(outcome.applied[0].failed); } + + #[derive(Clone, Default)] + struct OpenAiRedactionService { + preflight: Arc>>, + manifest_name: String, + describe_calls: Arc, + skip: bool, + deny: bool, + preflight_reason: String, + preflight_reason_code: String, + preflight_findings: Vec, + preflight_metadata: HashMap, + close_on_first_message: bool, + messages: Arc, + session_ends: Option< + tokio::sync::mpsc::UnboundedSender, + >, + } + + impl OpenAiRedactionService { + fn websocket_stream(&self, mut requests: S) -> WebSocketResponseStream + where + S: Stream< + Item = std::result::Result< + openshell_core::proto::WebSocketSessionEvent, + tonic::Status, + >, + > + Send + + Unpin + + 'static, + { + use openshell_core::proto::{ + WebSocketMessageResult, WebSocketPreflightAction, WebSocketPreflightDecision, + WebSocketSessionEventResult, web_socket_message, web_socket_message_result, + web_socket_session_event, web_socket_session_event_result, + }; + + let preflight = Arc::clone(&self.preflight); + let skip = self.skip; + let deny = self.deny; + let preflight_reason = self.preflight_reason.clone(); + let preflight_reason_code = self.preflight_reason_code.clone(); + let preflight_findings = self.preflight_findings.clone(); + let preflight_metadata = self.preflight_metadata.clone(); + let close_on_first_message = self.close_on_first_message; + let messages = Arc::clone(&self.messages); + let session_ends = self.session_ends.clone(); + let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Some(Ok(request)) = requests.next().await { + let response = match request.event { + Some(web_socket_session_event::Event::Preflight(value)) => { + *preflight.lock().expect("preflight lock") = Some(value); + Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::PreflightDecision( + WebSocketPreflightDecision { + action: if deny { + WebSocketPreflightAction::Deny as i32 + } else if skip { + WebSocketPreflightAction::Skip as i32 + } else { + WebSocketPreflightAction::Inspect as i32 + }, + reason: preflight_reason.clone(), + findings: preflight_findings.clone(), + metadata: preflight_metadata.clone(), + reason_code: preflight_reason_code.clone(), + }, + ), + ), + }) + } + Some(web_socket_session_event::Event::Message(value)) => { + messages.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if close_on_first_message { + break; + } + let web_socket_message::Payload::Text(payload) = + value.payload.expect("test OpenAI event payload") + else { + panic!("test OpenAI event must be text"); + }; + let payload = payload.replace("customer-secret", "[REDACTED]"); + Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::MessageResult( + WebSocketMessageResult { + sequence: value.sequence, + decision: Decision::Allow as i32, + replacement: Some( + web_socket_message_result::Replacement::Text( + payload, + ), + ), + reason_code: "redacted".into(), + ..Default::default() + }, + ), + ), + }) + } + Some(web_socket_session_event::Event::SessionStart(_)) | None => None, + Some(web_socket_session_event::Event::SessionEnd(end)) => { + if let Some(session_ends) = &session_ends + && let Ok(reason) = + openshell_core::proto::WebSocketSessionEndReason::try_from( + end.reason, + ) + { + let _ = session_ends.send(reason); + } + None + } + }; + if let Some(response) = response + && responses_tx.send(Ok(response)).await.is_err() + { + break; + } + } + }); + Box::pin(tokio_stream::wrappers::ReceiverStream::new(responses_rx)) + } + } + + fn websocket_preflight_input(session_id: impl Into) -> WebSocketPreflightInput { + WebSocketPreflightInput { + session_id: session_id.into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + } + } + + #[tonic::async_trait] + impl SupervisorMiddleware for OpenAiRedactionService { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn describe( + &self, + _request: Request<()>, + ) -> std::result::Result, tonic::Status> { + self.describe_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(tonic::Response::new(MiddlewareManifest { + name: if self.manifest_name.is_empty() { + "test/openai-websocket-redactor".into() + } else { + self.manifest_name.clone() + }, + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, + timeout: "1s".into(), + }], + expected_audience: String::new(), + })) + } + + async fn validate_config( + &self, + _request: Request, + ) -> std::result::Result, tonic::Status> { + Ok(tonic::Response::new(ValidateConfigResponse { + valid: true, + reason: String::new(), + })) + } + + async fn evaluate_http_request( + &self, + _request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Err(tonic::Status::unimplemented( + "WebSocket-only test middleware", + )) + } + + async fn evaluate_web_socket_session( + &self, + request: Request>, + ) -> std::result::Result, tonic::Status> + { + Ok(tonic::Response::new( + self.websocket_stream(request.into_inner()), + )) + } + } + + #[tonic::async_trait] + impl SupervisorMiddlewareEndpoint for OpenAiRedactionService { + async fn describe( + &self, + request: Request<()>, + ) -> std::result::Result, tonic::Status> { + SupervisorMiddleware::describe(self, request).await + } + + async fn validate_config( + &self, + request: Request, + ) -> std::result::Result, tonic::Status> { + SupervisorMiddleware::validate_config(self, request).await + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + SupervisorMiddleware::evaluate_http_request(self, request).await + } + + async fn open_websocket_session( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + Ok( + self.websocket_stream( + tokio_stream::wrappers::ReceiverStream::new(receiver).map(Ok), + ), + ) + } + } + + fn websocket_entry( + name: &str, + implementation: &str, + order: i32, + on_error: OnError, + ) -> ChainEntry { + ChainEntry { + name: name.into(), + implementation: implementation.into(), + order, + config: prost_types::Struct::default(), + on_error, + } + } + + #[tokio::test] + async fn empty_websocket_chain_skips_describe_and_preflight() { + let service = OpenAiRedactionService::default(); + let describe_calls = Arc::clone(&service.describe_calls); + let observed_preflight = Arc::clone(&service.preflight); + let runner = ChainRunner::from_endpoint(Arc::new(service)); + + let outcome = runner + .preflight_websocket(&[], websocket_preflight_input("empty-chain")) + .await + .expect("empty preflight"); + + assert!(outcome.allowed); + assert_eq!(outcome.terminal_reason, None); + assert!(outcome.session.is_none()); + assert!(outcome.invocations.is_empty()); + assert_eq!(describe_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(observed_preflight.lock().expect("preflight lock").is_none()); + } + + #[tokio::test] + async fn http_only_attachments_are_reported_but_do_not_select_websocket_stages() { + for on_error in [OnError::FailClosed, OnError::FailOpen] { + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { + manifest_name: "test/http-only".into(), + max_body_bytes: 4096, + result: allow_result(), + })); + let chain = [ChainEntry { + name: "http-guard".into(), + implementation: "test/http-only".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + }]; + + let outcome = runner + .preflight_websocket( + &chain, + websocket_preflight_input(format!("http-only-{on_error:?}")), + ) + .await + .expect("HTTP-only attachment coverage"); + + assert!(outcome.allowed); + assert_eq!(outcome.terminal_reason, None); + assert!(outcome.session.is_none()); + assert!(outcome.invocations.is_empty()); + assert_eq!( + outcome.coverage, + [WebSocketCoverage { + config_name: "http-guard".into(), + implementation: "test/http-only".into(), + state: WebSocketCoverageState::BindingNotSelected, + sequence: None, + message_type: None, + original_size: 0, + }] + ); + } + } + + #[tokio::test] + async fn http_only_attachment_allows_33_requested_subprotocols() { + let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { + manifest_name: "test/http-only".into(), + max_body_bytes: 4096, + result: allow_result(), + })); + let chain = [ChainEntry { + name: "http-guard".into(), + implementation: "test/http-only".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }]; + let mut input = websocket_preflight_input("http-only-many-subprotocols"); + input.requested_subprotocols = (0..33) + .map(|index| format!("subprotocol-{index}")) + .collect(); + + let outcome = runner + .preflight_websocket(&chain, input) + .await + .expect("HTTP-only attachment must not validate a WebSocket middleware envelope"); + + assert!(outcome.allowed); + assert_eq!(outcome.terminal_reason, None); + assert!(outcome.session.is_none()); + assert!(outcome.invocations.is_empty()); + assert_eq!( + outcome.coverage, + [WebSocketCoverage { + config_name: "http-guard".into(), + implementation: "test/http-only".into(), + state: WebSocketCoverageState::BindingNotSelected, + sequence: None, + message_type: None, + original_size: 0, + }] + ); + } + + #[tokio::test] + async fn unsupported_binary_messages_advance_sequence_without_applying_on_error() { + for on_error in [OnError::FailClosed, OnError::FailOpen] { + let runner = builtin_runner(); + let chain = [entry("regex-redactor", on_error)]; + let preflight = runner + .preflight_websocket( + &chain, + websocket_preflight_input(format!("binary-{on_error:?}")), + ) + .await + .expect("preflight"); + let mut session = preflight.session.expect("built-in inspects text"); + assert!(session.start("").await.allowed); + + let coverage = session.observe_unsupported_message(WebSocketMessageType::Binary, 23); + assert_eq!( + coverage, + [WebSocketCoverage { + config_name: "regex-redactor".into(), + implementation: BUILTIN_REGEX.into(), + state: WebSocketCoverageState::UnsupportedMessageType, + sequence: Some(1), + message_type: Some(WebSocketMessageType::Binary), + original_size: 23, + }] + ); + + let text = session.evaluate_text(r#"{"input":"safe"}"#.into()).await; + assert!(text.allowed); + assert_eq!(text.invocations[0].sequence, Some(2)); + assert!(!text.invocations[0].failed); + + session + .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .await; + } + } + + #[tokio::test] + async fn explicit_websocket_preflight_denial_is_authoritative_for_both_error_modes() { + use openshell_core::proto::WebSocketSessionEndReason; + + for on_error in [OnError::FailOpen, OnError::FailClosed] { + let (session_ends_tx, mut session_ends_rx) = tokio::sync::mpsc::unbounded_channel(); + let runner = ChainRunner::from_endpoint(Arc::new(OpenAiRedactionService { + deny: true, + preflight_reason: "contains sensitive request data".into(), + preflight_reason_code: "upgrade_blocked".into(), + preflight_findings: vec![Finding { + r#type: "content.sensitive".into(), + label: "Sensitive content".into(), + count: 1, + confidence: "high".into(), + severity: "high".into(), + }], + preflight_metadata: HashMap::from([("policy_version".into(), "1".into())]), + session_ends: Some(session_ends_tx), + ..Default::default() + })); + + let outcome = runner + .preflight_websocket( + &[websocket_entry( + "deny-upgrade", + "test/openai-websocket-redactor", + 0, + on_error, + )], + websocket_preflight_input("explicit-denial"), + ) + .await + .expect("denied preflight"); + + assert!(!outcome.allowed); + assert_eq!( + outcome.terminal_reason, + Some(WebSocketSessionEndReason::MiddlewareDenial) + ); + assert_eq!( + outcome.reason, + "middleware_denied:deny-upgrade:upgrade_blocked" + ); + assert_eq!( + outcome + .denial + .as_ref() + .map(|denial| denial.config_name.as_str()), + Some("deny-upgrade") + ); + assert_eq!( + outcome + .denial + .as_ref() + .and_then(|denial| denial.reason_code.as_deref()), + Some("upgrade_blocked") + ); + assert_eq!( + outcome.invocations[0].reason_code.as_deref(), + Some("upgrade_blocked") + ); + assert_eq!(outcome.findings.len(), 1); + assert_eq!(outcome.findings[0].middleware, "deny-upgrade"); + assert_eq!(outcome.findings[0].finding.r#type, "content.sensitive"); + assert_eq!(outcome.metadata["deny-upgrade"]["policy_version"], "1"); + assert!(!outcome.reason.contains("sensitive request data")); + assert!(outcome.session.is_none()); + assert_eq!(outcome.invocations.len(), 1); + assert_eq!( + outcome.invocations[0].outcome, + WebSocketInvocationOutcome::Deny + ); + assert!(!outcome.invocations[0].failed); + assert_eq!( + session_ends_rx.recv().await, + Some(WebSocketSessionEndReason::MiddlewareDenial) + ); + assert!( + session_ends_rx.try_recv().is_err(), + "each opened stream receives at most one session_end" + ); + } + } + + #[tokio::test] + async fn mixed_websocket_preflight_denial_ends_every_opened_stage() { + use openshell_core::proto::WebSocketSessionEndReason; + + let (first_end_tx, mut first_end_rx) = tokio::sync::mpsc::unbounded_channel(); + let (denier_end_tx, mut denier_end_rx) = tokio::sync::mpsc::unbounded_channel(); + let (last_end_tx, mut last_end_rx) = tokio::sync::mpsc::unbounded_channel(); + let endpoints: Vec> = vec![ + in_process_endpoint(Arc::new(OpenAiRedactionService { + manifest_name: "test/first-inspector".into(), + session_ends: Some(first_end_tx), + ..Default::default() + })), + in_process_endpoint(Arc::new(OpenAiRedactionService { + manifest_name: "test/denier".into(), + deny: true, + session_ends: Some(denier_end_tx), + ..Default::default() + })), + in_process_endpoint(Arc::new(OpenAiRedactionService { + manifest_name: "test/last-inspector".into(), + session_ends: Some(last_end_tx), + ..Default::default() + })), + ]; + let runner = ChainRunner::from_registry( + MiddlewareRegistry::connect_services(endpoints, Vec::new()) + .await + .expect("connect mixed middleware services"), + ); + let chain = [ + websocket_entry("first", "test/first-inspector", 0, OnError::FailClosed), + websocket_entry("deny", "test/denier", 1, OnError::FailOpen), + websocket_entry("last", "test/last-inspector", 2, OnError::FailClosed), + ]; + + let outcome = runner + .preflight_websocket(&chain, websocket_preflight_input("mixed-denial")) + .await + .expect("mixed preflight"); + + assert!(!outcome.allowed); + assert_eq!( + outcome.terminal_reason, + Some(WebSocketSessionEndReason::MiddlewareDenial) + ); + assert_eq!( + outcome + .invocations + .iter() + .map(|invocation| invocation.outcome) + .collect::>(), + vec![ + WebSocketInvocationOutcome::Inspect, + WebSocketInvocationOutcome::Deny, + WebSocketInvocationOutcome::Inspect, + ] + ); + for receiver in [&mut first_end_rx, &mut denier_end_rx, &mut last_end_rx] { + assert_eq!( + receiver.recv().await, + Some(WebSocketSessionEndReason::MiddlewareDenial) + ); + assert!( + receiver.try_recv().is_err(), + "each opened stream receives at most one session_end" + ); + } + assert_eq!( + runner.registry.session_admission.available_permits(), + MAX_CONCURRENT_MIDDLEWARE_SESSIONS + ); + } + + #[tokio::test] + async fn builtin_regex_redacts_ordered_websocket_text_messages() { + let runner = builtin_runner(); + let chain = [entry("regex-redactor", OnError::FailClosed)]; + let preflight = runner + .preflight_websocket( + &chain, + WebSocketPreflightInput { + session_id: "builtin-regex-session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: vec!["realtime".into()], + }, + ) + .await + .expect("preflight"); + assert!(preflight.allowed); + assert_eq!( + preflight.invocations[0].outcome, + WebSocketInvocationOutcome::Inspect + ); + let mut session = preflight.session.expect("built-in chose to inspect"); + assert!(session.start("realtime").await.allowed); + + let original = r#"{"type":"response.create","response":{"input":"sk-ABCDEFGHIJKLMNOP"}}"#; + let redacted = session.evaluate_text(original.into()).await; + assert!(redacted.allowed); + assert_eq!( + redacted.payload, + r#"{"type":"response.create","response":{"input":"[REDACTED]"}}"# + ); + assert_eq!(redacted.invocations[0].sequence, Some(1)); + assert!(redacted.invocations[0].transformed); + assert_eq!(redacted.findings.len(), 1); + assert_eq!(redacted.findings[0].middleware, "regex-redactor"); + assert_eq!(redacted.findings[0].finding.r#type, "regex.openai"); + assert_eq!( + redacted.metadata["regex-redactor"]["regex_matches_replaced"], + "1" + ); + + let unchanged = session + .evaluate_text(r#"{"type":"response.cancel"}"#.into()) + .await; + assert!(unchanged.allowed); + assert_eq!(unchanged.payload, r#"{"type":"response.cancel"}"#); + assert_eq!(unchanged.invocations[0].sequence, Some(2)); + assert!(!unchanged.invocations[0].transformed); + assert!(unchanged.findings.is_empty()); + assert!(unchanged.metadata.is_empty()); + + let oversized = session.evaluate_text("a".repeat(256 * 1024 + 1)).await; + assert!(!oversized.allowed); + assert_eq!( + oversized.reason, + "middleware_failed: request_message_over_capacity" + ); + session + .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .await; + } + + #[tokio::test] + async fn builtin_regex_redacts_after_fail_open_message_capacity_gap() { + let runner = builtin_runner(); + let chain = [entry("regex-redactor", OnError::FailOpen)]; + let preflight = runner + .preflight_websocket( + &chain, + WebSocketPreflightInput { + session_id: "builtin-regex-gap-session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: vec!["realtime".into()], + }, + ) + .await + .expect("preflight"); + assert!(preflight.allowed); + let mut session = preflight.session.expect("built-in chose to inspect"); + assert!(session.start("realtime").await.allowed); + + let oversized_payload = "a".repeat(256 * 1024 + 1); + let oversized = session.evaluate_text(oversized_payload.clone()).await; + assert!(oversized.allowed); + assert_eq!(oversized.payload, oversized_payload); + assert_eq!(oversized.invocations[0].sequence, Some(1)); + assert_eq!( + oversized.invocations[0].outcome, + WebSocketInvocationOutcome::FailOpen + ); + assert!(!oversized.invocations[0].stage_disabled); + + let original = r#"{"type":"response.create","response":{"input":"sk-ABCDEFGHIJKLMNOP"}}"#; + let redacted = session.evaluate_text(original.into()).await; + assert!(redacted.allowed); + assert_eq!( + redacted.payload, + r#"{"type":"response.create","response":{"input":"[REDACTED]"}}"# + ); + assert_eq!(redacted.invocations[0].sequence, Some(2)); + assert_eq!( + redacted.invocations[0].outcome, + WebSocketInvocationOutcome::Allow + ); + assert!(redacted.invocations[0].transformed); + assert!(!redacted.invocations[0].stage_disabled); + + session + .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .await; + } + + #[tokio::test] + async fn in_process_websocket_endpoint_redacts_openai_event() { + let service = OpenAiRedactionService::default(); + let runner = ChainRunner::from_endpoint(Arc::new(service)); + let chain = [ChainEntry { + name: "openai-redactor".into(), + implementation: "test/openai-websocket-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }]; + let preflight = runner + .preflight_websocket( + &chain, + WebSocketPreflightInput { + session_id: "in-process-session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: vec!["realtime".into()], + }, + ) + .await + .expect("preflight"); + assert!(preflight.allowed); + let mut session = preflight.session.expect("middleware chose to inspect"); + assert!(session.start("realtime").await.allowed); + + let original = r#"{"type":"response.create","response":{"input":"customer-secret"}}"#; + let outcome = session.evaluate_text(original.into()).await; + assert!(outcome.allowed); + assert_eq!( + outcome.payload, + r#"{"type":"response.create","response":{"input":"[REDACTED]"}}"# + ); + assert!(outcome.invocations[0].transformed); + session + .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .await; + } + + #[tokio::test] + async fn openai_websocket_event_is_introspected_and_redacted() { + let service = OpenAiRedactionService::default(); + let observed_preflight = Arc::clone(&service.preflight); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(service)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + + let mut registration = external_registration(1024); + registration.grpc_endpoint = format!("http://{address}"); + let registry = MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) + .await + .expect("connect WebSocket middleware"); + let runner = ChainRunner::from_registry(registry); + let chain = [ChainEntry { + name: "openai-redactor".into(), + implementation: "local-guard-service".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }]; + let described = runner + .describe_websocket_chain(&chain) + .await + .expect("describe WebSocket chain"); + assert_eq!( + described[0].max_payload_bytes(), + 1024, + "operator max_payload_bytes must cap WebSocket messages" + ); + let preflight = runner + .preflight_websocket( + &chain, + WebSocketPreflightInput { + session_id: "ws-session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: vec!["realtime".into()], + }, + ) + .await + .expect("preflight"); + assert!(preflight.allowed); + let mut session = preflight.session.expect("middleware chose to inspect"); + assert!(session.start("realtime").await.allowed); + + let original = r#"{"type":"response.create","response":{"input":"customer-secret"}}"#; + let outcome = session.evaluate_text(original.into()).await; + assert!(outcome.allowed); + let transformed = outcome.payload; + assert!(transformed.contains("[REDACTED]")); + assert!(!transformed.contains("customer-secret")); + assert!(outcome.invocations[0].transformed); + + let observed = observed_preflight + .lock() + .expect("preflight lock") + .clone() + .expect("preflight observed"); + let target = observed.target.expect("preflight target"); + assert_eq!(target.scheme, "wss"); + assert_eq!(target.host, "api.openai.com"); + assert_eq!(target.port, 443); + assert_eq!(target.method, "GET"); + assert_eq!(target.path, "/v1/responses"); + assert!(target.query.is_empty()); + assert_eq!(observed.requested_subprotocols, ["realtime"]); + session + .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .await; + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join test middleware") + .expect("serve middleware"); + } + + #[tokio::test] + async fn fail_open_disables_broken_websocket_stage_for_later_messages() { + let service = OpenAiRedactionService { + close_on_first_message: true, + ..Default::default() + }; + let observed_preflight = Arc::clone(&service.preflight); + let message_count = Arc::clone(&service.messages); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(service)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + + let mut registration = external_registration(1024); + registration.grpc_endpoint = format!("http://{address}"); + let registry = MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) + .await + .expect("connect WebSocket middleware"); + let runner = ChainRunner::from_registry(registry); + let chain = [ChainEntry { + name: "openai-redactor".into(), + implementation: "local-guard-service".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }]; + let preflight = runner + .preflight_websocket( + &chain, + WebSocketPreflightInput { + session_id: "ws-session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), + scheme: "ws".into(), + host: "api.openai.com".into(), + port: 80, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + let mut session = preflight.session.expect("middleware chose to inspect"); + assert!(session.start("").await.allowed); + assert_eq!( + observed_preflight + .lock() + .expect("preflight lock") + .as_ref() + .expect("preflight observed") + .target + .as_ref() + .expect("preflight target") + .scheme, + "ws" + ); + + let first = session + .evaluate_text(r#"{"type":"response.create"}"#.into()) + .await; + assert!(first.allowed, "fail-open should bypass the broken stage"); + assert_eq!(first.invocations.len(), 1); + assert!(first.invocations[0].failed); + assert!(first.invocations[0].stage_disabled); + assert_eq!( + runner.registry.session_admission.available_permits(), + MAX_CONCURRENT_MIDDLEWARE_SESSIONS, + "the final disabled stage must release persistent session capacity" + ); + + let mut work = Vec::new(); + for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { + work.push( + runner + .reserve_middleware_work_admission() + .await + .expect("fill middleware work budget"), + ); + } + + let second = session + .evaluate_text(r#"{"type":"response.cancel"}"#.into()) + .now_or_never() + .expect("fully disabled session must bypass without waiting for work admission"); + assert!(second.allowed); + assert!( + second.invocations.is_empty(), + "disabled stage must not be called again in this session" + ); + assert_eq!(message_count.load(std::sync::atomic::Ordering::SeqCst), 1); + drop(work); + + session + .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .await; + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join test middleware") + .expect("serve middleware"); + } + + #[tokio::test] + async fn mixed_websocket_session_keeps_message_admission_while_one_stage_is_active() { + let broken = Arc::new(OpenAiRedactionService { + close_on_first_message: true, + ..Default::default() + }); + let mut endpoints = services(); + endpoints.push(in_process_endpoint(broken)); + let runner = ChainRunner::from_registry( + MiddlewareRegistry::connect_services(endpoints, Vec::new()) + .await + .expect("connect mixed middleware services"), + ); + let broken_entry = ChainEntry { + name: "best-effort-remote".into(), + implementation: "test/openai-websocket-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }; + let mut regex_entry = entry("required-regex", OnError::FailClosed); + regex_entry.order = 1; + let preflight = runner + .preflight_websocket( + &[broken_entry, regex_entry], + websocket_preflight_input("mixed-active"), + ) + .await + .expect("mixed preflight"); + let mut session = preflight.session.expect("both stages inspect"); + assert!(session.start("").await.allowed); + + let first = session + .evaluate_text(r#"{"input":"sk-ABCDEFGHIJKLMNOP"}"#.into()) + .await; + assert!(first.allowed); + assert!(first.invocations[0].stage_disabled); + assert_eq!( + first.invocations[1].outcome, + WebSocketInvocationOutcome::Allow + ); + + let mut work = Vec::new(); + for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { + work.push( + runner + .reserve_middleware_work_admission() + .await + .expect("fill middleware work budget"), + ); + } + assert!( + session.admit_message().now_or_never().is_none(), + "an active remaining stage must still wait for message work admission" + ); + drop(work); + + let second = session + .evaluate_text(r#"{"input":"sk-QRSTUVWXYZabcdef"}"#.into()) + .await; + assert!(second.allowed); + assert_eq!(second.invocations.len(), 1); + assert_eq!( + second.invocations[0].config_name, "required-regex", + "disabled stage must stay bypassed while the active stage continues" + ); + } + + #[tokio::test] + async fn websocket_admission_wait_queue_is_bounded() { + let runner = ChainRunner::default(); + let mut active = Vec::new(); + for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { + active.push( + runner + .reserve_middleware_work_admission() + .await + .expect("active admission"), + ); + } + + let mut waiters = Vec::new(); + for _ in 0..MAX_QUEUED_MIDDLEWARE_WORK { + let runner = runner.clone(); + waiters.push(tokio::spawn(async move { + runner.reserve_middleware_work().await + })); + } + while runner.registry.work_admission_waiters.available_permits() != 0 { + tokio::task::yield_now().await; + } + + let overflow = runner + .reserve_middleware_work() + .await + .expect("admission outcome"); + assert!(matches!( + overflow, + MiddlewareWorkAdmissionOutcome::QueueExhausted + )); + runner + .reserve_middleware_work_admission() + .await + .expect_err("WebSocket callers retain their existing failure path"); + + drop(active); + for waiter in waiters { + let admission = waiter + .await + .expect("waiter task") + .expect("queued admission after capacity is released"); + assert!(matches!( + admission, + MiddlewareWorkAdmissionOutcome::Admitted(_) + )); + } + assert_eq!( + runner.registry.work_admission.available_permits(), + MAX_CONCURRENT_MIDDLEWARE_WORK + ); + assert_eq!( + runner.registry.work_admission_waiters.available_permits(), + MAX_QUEUED_MIDDLEWARE_WORK + ); + + let mut recovered = Vec::new(); + for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { + recovered.push( + runner + .reserve_middleware_work_admission() + .await + .expect("recovered active admission"), + ); + } + assert_eq!(recovered.len(), MAX_CONCURRENT_MIDDLEWARE_WORK); + } + + #[tokio::test] + async fn websocket_queue_exhaustion_remains_a_protocol_failure() { + let runner = builtin_runner(); + let chain = [entry("regex-redactor", OnError::FailClosed)]; + let preflight = runner + .preflight_websocket(&chain, websocket_preflight_input("established")) + .await + .expect("initial preflight"); + let mut session = preflight.session.expect("built-in inspects session"); + assert!(session.start("").await.allowed); + + let mut active = Vec::new(); + for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { + active.push( + runner + .reserve_middleware_work_admission() + .await + .expect("fill active work"), + ); + } + let mut waiters = Vec::new(); + for _ in 0..MAX_QUEUED_MIDDLEWARE_WORK { + let runner = runner.clone(); + waiters.push(tokio::spawn(async move { + runner.reserve_middleware_work().await + })); + } + while runner.registry.work_admission_waiters.available_permits() != 0 { + tokio::task::yield_now().await; + } + + let preflight_overflow = runner + .preflight_websocket(&chain, websocket_preflight_input("preflight-overflow")) + .await; + assert!( + preflight_overflow.is_err(), + "preflight exhaustion remains an outer HTTP failure" + ); + session + .admit_message() + .await + .expect_err("established message exhaustion remains a typed termination input"); + + for waiter in waiters { + waiter.abort(); + } + drop(active); + } + + #[tokio::test] + async fn websocket_preflight_skip_removes_stage_without_message_calls() { + let (session_ends_tx, mut session_ends_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = OpenAiRedactionService { + skip: true, + session_ends: Some(session_ends_tx), + ..Default::default() + }; + let message_count = Arc::clone(&service.messages); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(service)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let mut registration = external_registration(1024); + registration.grpc_endpoint = format!("http://{address}"); + let runner = ChainRunner::from_registry( + MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) + .await + .expect("connect middleware"), + ); + let result = runner + .preflight_websocket( + &[ChainEntry { + name: "scope".into(), + implementation: "local-guard-service".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + WebSocketPreflightInput { + session_id: "session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + assert!(result.allowed); + assert!(result.session.is_none()); + assert_eq!( + result.invocations[0].outcome, + WebSocketInvocationOutcome::Skip + ); + assert_eq!(message_count.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + runner.registry.session_admission.available_permits(), + MAX_CONCURRENT_MIDDLEWARE_SESSIONS, + "all-skip preflight must not retain session capacity" + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), session_ends_rx.recv()) + .await + .expect("skipped stage must receive session_end"), + Some(openshell_core::proto::WebSocketSessionEndReason::StageSkipped) + ); + assert!( + session_ends_rx.try_recv().is_err(), + "a skipped stage receives at most one session_end" + ); + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware") + .expect("serve middleware"); + } + + #[tokio::test] + async fn websocket_session_budget_caps_idle_inspecting_sessions_and_releases_on_end() { + let runner = builtin_runner(); + let chain = [entry("regex-redactor", OnError::FailClosed)]; + let mut sessions = Vec::new(); + for index in 0..MAX_CONCURRENT_MIDDLEWARE_SESSIONS { + let preflight = runner + .preflight_websocket( + &chain, + websocket_preflight_input(format!("session-{index}")), + ) + .await + .expect("admit inspecting session"); + assert!(preflight.allowed); + sessions.push(preflight.session.expect("built-in inspects session")); + } + assert_eq!(runner.registry.session_admission.available_permits(), 0); + + let overflow = runner + .preflight_websocket(&chain, websocket_preflight_input("overflow")) + .await + .expect("capacity exhaustion is a typed preflight outcome"); + assert!(!overflow.allowed); + assert!(overflow.session.is_none()); + assert!(overflow.session_capacity_exhausted); + assert_eq!( + overflow.invocations[0].outcome, + WebSocketInvocationOutcome::FailClosed + ); + + sessions + .pop() + .expect("retained session") + .end(openshell_core::proto::WebSocketSessionEndReason::NormalClose) + .await; + assert_eq!(runner.registry.session_admission.available_permits(), 1); + + let replacement = runner + .preflight_websocket(&chain, websocket_preflight_input("replacement")) + .await + .expect("released session capacity is reusable"); + assert!(replacement.allowed); + assert!(replacement.session.is_some()); + } + + #[tokio::test] + async fn websocket_session_budget_survives_registry_replacement() { + let runner = builtin_runner(); + let chain = [entry("regex-redactor", OnError::FailClosed)]; + let mut sessions = Vec::new(); + for index in 0..MAX_CONCURRENT_MIDDLEWARE_SESSIONS { + let preflight = runner + .preflight_websocket( + &chain, + websocket_preflight_input(format!("old-generation-{index}")), + ) + .await + .expect("admit old-generation session"); + sessions.push(preflight.session.expect("built-in inspects session")); + } + + let replacement_registry = MiddlewareRegistry::connect_services(services(), Vec::new()) + .await + .expect("connect replacement registry"); + let replacement = runner.with_replacement_registry(replacement_registry); + let overflow = replacement + .preflight_websocket(&chain, websocket_preflight_input("new-generation-overflow")) + .await + .expect("capacity exhaustion is a typed preflight outcome"); + assert!(!overflow.allowed); + assert!(overflow.session_capacity_exhausted); + + sessions + .pop() + .expect("retained old-generation session") + .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .await; + let admitted = replacement + .preflight_websocket(&chain, websocket_preflight_input("new-generation-admitted")) + .await + .expect("released capacity is reusable after replacement"); + assert!(admitted.allowed); + assert!(admitted.session.is_some()); + } + + #[tokio::test] + async fn websocket_session_capacity_exhaustion_honors_mixed_on_error() { + let runner = builtin_runner(); + let mut held = Vec::new(); + for _ in 0..MAX_CONCURRENT_MIDDLEWARE_SESSIONS { + match runner.try_reserve_middleware_session() { + MiddlewareSessionAdmission::Admitted(admission) => held.push(admission), + MiddlewareSessionAdmission::AtCapacity => { + panic!("session budget exhausted before platform limit") + } + } + } + + let mut first = entry("best-effort-a", OnError::FailOpen); + first.order = 1; + let mut second = entry("best-effort-b", OnError::FailOpen); + second.order = 2; + let all_fail_open = runner + .preflight_websocket( + &[first.clone(), second.clone()], + websocket_preflight_input("all-fail-open"), + ) + .await + .expect("all-fail-open capacity outcome"); + assert!(all_fail_open.allowed); + assert!(all_fail_open.session.is_none()); + assert!(all_fail_open.session_capacity_exhausted); + assert!( + all_fail_open + .invocations + .iter() + .all(|invocation| invocation.outcome == WebSocketInvocationOutcome::FailOpen) + ); + + second.on_error = OnError::FailClosed; + let mixed = runner + .preflight_websocket(&[first, second], websocket_preflight_input("mixed")) + .await + .expect("mixed capacity outcome"); + assert!(!mixed.allowed); + assert!(mixed.session.is_none()); + assert!(mixed.session_capacity_exhausted); + assert_eq!( + mixed + .invocations + .iter() + .map(|invocation| invocation.outcome) + .collect::>(), + [ + WebSocketInvocationOutcome::FailOpen, + WebSocketInvocationOutcome::FailClosed, + ] + ); + drop(held); + } } diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 378dac3ec4..edc1e8066c 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -1,47 +1,126 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::time::Duration; - use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::middleware::{ + HttpRequestView, SupervisorMiddlewareEndpoint, WebSocketResponseStream, +}; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; -use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; use openshell_core::proto::{ HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, - ValidateConfigResponse, + ValidateConfigResponse, WebSocketSessionEvent, +}; +use openshell_extension_core::{ + BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, + connect_channel, }; -use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; +use std::sync::Arc; +use tonic::service::interceptor::InterceptedService; +use tonic::transport::Channel; use tonic::{Request, Response, Status}; use crate::MIDDLEWARE_GRPC_MESSAGE_BYTES; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +type ExtensionChannel = InterceptedService; + +/// Adapts the borrowed runtime request contract to the owned protobuf service +/// contract only when dispatch crosses a gRPC-shaped boundary. +#[derive(Clone)] +pub struct GrpcMiddlewareService { + service: Arc, +} + +impl GrpcMiddlewareService { + /// Connect an operator registration and wrap its generated gRPC client. + pub async fn connect( + registration_name: &str, + grpc_endpoint: &str, + tls_ca_cert_pem: &[u8], + bearer: Option, + ) -> Result { + Ok(Self { + service: Arc::new( + RemoteMiddlewareService::connect( + registration_name, + grpc_endpoint, + tls_ca_cert_pem, + bearer, + ) + .await?, + ), + }) + } + + /// Wrap a protobuf-shaped service used by transport-boundary tests. + #[cfg(test)] + pub fn from_service(service: Arc) -> Self { + Self { service } + } + + /// Forward a manifest request through the protobuf service contract. + pub async fn describe(&self) -> std::result::Result, Status> { + self.service.describe(Request::new(())).await + } + + /// Materialize the owned configuration request required by gRPC. + pub async fn validate_config( + &self, + middleware_name: &str, + config: &prost_types::Struct, + ) -> std::result::Result, Status> { + self.service + .validate_config(Request::new(ValidateConfigRequest { + config: Some(config.clone()), + middleware_name: middleware_name.to_string(), + })) + .await + } + + /// Materialize an owned protobuf evaluation immediately before transport. + pub async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> std::result::Result, Status> { + self.service + .evaluate_http_request(Request::new(HttpRequestEvaluation { + phase: request.phase() as i32, + context: Some(request.context().clone()), + config: Some(request.config().clone()), + target: Some(request.target().clone()), + headers: request.headers().to_vec(), + body: request.body().to_vec(), + middleware_name: request.middleware_name().to_string(), + })) + .await + } + + /// Open a remote WebSocket middleware stream through the gRPC adapter. + pub async fn open_websocket_session( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + self.service.open_websocket_session(receiver).await + } +} #[derive(Clone)] pub struct RemoteMiddlewareService { - client: SupervisorMiddlewareClient, + client: SupervisorMiddlewareClient, } impl RemoteMiddlewareService { - pub async fn connect(registration_name: &str, grpc_endpoint: &str) -> Result { - let mut endpoint = Endpoint::from_shared(grpc_endpoint.to_string()) - .into_diagnostic() - .wrap_err_with(|| { - format!( - "middleware registration '{registration_name}' has an invalid grpc_endpoint" - ) - })?; - if grpc_endpoint.starts_with("https://") { - endpoint = endpoint - .tls_config(ClientTlsConfig::new().with_enabled_roots()) - .into_diagnostic() - .wrap_err_with(|| { - format!("middleware registration '{registration_name}' could not configure TLS") - })?; + pub async fn connect( + registration_name: &str, + grpc_endpoint: &str, + tls_ca_cert_pem: &[u8], + bearer: Option, + ) -> Result { + let mut config = ExtensionChannelConfig::new(grpc_endpoint); + if !tls_ca_cert_pem.is_empty() { + config = config + .with_server_trust(ExtensionServerTrust::CustomCaPem(tls_ca_cert_pem.to_vec())); } - let channel = endpoint - .connect_timeout(CONNECT_TIMEOUT) - .connect() + let channel = connect_channel(&config) .await .into_diagnostic() .wrap_err_with(|| { @@ -49,6 +128,10 @@ impl RemoteMiddlewareService { "middleware registration '{registration_name}' could not connect to {grpc_endpoint}" ) })?; + let interceptor = + bearer.map_or_else(BearerTokenInterceptor::disabled, |slot| slot.interceptor()); + let channel = InterceptedService::new(channel, interceptor); + Ok(Self { client: SupervisorMiddlewareClient::new(channel) .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) @@ -58,7 +141,7 @@ impl RemoteMiddlewareService { } #[tonic::async_trait] -impl SupervisorMiddleware for RemoteMiddlewareService { +impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService { async fn describe( &self, request: Request<()>, @@ -82,4 +165,18 @@ impl SupervisorMiddleware for RemoteMiddlewareService { let mut client = self.client.clone(); client.evaluate_http_request(request).await } + + async fn open_websocket_session( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let mut client = self.client.clone(); + let responses = client + .evaluate_web_socket_session(Request::new(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) + .await? + .into_inner(); + Ok(Box::pin(responses)) + } } diff --git a/crates/openshell-supervisor-middleware/src/websocket.rs b/crates/openshell-supervisor-middleware/src/websocket.rs new file mode 100644 index 0000000000..e61460946a --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/websocket.rs @@ -0,0 +1,1447 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Forward-direction WebSocket middleware session runner. + +use std::collections::BTreeMap; +use std::time::Duration; + +use futures::{StreamExt, future::join_all}; +use prost::Message as _; +use tokio::sync::mpsc; +use tokio::time::Instant; + +use openshell_core::proto::{ + Decision, HttpRequestTarget, RequestContext, SupervisorMiddlewarePhase, WebSocketMessage, + WebSocketMessageResult, WebSocketPreflight, WebSocketPreflightAction, + WebSocketPreflightDecision, WebSocketSessionEnd, WebSocketSessionEndReason, + WebSocketSessionEvent, WebSocketSessionStart, web_socket_message, web_socket_message_result, + web_socket_session_event, web_socket_session_event_result, +}; + +use super::{ + ChainEntry, ChainRunner, DescribedChainEntry, MAX_MIDDLEWARE_CHAIN_TIMEOUT, + MAX_MIDDLEWARE_CONFIG_BYTES, MAX_MIDDLEWARE_CONTEXT_BYTES, MAX_MIDDLEWARE_FINDING_BYTES, + MAX_MIDDLEWARE_FINDINGS_PER_STAGE, MAX_MIDDLEWARE_METADATA_BYTES, + MAX_MIDDLEWARE_METADATA_ENTRIES, MAX_MIDDLEWARE_PAYLOAD_BYTES, + MAX_MIDDLEWARE_PREFLIGHT_TIMEOUT, MAX_MIDDLEWARE_REASON_BYTES, MIDDLEWARE_GRPC_MESSAGE_BYTES, + MiddlewareDenial, MiddlewareSessionAdmission, MiddlewareSessionPermit, MiddlewareWorkAdmission, + NamespacedFinding, OnError, is_stable_reason_code, middleware_denial_reason, +}; + +const STREAM_CHANNEL_CAPACITY: usize = 4; +const SESSION_END_TIMEOUT: Duration = Duration::from_millis(10); +const MAX_REQUESTED_SUBPROTOCOLS: usize = 32; +const MAX_SUBPROTOCOL_BYTES: usize = 4 * 1024; +const MAX_SELECTED_SUBPROTOCOL_BYTES: usize = 256; +#[derive(Debug, Clone)] +pub struct WebSocketPreflightInput { + pub session_id: String, + pub request_id: String, + pub sandbox_id: String, + pub sandbox_name: String, + pub workspace: String, + pub scheme: String, + pub host: String, + pub port: u16, + /// Raw request path without a query string. + pub path: String, + pub requested_subprotocols: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebSocketInvocationOutcome { + Inspect, + Skip, + Allow, + Deny, + FailOpen, + FailClosed, +} + +#[derive(Debug, Clone)] +pub struct WebSocketInvocation { + pub config_name: String, + pub implementation: String, + pub outcome: WebSocketInvocationOutcome, + pub sequence: Option, + pub original_size: usize, + pub replacement_size: Option, + pub transformed: bool, + pub failed: bool, + /// The stage stream became unusable and will be bypassed for the rest of + /// this session when its policy is `fail_open`. + pub stage_disabled: bool, + pub reason_code: Option, +} + +/// Coverage of a selected policy attachment that did not result in a +/// WebSocket middleware invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebSocketCoverageState { + /// The attached implementation did not advertise the exact + /// `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` binding. + BindingNotSelected, + /// The binding was selected, but the V1 relay does not expose this message + /// class to middleware. + UnsupportedMessageType, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebSocketMessageType { + Text, + Binary, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebSocketCoverage { + pub config_name: String, + pub implementation: String, + pub state: WebSocketCoverageState, + pub sequence: Option, + pub message_type: Option, + pub original_size: usize, +} + +pub struct WebSocketPreflightResult { + pub allowed: bool, + /// Typed terminal reason when preflight denied the upgrade. `None` means + /// the request may continue, including voluntary skip and fail-open. + pub terminal_reason: Option, + pub reason: String, + pub denial: Option, + pub session: Option, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, + pub coverage: Vec, + pub saturated: bool, + pub session_capacity_exhausted: bool, +} + +#[derive(Debug)] +pub struct WebSocketSessionStartOutcome { + pub allowed: bool, + /// Typed terminal reason when session start cannot continue. + pub terminal_reason: Option, + pub reason: String, + pub invocations: Vec, +} + +#[derive(Debug)] +pub struct WebSocketMessageOutcome { + pub allowed: bool, + pub reason: String, + pub payload: String, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, + pub denial: Option, + pub saturated: bool, + pub platform_oversize: bool, +} + +struct WebSocketStageTransport { + sender: mpsc::Sender, + responses: super::WebSocketResponseStream, +} + +impl WebSocketStageTransport { + async fn end(self, reason: WebSocketSessionEndReason) { + let _ = tokio::time::timeout(SESSION_END_TIMEOUT, self.end_inner(reason)).await; + } + + async fn end_inner(self, reason: WebSocketSessionEndReason) { + if self.sender.send(session_end_request(reason)).await.is_err() { + return; + } + self.drain().await; + } + + async fn drain(self) { + let Self { + sender, + mut responses, + } = self; + // Keep the response handle alive while half-closing the request stream. + // Dropping both handles together schedules an HTTP/2 CANCEL, which may + // discard the buffered session_end before the middleware receives it. + drop(sender); + while responses.next().await.is_some() {} + } + + fn end_now(self, reason: WebSocketSessionEndReason) { + if self.sender.try_send(session_end_request(reason)).is_err() { + return; + } + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + drop(runtime.spawn(async move { + let _ = tokio::time::timeout(SESSION_END_TIMEOUT, self.drain()).await; + })); + } + } +} + +struct WebSocketStage { + entry: DescribedChainEntry, + transport: Option, +} + +impl WebSocketStage { + fn is_active(&self) -> bool { + self.transport.is_some() + } + + async fn disable(&mut self) { + self.end(WebSocketSessionEndReason::MiddlewareFailure).await; + } + + async fn end(&mut self, reason: WebSocketSessionEndReason) { + if let Some(transport) = self.transport.take() { + transport.end(reason).await; + } + } +} + +pub struct WebSocketSession { + runner: ChainRunner, + stages: Vec, + next_sequence: u64, + session_admission: Option, +} + +/// Whether a WebSocket text message needs the shared short-lived work budget. +/// +/// A fully disabled fail-open session returns `Bypass` without touching the +/// work semaphore. Other parsed WebSocket features may continue processing the +/// original payload independently. +#[derive(Debug)] +pub enum WebSocketMessageAdmission { + Bypass, + Inspect(MiddlewareWorkAdmission), +} + +enum OpenStage { + Inspect(Box, PreflightStageOutcome), + Deny(Box, PreflightStageOutcome), + Skip(PreflightStageOutcome), + Failed(DescribedChainEntry, String), +} + +struct PreflightStageOutcome { + invocation: WebSocketInvocation, + findings: Vec, + metadata: Option<(String, BTreeMap)>, +} + +impl ChainRunner { + pub async fn preflight_websocket( + &self, + entries: &[ChainEntry], + input: WebSocketPreflightInput, + ) -> miette::Result { + if entries.is_empty() { + return Ok(empty_preflight_result()); + } + let description = self + .describe_chain_for( + entries, + openshell_core::proto::SupervisorMiddlewareOperation::WebsocketMessage, + SupervisorMiddlewarePhase::PreCredentials, + ) + .await?; + let coverage = description + .unbound + .iter() + .map(binding_not_selected_coverage) + .collect::>(); + let described = description.entries; + if described.is_empty() { + let mut result = empty_preflight_result(); + result.coverage = coverage; + return Ok(result); + } + validate_preflight_input(&input)?; + + // One permit covers the complete concurrent preflight fan-out. Permit + // wait is deliberate backpressure and is excluded from every deadline. + let preflight_work = self.reserve_middleware_work_admission().await?; + let saturated = preflight_work.saturated(); + let session_admission = match self.try_reserve_middleware_session() { + MiddlewareSessionAdmission::Admitted(admission) => admission, + MiddlewareSessionAdmission::AtCapacity => { + return Ok(session_capacity_exhausted(described, coverage, saturated)); + } + }; + let opened = join_all( + described + .into_iter() + .map(|entry| open_stage(entry, input.clone())), + ) + .await; + + let mut stages = Vec::new(); + let mut invocations = Vec::new(); + let mut findings = Vec::new(); + let mut metadata = BTreeMap::new(); + let mut denial = None; + let mut fail_closed_reason = None; + for result in opened { + match result { + OpenStage::Inspect(stage, outcome) => { + stages.push(*stage); + collect_preflight_outcome( + outcome, + &mut invocations, + &mut findings, + &mut metadata, + ); + } + OpenStage::Deny(stage, outcome) => { + denial.get_or_insert_with(|| MiddlewareDenial { + config_name: stage.entry.entry.name.clone(), + reason_code: outcome.invocation.reason_code.clone(), + }); + stages.push(*stage); + collect_preflight_outcome( + outcome, + &mut invocations, + &mut findings, + &mut metadata, + ); + } + OpenStage::Skip(outcome) => collect_preflight_outcome( + outcome, + &mut invocations, + &mut findings, + &mut metadata, + ), + OpenStage::Failed(entry, reason) => { + let invocation = failure_invocation(&entry, None, 0, &reason); + if entry.entry.on_error == OnError::FailClosed { + fail_closed_reason + .get_or_insert_with(|| format!("middleware_failed: {reason}")); + } + invocations.push(invocation); + } + } + } + + if let Some(denial) = denial { + end_stages(&mut stages, WebSocketSessionEndReason::MiddlewareDenial).await; + return Ok(WebSocketPreflightResult { + allowed: false, + terminal_reason: Some(WebSocketSessionEndReason::MiddlewareDenial), + reason: middleware_denial_reason( + &denial.config_name, + denial.reason_code.as_deref(), + ), + denial: Some(denial), + session: None, + findings, + metadata, + invocations, + coverage, + saturated, + session_capacity_exhausted: false, + }); + } + + if let Some(reason) = fail_closed_reason { + end_stages(&mut stages, WebSocketSessionEndReason::MiddlewareFailure).await; + return Ok(WebSocketPreflightResult { + allowed: false, + terminal_reason: Some(WebSocketSessionEndReason::MiddlewareFailure), + reason, + denial: None, + session: None, + findings, + metadata, + invocations, + coverage, + saturated, + session_capacity_exhausted: false, + }); + } + + if stages.is_empty() { + drop(session_admission); + return Ok(WebSocketPreflightResult { + allowed: true, + terminal_reason: None, + reason: String::new(), + denial: None, + session: None, + findings, + metadata, + invocations, + coverage, + saturated, + session_capacity_exhausted: false, + }); + } + + Ok(WebSocketPreflightResult { + allowed: true, + terminal_reason: None, + reason: String::new(), + denial: None, + session: Some(WebSocketSession { + runner: self.clone(), + stages, + next_sequence: 1, + session_admission: Some(session_admission), + }), + findings, + metadata, + invocations, + coverage, + saturated, + session_capacity_exhausted: false, + }) + } +} + +fn empty_preflight_result() -> WebSocketPreflightResult { + WebSocketPreflightResult { + allowed: true, + terminal_reason: None, + reason: String::new(), + denial: None, + session: None, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + coverage: Vec::new(), + saturated: false, + session_capacity_exhausted: false, + } +} + +fn session_capacity_exhausted( + described: Vec, + coverage: Vec, + saturated: bool, +) -> WebSocketPreflightResult { + let reason = "middleware_session_capacity_exhausted"; + let mut fail_closed = false; + let invocations = described + .iter() + .map(|entry| { + fail_closed |= entry.entry.on_error == OnError::FailClosed; + failure_invocation(entry, None, 0, reason) + }) + .collect(); + WebSocketPreflightResult { + allowed: !fail_closed, + terminal_reason: fail_closed.then_some(WebSocketSessionEndReason::MiddlewareFailure), + reason: if fail_closed { + format!("middleware_failed: {reason}") + } else { + String::new() + }, + denial: None, + session: None, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations, + coverage, + saturated, + session_capacity_exhausted: true, + } +} + +impl WebSocketSession { + fn has_active_stages(&self) -> bool { + self.stages.iter().any(WebSocketStage::is_active) + } + + fn reconcile_lifecycle(&mut self) { + if !self.has_active_stages() { + self.session_admission.take(); + } + } + + pub async fn admit_message(&mut self) -> miette::Result { + self.reconcile_lifecycle(); + if !self.has_active_stages() { + return Ok(WebSocketMessageAdmission::Bypass); + } + self.runner + .reserve_middleware_work_admission() + .await + .map(WebSocketMessageAdmission::Inspect) + } + + pub async fn start(&mut self, selected_subprotocol: &str) -> WebSocketSessionStartOutcome { + if selected_subprotocol.len() > MAX_SELECTED_SUBPROTOCOL_BYTES { + return WebSocketSessionStartOutcome { + allowed: false, + terminal_reason: Some(WebSocketSessionEndReason::MiddlewareFailure), + reason: "middleware_failed: selected_subprotocol_over_capacity".to_string(), + invocations: Vec::new(), + }; + } + let mut invocations = Vec::new(); + let mut fail_closed = None; + for stage in &mut self.stages { + let Some(transport) = stage.transport.as_mut() else { + continue; + }; + let request = WebSocketSessionEvent { + event: Some(web_socket_session_event::Event::SessionStart( + WebSocketSessionStart { + selected_subprotocol: selected_subprotocol.to_string(), + }, + )), + }; + let sent = + tokio::time::timeout(stage.entry.timeout, transport.sender.send(request)).await; + if !matches!(sent, Ok(Ok(()))) { + stage.disable().await; + let reason = "session_start_send_failed"; + let mut invocation = failure_invocation(&stage.entry, None, 0, reason); + invocation.stage_disabled = true; + if stage.entry.entry.on_error == OnError::FailClosed { + fail_closed.get_or_insert_with(|| format!("middleware_failed: {reason}")); + } + invocations.push(invocation); + } + } + self.reconcile_lifecycle(); + WebSocketSessionStartOutcome { + allowed: fail_closed.is_none(), + terminal_reason: fail_closed + .as_ref() + .map(|_| WebSocketSessionEndReason::MiddlewareFailure), + reason: fail_closed.unwrap_or_default(), + invocations, + } + } + + /// Record one logical message whose type the selected V1 WebSocket binding + /// cannot inspect. This is a coverage state, not a middleware failure: + /// `on_error` is not applied and active stages remain available for later + /// text messages. + pub fn observe_unsupported_message( + &mut self, + message_type: WebSocketMessageType, + original_size: usize, + ) -> Vec { + self.reconcile_lifecycle(); + if !self.has_active_stages() { + return Vec::new(); + } + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + self.stages + .iter() + .filter(|stage| stage.is_active()) + .map(|stage| WebSocketCoverage { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + state: WebSocketCoverageState::UnsupportedMessageType, + sequence: Some(sequence), + message_type: Some(message_type), + original_size, + }) + .collect() + } + + pub async fn evaluate_text(&mut self, payload: String) -> WebSocketMessageOutcome { + if payload.len() > MAX_MIDDLEWARE_PAYLOAD_BYTES { + return platform_oversize_outcome(payload); + } + match self.admit_message().await { + Ok(WebSocketMessageAdmission::Bypass) => bypassed_message_outcome(payload), + Ok(WebSocketMessageAdmission::Inspect(admission)) => { + self.evaluate_text_admitted(payload, admission).await + } + Err(_) => admission_failure_outcome(payload), + } + } + + pub async fn evaluate_text_admitted( + &mut self, + payload: String, + admission: MiddlewareWorkAdmission, + ) -> WebSocketMessageOutcome { + let outcome = self.evaluate_text_admitted_inner(payload, admission).await; + self.reconcile_lifecycle(); + outcome + } + + async fn evaluate_text_admitted_inner( + &mut self, + payload: String, + admission: MiddlewareWorkAdmission, + ) -> WebSocketMessageOutcome { + if payload.len() > MAX_MIDDLEWARE_PAYLOAD_BYTES { + return platform_oversize_outcome(payload); + } + + let saturated = admission.saturated(); + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + let chain_deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + let mut current = payload; + let mut findings = Vec::new(); + let mut metadata = BTreeMap::new(); + let mut invocations = Vec::new(); + + for stage in &mut self.stages { + if !stage.is_active() { + continue; + } + let original_size = current.len(); + if original_size > stage.entry.max_payload_bytes { + let reason = "request_message_over_capacity"; + let invocation = + failure_invocation(&stage.entry, Some(sequence), original_size, reason); + let fail_closed = stage.entry.entry.on_error == OnError::FailClosed; + invocations.push(invocation); + if fail_closed { + return denied_message_outcome( + current, + findings, + metadata, + invocations, + format!("middleware_failed: {reason}"), + None, + saturated, + ); + } + continue; + } + + let remaining = chain_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let reason = "middleware_chain_timeout"; + let mut invocation = + failure_invocation(&stage.entry, Some(sequence), original_size, reason); + let fail_closed = stage.entry.entry.on_error == OnError::FailClosed; + stage.disable().await; + invocation.stage_disabled = true; + invocations.push(invocation); + if fail_closed { + return denied_message_outcome( + current, + findings, + metadata, + invocations, + format!("middleware_failed: {reason}"), + None, + saturated, + ); + } + continue; + } + let stage_timeout = stage.entry.timeout.min(remaining); + let request = WebSocketSessionEvent { + event: Some(web_socket_session_event::Event::Message(WebSocketMessage { + sequence, + payload: Some(web_socket_message::Payload::Text(current.clone())), + })), + }; + let response = { + let transport = stage + .transport + .as_mut() + .expect("active WebSocket stage has transport"); + tokio::time::timeout(stage_timeout, async { + transport + .sender + .send(request) + .await + .map_err(|_| tonic::Status::unavailable("request stream closed"))?; + transport.responses.next().await.transpose() + }) + .await + }; + let result = match response { + Ok(Ok(Some(response))) => match response.result { + Some(web_socket_session_event_result::Result::MessageResult(result)) => result, + Some(web_socket_session_event_result::Result::PreflightDecision(_)) | None => { + if let Some(outcome) = handle_stage_failure( + stage, + sequence, + original_size, + "unexpected_websocket_response", + ¤t, + &findings, + &metadata, + &mut invocations, + saturated, + ) + .await + { + return outcome; + } + continue; + } + }, + Ok(Ok(None)) => { + if let Some(outcome) = handle_stage_failure( + stage, + sequence, + original_size, + "missing_message_result", + ¤t, + &findings, + &metadata, + &mut invocations, + saturated, + ) + .await + { + return outcome; + } + continue; + } + Ok(Err(error)) => { + let reason = stage.entry.service.as_ref().map_or_else( + || "binding_not_described".to_string(), + |service| service.diagnostic_policy.error_reason(&error), + ); + if let Some(outcome) = handle_stage_failure( + stage, + sequence, + original_size, + &reason, + ¤t, + &findings, + &metadata, + &mut invocations, + saturated, + ) + .await + { + return outcome; + } + continue; + } + Err(_) => { + if let Some(outcome) = handle_stage_failure( + stage, + sequence, + original_size, + "middleware_timeout", + ¤t, + &findings, + &metadata, + &mut invocations, + saturated, + ) + .await + { + return outcome; + } + continue; + } + }; + + let result = match validate_message_result( + result, + sequence, + WebSocketMessageType::Text, + stage.entry.max_payload_bytes, + ) { + Ok(result) => result, + Err(reason) => { + if let Some(outcome) = handle_stage_failure( + stage, + sequence, + original_size, + reason, + ¤t, + &findings, + &metadata, + &mut invocations, + saturated, + ) + .await + { + return outcome; + } + continue; + } + }; + + let decision = Decision::try_from(result.decision).expect("validated decision"); + let reason_code = (!result.reason_code.is_empty()).then(|| result.reason_code.clone()); + for finding in result.findings { + findings.push(NamespacedFinding { + middleware: stage.entry.entry.name.clone(), + finding, + }); + } + if !result.metadata.is_empty() { + metadata.insert( + stage.entry.entry.name.clone(), + result.metadata.into_iter().collect(), + ); + } + if decision == Decision::Deny { + let denial = MiddlewareDenial { + config_name: stage.entry.entry.name.clone(), + reason_code, + }; + invocations.push(success_invocation( + &stage.entry, + WebSocketInvocationOutcome::Deny, + sequence, + original_size, + None, + false, + denial.reason_code.clone(), + )); + return denied_message_outcome( + current, + findings, + metadata, + invocations, + middleware_denial_reason(&denial.config_name, denial.reason_code.as_deref()), + Some(denial), + saturated, + ); + } + + let replacement_size = result.replacement.as_ref().map(websocket_replacement_len); + let transformed = result.replacement.is_some(); + if let Some(web_socket_message_result::Replacement::Text(replacement)) = + result.replacement + { + current = replacement; + } + invocations.push(success_invocation( + &stage.entry, + WebSocketInvocationOutcome::Allow, + sequence, + original_size, + replacement_size, + transformed, + reason_code, + )); + } + + WebSocketMessageOutcome { + allowed: true, + reason: String::new(), + payload: current, + findings, + metadata, + invocations, + denial: None, + saturated, + platform_oversize: false, + } + } + + pub async fn end(mut self, reason: WebSocketSessionEndReason) { + end_stages(&mut self.stages, reason).await; + self.reconcile_lifecycle(); + } +} + +impl Drop for WebSocketSession { + fn drop(&mut self) { + end_stages_now(&mut self.stages, WebSocketSessionEndReason::Cancellation); + self.reconcile_lifecycle(); + } +} + +fn platform_oversize_outcome(payload: String) -> WebSocketMessageOutcome { + WebSocketMessageOutcome { + allowed: false, + reason: "websocket_message_over_platform_capacity".to_string(), + payload, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + denial: None, + saturated: false, + platform_oversize: true, + } +} + +fn admission_failure_outcome(payload: String) -> WebSocketMessageOutcome { + WebSocketMessageOutcome { + allowed: false, + reason: "middleware_admission_over_capacity".to_string(), + payload, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + denial: None, + saturated: true, + platform_oversize: false, + } +} + +fn bypassed_message_outcome(payload: String) -> WebSocketMessageOutcome { + WebSocketMessageOutcome { + allowed: true, + reason: String::new(), + payload, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + denial: None, + saturated: false, + platform_oversize: false, + } +} + +fn binding_not_selected_coverage(entry: &ChainEntry) -> WebSocketCoverage { + WebSocketCoverage { + config_name: entry.name.clone(), + implementation: entry.implementation.clone(), + state: WebSocketCoverageState::BindingNotSelected, + sequence: None, + message_type: None, + original_size: 0, + } +} + +fn preflight_stage_outcome( + entry: &DescribedChainEntry, + outcome: WebSocketInvocationOutcome, + decision: WebSocketPreflightDecision, +) -> PreflightStageOutcome { + let reason_code = (!decision.reason_code.is_empty()).then(|| decision.reason_code.clone()); + let findings = decision + .findings + .into_iter() + .map(|finding| NamespacedFinding { + middleware: entry.entry.name.clone(), + finding, + }) + .collect(); + let metadata = (!decision.metadata.is_empty()).then(|| { + ( + entry.entry.name.clone(), + decision.metadata.into_iter().collect(), + ) + }); + PreflightStageOutcome { + invocation: success_invocation(entry, outcome, 0, 0, None, false, reason_code), + findings, + metadata, + } +} + +fn collect_preflight_outcome( + outcome: PreflightStageOutcome, + invocations: &mut Vec, + findings: &mut Vec, + metadata: &mut BTreeMap>, +) { + invocations.push(outcome.invocation); + findings.extend(outcome.findings); + if let Some((middleware, values)) = outcome.metadata { + metadata.insert(middleware, values); + } +} + +async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) -> OpenStage { + let Some(service) = entry.service.as_ref() else { + return OpenStage::Failed(entry, "binding_not_described".into()); + }; + let dispatch = service.service.clone(); + let diagnostic_policy = service.diagnostic_policy; + let preflight = WebSocketPreflight { + session_id: input.session_id, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + context: Some(RequestContext { + request_id: input.request_id, + sandbox_id: input.sandbox_id, + sandbox_name: input.sandbox_name, + workspace: input.workspace, + originating_process: None, + }), + target: Some(HttpRequestTarget { + scheme: input.scheme, + host: input.host, + port: u32::from(input.port), + method: "GET".into(), + path: input.path, + query: String::new(), + }), + requested_subprotocols: input.requested_subprotocols, + middleware_name: entry.entry.implementation.clone(), + config: Some(entry.entry.config.clone()), + }; + if validate_preflight_envelope(&preflight).is_err() { + return OpenStage::Failed(entry, "preflight_envelope_over_capacity".into()); + } + + let timeout = entry.timeout.min(MAX_MIDDLEWARE_PREFLIGHT_TIMEOUT); + let opened = tokio::time::timeout(timeout, async { + let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + sender + .send(WebSocketSessionEvent { + event: Some(web_socket_session_event::Event::Preflight(preflight)), + }) + .await + .map_err(|_| tonic::Status::unavailable("request stream closed"))?; + let mut responses = dispatch.open_websocket_session(receiver).await?; + let response = responses.next().await.transpose()?; + Ok::<_, tonic::Status>((sender, responses, response)) + }) + .await; + + let (sender, responses, response) = match opened { + Ok(Ok(opened)) => opened, + Ok(Err(error)) => { + return OpenStage::Failed(entry, diagnostic_policy.error_reason(&error)); + } + Err(_) => return OpenStage::Failed(entry, "middleware_timeout".into()), + }; + let Some(response) = response else { + WebSocketStageTransport { sender, responses } + .end(WebSocketSessionEndReason::MiddlewareFailure) + .await; + return OpenStage::Failed(entry, "missing_preflight_decision".into()); + }; + let Some(web_socket_session_event_result::Result::PreflightDecision(decision)) = + response.result + else { + WebSocketStageTransport { sender, responses } + .end(WebSocketSessionEndReason::MiddlewareFailure) + .await; + return OpenStage::Failed(entry, "invalid_preflight_decision".into()); + }; + let decision = match validate_preflight_decision(decision) { + Ok(decision) => decision, + Err(reason) => { + WebSocketStageTransport { sender, responses } + .end(WebSocketSessionEndReason::MiddlewareFailure) + .await; + return OpenStage::Failed(entry, reason.into()); + } + }; + let action = + WebSocketPreflightAction::try_from(decision.action).expect("validated preflight action"); + match action { + WebSocketPreflightAction::Inspect => { + let outcome = + preflight_stage_outcome(&entry, WebSocketInvocationOutcome::Inspect, decision); + OpenStage::Inspect( + Box::new(WebSocketStage { + entry, + transport: Some(WebSocketStageTransport { sender, responses }), + }), + outcome, + ) + } + WebSocketPreflightAction::Deny => { + let outcome = + preflight_stage_outcome(&entry, WebSocketInvocationOutcome::Deny, decision); + OpenStage::Deny( + Box::new(WebSocketStage { + entry, + transport: Some(WebSocketStageTransport { sender, responses }), + }), + outcome, + ) + } + WebSocketPreflightAction::Skip => { + let outcome = + preflight_stage_outcome(&entry, WebSocketInvocationOutcome::Skip, decision); + WebSocketStageTransport { sender, responses } + .end(WebSocketSessionEndReason::StageSkipped) + .await; + OpenStage::Skip(outcome) + } + WebSocketPreflightAction::Unspecified => { + unreachable!("validated preflight action cannot be unspecified") + } + } +} + +fn validate_preflight_input(input: &WebSocketPreflightInput) -> miette::Result<()> { + if input.session_id.is_empty() || input.session_id.len() > 128 { + return Err(miette::miette!("invalid WebSocket middleware session id")); + } + if input.path.len() > super::MAX_MIDDLEWARE_TARGET_BYTES { + return Err(miette::miette!( + "WebSocket middleware preflight path exceeds platform capacity" + )); + } + if input.path.contains('?') { + return Err(miette::miette!( + "WebSocket middleware preflight path must not contain a query string" + )); + } + if input.requested_subprotocols.len() > MAX_REQUESTED_SUBPROTOCOLS + || input + .requested_subprotocols + .iter() + .map(String::len) + .sum::() + > MAX_SUBPROTOCOL_BYTES + { + return Err(miette::miette!( + "WebSocket middleware requested subprotocols exceed platform capacity" + )); + } + Ok(()) +} + +fn validate_preflight_envelope(preflight: &WebSocketPreflight) -> Result<(), &'static str> { + let Some(target) = preflight.target.as_ref() else { + return Err("preflight_target_missing"); + }; + if target.method != "GET" { + return Err("preflight_target_method_invalid"); + } + if !target.query.is_empty() || target.path.contains('?') { + return Err("preflight_target_query_present"); + } + if target.encoded_len() > super::MAX_MIDDLEWARE_TARGET_BYTES { + return Err("preflight_target_over_capacity"); + } + if preflight + .config + .as_ref() + .is_some_and(|config| config.encoded_len() > MAX_MIDDLEWARE_CONFIG_BYTES) + { + return Err("preflight_config_over_capacity"); + } + if preflight + .context + .as_ref() + .is_some_and(|context| context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES) + { + return Err("preflight_context_over_capacity"); + } + if preflight.encoded_len() > MIDDLEWARE_GRPC_MESSAGE_BYTES { + return Err("preflight_envelope_over_capacity"); + } + Ok(()) +} + +fn validate_preflight_decision( + decision: WebSocketPreflightDecision, +) -> Result { + if !matches!( + WebSocketPreflightAction::try_from(decision.action), + Ok(WebSocketPreflightAction::Inspect + | WebSocketPreflightAction::Skip + | WebSocketPreflightAction::Deny) + ) { + return Err("invalid_preflight_decision"); + } + if decision.reason.len() > MAX_MIDDLEWARE_REASON_BYTES { + return Err("response_reason_over_capacity"); + } + if !decision.reason_code.is_empty() && !is_stable_reason_code(&decision.reason_code) { + return Err("response_reason_code_invalid"); + } + if decision.findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE + || decision + .findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + { + return Err("response_findings_over_capacity"); + } + if decision.metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { + return Err("response_metadata_count_over_capacity"); + } + let metadata_bytes = decision + .metadata + .iter() + .fold(0usize, |total, (key, value)| { + total.saturating_add(key.len()).saturating_add(value.len()) + }); + if metadata_bytes > MAX_MIDDLEWARE_METADATA_BYTES { + return Err("response_metadata_bytes_over_capacity"); + } + if decision.encoded_len() > MIDDLEWARE_GRPC_MESSAGE_BYTES { + return Err("response_envelope_over_capacity"); + } + Ok(decision) +} + +fn validate_message_result( + result: WebSocketMessageResult, + sequence: u64, + message_type: WebSocketMessageType, + stage_limit: usize, +) -> Result { + if result.sequence != sequence { + return Err("message_result_sequence_mismatch"); + } + if !matches!( + Decision::try_from(result.decision), + Ok(Decision::Allow | Decision::Deny) + ) { + return Err("invalid_response_decision"); + } + if result.reason.len() > MAX_MIDDLEWARE_REASON_BYTES { + return Err("response_reason_over_capacity"); + } + if !result.reason_code.is_empty() && !is_stable_reason_code(&result.reason_code) { + return Err("response_reason_code_invalid"); + } + if let Some(replacement) = &result.replacement { + if !matches!( + (message_type, replacement), + ( + WebSocketMessageType::Text, + web_socket_message_result::Replacement::Text(_) + ) | ( + WebSocketMessageType::Binary, + web_socket_message_result::Replacement::Binary(_) + ) + ) { + return Err("replacement_type_mismatch"); + } + let replacement_len = websocket_replacement_len(replacement); + if replacement_len > MAX_MIDDLEWARE_PAYLOAD_BYTES { + return Err("response_message_over_platform_capacity"); + } + if replacement_len > stage_limit { + return Err("response_message_over_capacity"); + } + } + if result.findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE + || result + .findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + { + return Err("response_findings_over_capacity"); + } + if result.metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { + return Err("response_metadata_count_over_capacity"); + } + let metadata_bytes = result.metadata.iter().fold(0usize, |total, (key, value)| { + total.saturating_add(key.len()).saturating_add(value.len()) + }); + if metadata_bytes > MAX_MIDDLEWARE_METADATA_BYTES { + return Err("response_metadata_bytes_over_capacity"); + } + if result.encoded_len() > MIDDLEWARE_GRPC_MESSAGE_BYTES { + return Err("response_envelope_over_capacity"); + } + Ok(result) +} + +fn websocket_replacement_len(replacement: &web_socket_message_result::Replacement) -> usize { + match replacement { + web_socket_message_result::Replacement::Text(text) => text.len(), + web_socket_message_result::Replacement::Binary(binary) => binary.len(), + } +} + +#[allow(clippy::too_many_arguments)] +async fn handle_stage_failure( + stage: &mut WebSocketStage, + sequence: u64, + original_size: usize, + reason: &str, + current: &str, + findings: &[NamespacedFinding], + metadata: &BTreeMap>, + invocations: &mut Vec, + saturated: bool, +) -> Option { + stage.disable().await; + let mut invocation = failure_invocation(&stage.entry, Some(sequence), original_size, reason); + invocation.stage_disabled = true; + invocations.push(invocation); + (stage.entry.entry.on_error == OnError::FailClosed).then(|| { + denied_message_outcome( + current.to_owned(), + findings.to_vec(), + metadata.clone(), + invocations.clone(), + format!("middleware_failed: {reason}"), + None, + saturated, + ) + }) +} + +fn denied_message_outcome( + payload: String, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, + reason: String, + denial: Option, + saturated: bool, +) -> WebSocketMessageOutcome { + WebSocketMessageOutcome { + allowed: false, + reason, + payload, + findings, + metadata, + invocations, + denial, + saturated, + platform_oversize: false, + } +} + +fn success_invocation( + entry: &DescribedChainEntry, + outcome: WebSocketInvocationOutcome, + sequence: u64, + original_size: usize, + replacement_size: Option, + transformed: bool, + reason_code: Option, +) -> WebSocketInvocation { + WebSocketInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome, + sequence: (sequence != 0).then_some(sequence), + original_size, + replacement_size, + transformed, + failed: false, + stage_disabled: false, + reason_code, + } +} + +fn failure_invocation( + entry: &DescribedChainEntry, + sequence: Option, + original_size: usize, + _reason: &str, +) -> WebSocketInvocation { + let outcome = match entry.entry.on_error { + OnError::FailOpen => WebSocketInvocationOutcome::FailOpen, + OnError::FailClosed => WebSocketInvocationOutcome::FailClosed, + }; + WebSocketInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome, + sequence, + original_size, + replacement_size: None, + transformed: false, + failed: true, + stage_disabled: false, + reason_code: None, + } +} + +async fn end_stages(stages: &mut [WebSocketStage], reason: WebSocketSessionEndReason) { + for stage in stages { + stage.end(reason).await; + } +} + +fn end_stages_now(stages: &mut [WebSocketStage], reason: WebSocketSessionEndReason) { + for stage in stages { + if let Some(transport) = stage.transport.take() { + transport.end_now(reason); + } + } +} + +fn session_end_request(reason: WebSocketSessionEndReason) -> WebSocketSessionEvent { + WebSocketSessionEvent { + event: Some(web_socket_session_event::Event::SessionEnd( + WebSocketSessionEnd { + reason: reason as i32, + }, + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protobuf_rejects_invalid_utf8_text_payload() { + let encoded_text_with_invalid_utf8 = [0x12, 0x01, 0xff]; + assert!(WebSocketMessage::decode(encoded_text_with_invalid_utf8.as_slice()).is_err()); + } + + #[test] + fn replacement_presence_preserves_empty_text_and_rejects_type_changes() { + let empty_text = WebSocketMessageResult { + sequence: 7, + decision: Decision::Allow as i32, + replacement: Some(web_socket_message_result::Replacement::Text(String::new())), + ..Default::default() + }; + let validated = validate_message_result( + empty_text, + 7, + WebSocketMessageType::Text, + MAX_MIDDLEWARE_PAYLOAD_BYTES, + ) + .expect("empty text replacement must retain oneof presence"); + assert_eq!( + validated.replacement, + Some(web_socket_message_result::Replacement::Text(String::new())) + ); + + let binary_replacement = WebSocketMessageResult { + sequence: 7, + decision: Decision::Allow as i32, + replacement: Some(web_socket_message_result::Replacement::Binary(Vec::new())), + ..Default::default() + }; + assert_eq!( + validate_message_result( + binary_replacement, + 7, + WebSocketMessageType::Text, + MAX_MIDDLEWARE_PAYLOAD_BYTES, + ), + Err("replacement_type_mismatch") + ); + } + + #[tokio::test] + async fn disabling_stage_sends_middleware_failure_once() { + let (sender, mut requests) = mpsc::channel(1); + let mut stage = WebSocketStage { + entry: DescribedChainEntry { + entry: ChainEntry { + name: "best-effort".into(), + implementation: "test/middleware".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }, + service: None, + binding: None, + max_payload_bytes: 1024, + timeout: Duration::from_secs(1), + }, + transport: Some(WebSocketStageTransport { + sender, + responses: Box::pin(tokio_stream::empty()), + }), + }; + + stage.disable().await; + stage.disable().await; + + let event = requests.recv().await.expect("session end event"); + let Some(web_socket_session_event::Event::SessionEnd(end)) = event.event else { + panic!("disabled stage must receive session end"); + }; + assert_eq!( + WebSocketSessionEndReason::try_from(end.reason), + Ok(WebSocketSessionEndReason::MiddlewareFailure) + ); + assert!( + requests.try_recv().is_err(), + "disabled stage must receive at most one session end" + ); + } +} diff --git a/crates/openshell-supervisor-network/BUILD.bazel b/crates/openshell-supervisor-network/BUILD.bazel deleted file mode 100644 index fe3673a6ba..0000000000 --- a/crates/openshell-supervisor-network/BUILD.bazel +++ /dev/null @@ -1,83 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -filegroup( - name = "sandbox-policy-rego", - srcs = ["data/sandbox-policy.rego"], - visibility = ["//crates/openshell-sandbox:__pkg__"], -) - -rust_library( - name = "openshell-supervisor-network", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - compile_data = glob(["data/**/*"]), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-supervisor-network_test", - compile_data = glob(["testdata/**/*"]), - crate = ":openshell-supervisor-network", - deps = all_crate_deps(normal_dev = True), -) - -rust_test( - name = "system_inference_integration_test", - srcs = ["tests/system_inference.rs"], - aliases = aliases(), - crate_root = "tests/system_inference.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-supervisor-network"], -) - -rust_test( - name = "websocket_upgrade_integration_test", - srcs = ["tests/websocket_upgrade.rs"], - aliases = aliases(), - crate_root = "tests/websocket_upgrade.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-supervisor-network"], -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":accept_fd_exhaustion_integration_test", - ":openshell-supervisor-network", - ":openshell-supervisor-network_test", - ":sigv4_localstack_integration_test", - ":system_inference_integration_test", - ":websocket_upgrade_integration_test", - ], - visibility = ["//crates:__pkg__"], -) - -rust_test( - name = "accept_fd_exhaustion_integration_test", - srcs = ["tests/accept_fd_exhaustion.rs"], - aliases = aliases(), - crate_root = "tests/accept_fd_exhaustion.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ), -) - -rust_test( - name = "sigv4_localstack_integration_test", - srcs = ["tests/sigv4_localstack.rs"], - aliases = aliases(), - crate_root = "tests/sigv4_localstack.rs", - deps = all_crate_deps( - normal = True, - normal_dev = True, - ) + [":openshell-supervisor-network"], -) diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 3dead2b8be..34d9c32a47 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -11,7 +11,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", features = ["oauth"] } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-router = { path = "../openshell-router" } @@ -27,6 +27,7 @@ bytes = { workspace = true } flate2 = "1" glob = { workspace = true } hex = "0.4" +hickory-proto = "0.26.1" ipnet = "2" miette = { workspace = true } prost-types = { workspace = true } @@ -34,6 +35,7 @@ rcgen = { workspace = true } regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob"] } reqwest = { workspace = true } rustls = { workspace = true } +rustls-native-certs = { workspace = true } rustls-pemfile = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -47,16 +49,22 @@ tokio-rustls = { workspace = true } tower-mcp-types = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } -webpki-roots = { workspace = true } +webpki-roots = { workspace = true, optional = true } + +[features] +default = ["bundled-ca-roots"] +bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } tempfile = "3" tonic = { workspace = true } temp-env = "0.3" +tokio = { workspace = true, features = ["test-util"] } tokio-tungstenite = { workspace = true } futures = { workspace = true } tracing-subscriber = { workspace = true } +tokio-stream = { workspace = true, features = ["net"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 780080c54a..e3fa6d36b4 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -214,6 +214,39 @@ network_action := "allow" if { network_policy_for_request } +# --- Authoritative egress authorization snapshot --- +# +# Rust evaluates this rule once per admitted connection. Keeping the action, +# matched policy, endpoint metadata, and exact-host signal in one result makes +# them an atomic view of one policy generation. + +default _egress_matched_policy := "" + +_egress_matched_policy := matched_network_policy if { + matched_network_policy +} + +default _egress_deny_reason := "" + +_egress_deny_reason := deny_reason if { + network_action == "deny" +} + +default _egress_exact_declared_endpoint_host := false + +_egress_exact_declared_endpoint_host := true if { + exact_declared_endpoint_host +} + +egress_authorization := { + "action": network_action, + "deny_reason": _egress_deny_reason, + "matched_policy": _egress_matched_policy, + "endpoint_configs": _matching_endpoint_configs, + "matched_endpoints": _matching_endpoint_records, + "exact_declared_endpoint_host": _egress_exact_declared_endpoint_host, +} + # =========================================================================== # L7 request evaluation (queried per-request within a tunnel) # =========================================================================== @@ -864,10 +897,75 @@ _matching_endpoint_configs := [cfg | endpoint_has_extended_config(cfg) ] +# Full matched endpoint records are kept separate from the legacy +# endpoint-config list, which intentionally contains only connection/L7 +# metadata. The policy name and array index identify the endpoint within this +# policy generation while the complete endpoint preserves explicit protocol +# markers needed by later policy-DNS correlation. + +_policy_endpoint_records(policy_name, policy) := [record | + some endpoint_index, ep in policy.endpoints + endpoint_matches_request(ep, input.network) + record := { + "policy_name": policy_name, + "endpoint_index": endpoint_index, + "endpoint": ep, + } +] + +_matching_endpoint_records := [record | + some pname + _matching_policy_names[pname] + records := _policy_endpoint_records(pname, data.network_policies[pname]) + record := records[_] +] + +# Endpoints eligible for policy DNS are a policy-data snapshot, not an +# authorization decision. In particular, they do not depend on input.exec or +# grant access to any process. Only endpoints that explicitly opt into raw TCP +# and provide a resolvable host plus concrete ports are materialized. +policy_dns_eligible_endpoint_records := [record | + some policy_name, policy in data.network_policies + some endpoint_index, ep in policy.endpoints + lower(object.get(ep, "protocol", "")) == "tcp" + object.get(ep, "host", "") != "" + ports := object.get(ep, "ports", []) + count(ports) > 0 + every port in ports { + is_number(port) + port >= 1 + port <= 65535 + } + record := { + "policy_name": policy_name, + "endpoint_index": endpoint_index, + "endpoint": ep, + } +] + matched_endpoint_config := _matching_endpoint_configs[0] if { count(_matching_endpoint_configs) > 0 } +# --- Credential provenance view (credential gating only) --- +# Deliberately separate from `_matching_endpoint_configs`. The credential guard +# must also see L4-only endpoints, which carry no extended config. Widening +# `endpoint_has_extended_config` instead would let such an endpoint become +# element [0] of the shared list and shadow the TLS mode, SSRF allowlist, and +# L7 protocol of an inspected endpoint on the same host:port. +_policy_credential_guards(policy) := [ep | + some ep + ep := policy.endpoints[_] + endpoint_matches_request(ep, input.network) +] + +endpoint_credential_guards := [cfg | + some pname + _matching_policy_names[pname] + cfgs := _policy_credential_guards(data.network_policies[pname]) + cfg := cfgs[_] +] + # Expose middleware policy data to Rust. Selection and validation stay in Rust; # Rego does not evaluate middleware selectors. network_middlewares := object.get(data, "network_middlewares", {}) @@ -924,10 +1022,13 @@ endpoint_path_matches_request(ep, request) if { path_matches(request.path, path) } -# An endpoint has extended config if it specifies L7 protocol, allowed_ips, -# or an explicit tls mode (e.g. tls: skip). +# An endpoint has extended config if it specifies an L7 protocol, allowed_ips, +# or an explicit tls mode (e.g. tls: skip). Explicit protocol "tcp" is the +# authored spelling of plain L4 behavior and does not select an L7 config. endpoint_has_extended_config(ep) if { - ep.protocol + protocol := object.get(ep, "protocol", "") + protocol != "" + lower(protocol) != "tcp" } endpoint_has_extended_config(ep) if { diff --git a/crates/openshell-supervisor-network/src/l7/graphql.rs b/crates/openshell-supervisor-network/src/l7/graphql.rs index 2569bc68fa..994d101314 100644 --- a/crates/openshell-supervisor-network/src/l7/graphql.rs +++ b/crates/openshell-supervisor-network/src/l7/graphql.rs @@ -39,6 +39,30 @@ pub struct GraphqlHttpRequest { pub info: GraphqlRequestInfo, } +pub(crate) fn log_summary(info: &GraphqlRequestInfo) -> String { + if let Some(error) = &info.error { + return format!("graphql_error={error:?}"); + } + let operations = info.operations.iter().map(|operation| { + let name = operation.operation_name.as_deref().unwrap_or("-"); + let fields = if operation.fields.is_empty() { + "-".to_string() + } else { + operation.fields.join(",") + }; + let persisted = operation + .persisted_query_hash + .as_deref() + .or(operation.persisted_query_id.as_deref()) + .unwrap_or("-"); + format!( + "type={} name={} fields={} persisted={}", + operation.operation_type, name, fields, persisted + ) + }); + format!("graphql_ops={}", operations.collect::>().join(";")) +} + pub async fn parse_graphql_http_request( client: &mut C, max_body_bytes: usize, diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 28f91c3bb7..6305653f6a 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -8,7 +8,8 @@ use crate::opa::PolicyGenerationGuard; use miette::{Result, miette}; use openshell_ocsf::{ ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, - HttpActivityBuilder, HttpRequest, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, + ocsf_emit, }; use std::path::PathBuf; use tokio::io::{AsyncRead, AsyncWrite}; @@ -18,6 +19,11 @@ pub enum MiddlewareApplyResult { Denied { denial: Option, }, + /// The platform's shared active-work and waiter capacities were both full. + /// + /// This is platform load shedding, not a selected middleware-stage failure, + /// so callers must not apply a stage's `on_error` policy. + AdmissionExhausted, } /// How traffic a middleware chain can never inspect (h2c, non-HTTP TCP, @@ -83,6 +89,321 @@ pub fn emit_middleware_uninspectable(ctx: &L7EvalContext, detail: &str, denied: ocsf_emit!(event); } +pub fn emit_websocket_preflight_events( + ctx: &L7EvalContext, + outcome: &openshell_supervisor_middleware::WebSocketPreflightResult, +) { + emit_websocket_invocations(ctx, &outcome.invocations); + emit_websocket_coverage(ctx, &outcome.coverage); + for event in websocket_preflight_finding_events(outcome) { + ocsf_emit!(event); + } + if outcome.saturated { + emit_websocket_saturation(ctx); + } + if outcome.session_capacity_exhausted { + emit_middleware_session_capacity_exhausted(ctx); + } +} + +pub(super) fn emit_websocket_coverage( + ctx: &L7EvalContext, + coverage: &[openshell_supervisor_middleware::WebSocketCoverage], +) { + for event in websocket_coverage_events(ctx, coverage) { + ocsf_emit!(event); + } +} + +/// Build coverage events separately from the tracing pipeline so tests can +/// assert exact coverage telemetry without process-global callsite cache races. +pub(super) fn websocket_coverage_events( + ctx: &L7EvalContext, + coverage: &[openshell_supervisor_middleware::WebSocketCoverage], +) -> Vec { + coverage + .iter() + .map(|coverage| { + use openshell_supervisor_middleware::WebSocketCoverageState as State; + + let state = match coverage.state { + State::BindingNotSelected => "binding_not_selected", + State::UnsupportedMessageType => "unsupported_message_type", + }; + let sequence = coverage + .sequence + .map_or_else(|| "-".to_string(), |sequence| sequence.to_string()); + let message_type = match coverage.message_type { + Some(openshell_supervisor_middleware::WebSocketMessageType::Text) => "text", + Some(openshell_supervisor_middleware::WebSocketMessageType::Binary) => "binary", + None => "-", + }; + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .activity_name("WebSocket middleware coverage") + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "supervisor-middleware") + .unmapped("middleware_config", coverage.config_name.as_str()) + .unmapped( + "middleware_implementation", + coverage.implementation.as_str(), + ) + .unmapped("coverage_state", state) + .unmapped("websocket_message_type", message_type) + .unmapped("websocket_sequence", sequence.as_str()) + .unmapped("input_bytes", coverage.original_size) + .message(format!( + "WEBSOCKET_MIDDLEWARE_COVERAGE state={state} config={} implementation={} sequence={sequence} message_type={message_type} input_bytes={}", + coverage.config_name, coverage.implementation, coverage.original_size, + )) + .build() + }) + .collect() +} + +pub(super) fn emit_websocket_session_start_events( + ctx: &L7EvalContext, + outcome: &openshell_supervisor_middleware::WebSocketSessionStartOutcome, +) { + emit_websocket_invocations(ctx, &outcome.invocations); +} + +pub(super) fn emit_websocket_message_events( + ctx: &L7EvalContext, + outcome: &openshell_supervisor_middleware::WebSocketMessageOutcome, +) { + emit_websocket_invocations(ctx, &outcome.invocations); + if outcome.saturated { + emit_websocket_saturation(ctx); + } + for event in websocket_message_finding_events(outcome) { + ocsf_emit!(event); + } +} + +pub(super) fn websocket_preflight_finding_events( + outcome: &openshell_supervisor_middleware::WebSocketPreflightResult, +) -> Vec { + middleware_finding_events(&outcome.findings) +} + +pub(super) fn websocket_message_finding_events( + outcome: &openshell_supervisor_middleware::WebSocketMessageOutcome, +) -> Vec { + middleware_finding_events(&outcome.findings) +} + +fn middleware_finding_events( + findings: &[openshell_supervisor_middleware::NamespacedFinding], +) -> Vec { + findings + .iter() + .take(openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_FINDINGS) + .map(|finding| { + DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(match finding.finding.severity.as_str() { + "high" => SeverityId::High, + "low" => SeverityId::Low, + _ => SeverityId::Medium, + }) + .finding_info(FindingInfo::new( + &finding.finding.r#type, + &finding.finding.label, + )) + .evidence_pairs(&[ + ("middleware", &finding.middleware), + ("count", &finding.finding.count.to_string()), + ]) + .unmapped("middleware", finding.middleware.as_str()) + .unmapped("count", finding.finding.count) + .message(format!( + "Middleware finding {} count={}", + finding.finding.r#type, finding.finding.count + )) + .build() + }) + .collect() +} + +fn emit_websocket_invocations( + ctx: &L7EvalContext, + invocations: &[openshell_supervisor_middleware::WebSocketInvocation], +) { + for invocation in invocations { + use openshell_supervisor_middleware::WebSocketInvocationOutcome as Outcome; + let (action, disposition, severity, status, outcome_name) = match invocation.outcome { + Outcome::Inspect => ( + ActionId::Other, + DispositionId::Allowed, + SeverityId::Informational, + StatusId::Success, + "inspect", + ), + Outcome::Skip => ( + ActionId::Other, + DispositionId::Other, + SeverityId::Informational, + StatusId::Success, + "voluntary_skip", + ), + Outcome::Allow => ( + ActionId::Allowed, + DispositionId::Allowed, + SeverityId::Informational, + StatusId::Success, + "allow", + ), + Outcome::Deny => ( + ActionId::Denied, + DispositionId::Blocked, + SeverityId::Medium, + StatusId::Failure, + "deny", + ), + Outcome::FailOpen => ( + ActionId::Other, + DispositionId::Allowed, + SeverityId::Medium, + StatusId::Failure, + "fail_open", + ), + Outcome::FailClosed => ( + ActionId::Denied, + DispositionId::Blocked, + SeverityId::High, + StatusId::Failure, + "fail_closed", + ), + }; + let sequence = invocation + .sequence + .map_or_else(|| "-".to_string(), |sequence| sequence.to_string()); + let replacement_size = invocation + .replacement_size + .map_or_else(|| "-".to_string(), |size| size.to_string()); + let reason_code = invocation.reason_code.as_deref().unwrap_or("-"); + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .activity_name("WebSocket middleware") + .action(action) + .disposition(disposition) + .severity(severity) + .status(status) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "supervisor-middleware") + .message(format!( + "WEBSOCKET_MIDDLEWARE {outcome_name} config={} implementation={} sequence={sequence} input_bytes={} replacement_bytes={replacement_size} transformed={} reason_code={reason_code}", + invocation.config_name, + invocation.implementation, + invocation.original_size, + invocation.transformed, + )) + .build(); + ocsf_emit!(event); + if invocation.failed { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(if invocation.outcome == Outcome::FailClosed { + SeverityId::High + } else { + SeverityId::Medium + }) + .finding_info(FindingInfo::new( + "openshell.middleware.websocket_failure", + "WebSocket middleware processing failure", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("config", invocation.config_name.as_str()), + ("implementation", invocation.implementation.as_str()), + ("disposition", outcome_name), + ]) + .message("WebSocket middleware stage failed") + .build(); + ocsf_emit!(event); + } + if invocation.stage_disabled { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Medium) + .finding_info(FindingInfo::new( + "openshell.middleware.websocket_stage_disabled", + "WebSocket middleware stage disabled", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("config", invocation.config_name.as_str()), + ("implementation", invocation.implementation.as_str()), + ("disposition", outcome_name), + ]) + .message( + "WebSocket middleware stage stream became unusable and was disabled for this session", + ) + .build(); + ocsf_emit!(event); + } + } +} + +fn emit_websocket_saturation(ctx: &L7EvalContext) { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Medium) + .finding_info(FindingInfo::new( + "openshell.middleware.admission_saturated", + "Supervisor middleware admission saturated", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("operation", "websocket_message"), + ]) + .message("WebSocket middleware work waited for admission capacity") + .build(); + ocsf_emit!(event); +} + +fn emit_middleware_session_capacity_exhausted(ctx: &L7EvalContext) { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Medium) + .finding_info(FindingInfo::new( + "openshell.middleware.session_capacity_exhausted", + "Supervisor middleware session capacity exhausted", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("operation", "websocket_message"), + ]) + .message("Persistent middleware session admission was refused at process capacity") + .build(); + ocsf_emit!(event); +} + +fn middleware_admission_exhausted_event(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { + DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Medium) + .finding_info(FindingInfo::new( + "openshell.middleware.admission_exhausted", + "Supervisor middleware admission exhausted", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("operation", "http_request"), + ("disposition", "shed"), + ]) + .message("HTTP request shed because middleware work admission was exhausted") + .build() +} + +fn emit_middleware_admission_exhausted(ctx: &L7EvalContext) { + ocsf_emit!(middleware_admission_exhausted_event(ctx)); +} + /// Largest body-buffering limit across the entries that actually resolved to a /// registered binding. Buffering for the most capable stage lets every stage /// that can handle the body run; stages whose own limit is smaller are failed @@ -98,7 +419,7 @@ pub(super) fn middleware_chain_body_limit( chain .iter() .filter(|entry| entry.is_resolved()) - .map(openshell_supervisor_middleware::DescribedChainEntry::max_body_bytes) + .map(openshell_supervisor_middleware::DescribedChainEntry::max_payload_bytes) .max() } @@ -139,12 +460,27 @@ pub async fn apply_middleware_chain_for_scheme Some(admission), + openshell_supervisor_middleware::MiddlewareWorkAdmissionOutcome::QueueExhausted => { + emit_middleware_admission_exhausted(ctx); + return Ok(MiddlewareApplyResult::AdmissionExhausted); + } + } + }; let Some(max_body_bytes) = middleware_chain_body_limit(&chain) else { // No entry resolved to a registered binding, so nothing inspects the // body. Apply each entry's `on_error` policy without buffering (an // unresolved binding is handled before the body is read) and forward // the original request unchanged if the chain allows. let input = middleware_request_input( + openshell_ocsf::ctx::ctx(), scheme, &req, ctx, @@ -153,7 +489,14 @@ pub async fn apply_middleware_chain_for_scheme( + req: &crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + redacted_target: &str, +) -> Result<()> { + crate::l7::rest::send_middleware_unavailable_response( + req, + &ctx.policy_name, + client, + Some(redacted_target), + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), + ) + .await +} + +#[allow(clippy::too_many_arguments)] pub(super) fn middleware_request_input( + sandbox: &openshell_ocsf::SandboxContext, scheme: &str, req: &crate::l7::provider::L7Request, ctx: &L7EvalContext, @@ -249,7 +622,9 @@ pub(super) fn middleware_request_input( ) -> openshell_supervisor_middleware::HttpRequestInput { openshell_supervisor_middleware::HttpRequestInput { request_id: uuid::Uuid::new_v4().to_string(), - sandbox_id: openshell_ocsf::ctx::ctx().sandbox_id.clone(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), scheme: scheme.into(), host: ctx.host.clone(), port: ctx.port, @@ -499,34 +874,7 @@ pub(super) fn middleware_events( // Each stage and the selected chain are independently bounded by the // runner. Keep the derived chain-wide emission bound as defense in depth // for manually constructed or future outcome producers. - for finding in outcome - .findings - .iter() - .take(openshell_supervisor_middleware::MAX_MIDDLEWARE_CHAIN_FINDINGS) - { - let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(match finding.finding.severity.as_str() { - "high" => SeverityId::High, - "low" => SeverityId::Low, - _ => SeverityId::Medium, - }) - .finding_info(FindingInfo::new( - &finding.finding.r#type, - &finding.finding.label, - )) - .evidence_pairs(&[ - ("middleware", &finding.middleware), - ("count", &finding.finding.count.to_string()), - ]) - .unmapped("middleware", finding.middleware.as_str()) - .unmapped("count", finding.finding.count) - .message(format!( - "Middleware finding {} count={}", - finding.finding.r#type, finding.finding.count - )) - .build(); - events.push(event); - } + events.extend(middleware_finding_events(&outcome.findings)); events } @@ -544,10 +892,153 @@ fn emit_middleware_events( #[cfg(test)] mod tests { - use super::{safe_middleware_headers, send_middleware_rejection_response}; + use super::{ + middleware_admission_exhausted_event, safe_middleware_headers, + send_middleware_rejection_response, websocket_coverage_events, + websocket_message_finding_events, websocket_preflight_finding_events, + }; use crate::l7::relay::L7EvalContext; use tokio::io::AsyncReadExt; + #[test] + fn admission_exhaustion_event_contains_only_platform_context() { + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "api-policy".into(), + binary_path: "DO_NOT_LOG_BINARY".into(), + ..Default::default() + }; + + let serialized = serde_json::to_string(&middleware_admission_exhausted_event(&ctx)) + .expect("serialize admission event"); + assert!(serialized.contains("openshell.middleware.admission_exhausted")); + assert!(serialized.contains("api-policy")); + assert!(serialized.contains("api.example.test")); + assert!(serialized.contains("http_request")); + assert!(serialized.contains("shed")); + assert!(!serialized.contains("DO_NOT_LOG_BINARY")); + assert!(!serialized.contains("middleware_config")); + assert!(!serialized.contains("request_body")); + assert!(!serialized.contains("query")); + } + + #[test] + fn websocket_coverage_events_distinguish_selection_from_message_support() { + use openshell_ocsf::SeverityId; + use openshell_supervisor_middleware::{ + WebSocketCoverage, WebSocketCoverageState as State, WebSocketMessageType, + }; + + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "api-policy".into(), + ..Default::default() + }; + let events = websocket_coverage_events( + &ctx, + &[ + WebSocketCoverage { + config_name: "http-guard".into(), + implementation: "example/http-only".into(), + state: State::BindingNotSelected, + sequence: None, + message_type: None, + original_size: 0, + }, + WebSocketCoverage { + config_name: "text-guard".into(), + implementation: "example/websocket-text".into(), + state: State::UnsupportedMessageType, + sequence: Some(7), + message_type: Some(WebSocketMessageType::Binary), + original_size: 23, + }, + ], + ); + + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event.class_uid() == 4001)); + assert!( + events + .iter() + .all(|event| event.base().severity == SeverityId::Informational) + ); + let serialized = serde_json::to_string(&events).expect("serialize coverage events"); + assert!(serialized.contains("binding_not_selected")); + assert!(serialized.contains("unsupported_message_type")); + assert!(serialized.contains("\"websocket_message_type\":\"binary\"")); + assert!(serialized.contains("\"websocket_sequence\":\"7\"")); + assert!(serialized.contains("\"input_bytes\":23")); + assert!(!serialized.contains("fail_open")); + assert!(!serialized.contains("fail_closed")); + } + + #[test] + fn websocket_preflight_findings_match_http_mapping() { + let outcome = openshell_supervisor_middleware::WebSocketPreflightResult { + allowed: true, + terminal_reason: None, + reason: "allowed".into(), + denial: None, + session: None, + findings: vec![test_namespaced_finding()], + metadata: std::collections::BTreeMap::new(), + invocations: Vec::new(), + coverage: Vec::new(), + saturated: false, + session_capacity_exhausted: false, + }; + + assert_middleware_finding_mapping(websocket_preflight_finding_events(&outcome)); + } + + #[test] + fn websocket_message_findings_match_http_mapping() { + let outcome = openshell_supervisor_middleware::WebSocketMessageOutcome { + allowed: true, + reason: "allowed".into(), + payload: "hello".into(), + findings: vec![test_namespaced_finding()], + metadata: std::collections::BTreeMap::new(), + invocations: Vec::new(), + denial: None, + saturated: false, + platform_oversize: false, + }; + + assert_middleware_finding_mapping(websocket_message_finding_events(&outcome)); + } + + fn test_namespaced_finding() -> openshell_supervisor_middleware::NamespacedFinding { + openshell_supervisor_middleware::NamespacedFinding { + middleware: "content-guard".into(), + finding: openshell_core::proto::Finding { + r#type: "regex.api_key".into(), + label: "API key".into(), + count: 3, + confidence: "high".into(), + severity: "high".into(), + }, + } + } + + fn assert_middleware_finding_mapping(events: Vec) { + use openshell_ocsf::SeverityId; + + assert_eq!(events.len(), 1); + assert_eq!(events[0].class_uid(), 2004); + assert_eq!(events[0].base().severity, SeverityId::High); + let serialized = serde_json::to_string(&events[0]).expect("serialize finding event"); + assert!(serialized.contains("regex.api_key")); + assert!(serialized.contains("API key")); + assert!(serialized.contains("content-guard")); + assert!(serialized.contains("\"count\":3")); + assert!(serialized.contains("Middleware finding regex.api_key count=3")); + assert!(!serialized.contains("openshell.middleware.websocket_finding")); + } + #[tokio::test] async fn direct_denial_uses_middleware_response_without_service_text() { let ctx = L7EvalContext { @@ -594,6 +1085,53 @@ mod tests { assert!(!body.to_string().contains("secret-value")); } + #[test] + fn middleware_input_carries_real_sandbox_name() { + let sandbox = openshell_ocsf::SandboxContext { + sandbox_id: "sbx-123".into(), + sandbox_name: "nightly-build".into(), + container_image: String::new(), + hostname: "h".into(), + product_version: "0".into(), + proxy_ip: [127, 0, 0, 1].into(), + proxy_port: 3128, + }; + + let eval = L7EvalContext { + host: "api.example.test".into(), + port: 443, + workspace: "wrks-default".into(), + policy_name: "api-policy".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + secret_resolver: None, + ..Default::default() + }; + let req = crate::l7::provider::L7Request { + action: "POST".into(), + target: "/v1/messages".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + + let input = super::middleware_request_input( + &sandbox, + "https", + &req, + &eval, + Vec::new(), + Vec::new(), + String::new(), + Vec::new(), + ); + + assert_eq!(input.sandbox_name, "nightly-build"); + assert_eq!(input.sandbox_id, "sbx-123"); + assert_eq!(input.workspace, "wrks-default"); + } + #[tokio::test] async fn middleware_failure_uses_platform_response_without_policy_guidance() { let ctx = L7EvalContext { diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 64f4ae3741..70a980ba2d 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -22,7 +22,40 @@ pub(crate) mod token_grant_injection; pub(crate) mod websocket; pub use openshell_policy::L7Protocol; -use openshell_policy::{L7EndpointFields, validate_l7_endpoint_semantics}; +use openshell_policy::{ + L7EndpointFields, validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, +}; + +pub(crate) fn build_credential_endpoint_mismatch_finding( + policy_name: &str, + host: &str, + protocol: Option<&str>, + message: &str, +) -> openshell_ocsf::OcsfEvent { + use openshell_ocsf::{ + ActionId, ActivityId, DetectionFindingBuilder, DispositionId, FindingInfo, SeverityId, + }; + + let mut evidence = vec![("policy", policy_name), ("host", host)]; + if let Some(protocol) = protocol { + evidence.push(("protocol", protocol)); + } + evidence.push(("disposition", "denied")); + + DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .finding_info(FindingInfo::new( + "openshell.provider_credential.endpoint_mismatch", + "Provider credential used at an unauthorized endpoint", + )) + .evidence_pairs(&evidence) + .message(message) + .build() +} /// TLS handling mode for proxy connections. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -96,6 +129,10 @@ pub struct L7EndpointConfig { /// Opt-in rewrite of credential placeholders in supported textual REST /// request bodies before forwarding upstream. pub request_body_credential_rewrite: bool, + /// Explicit opt-in to credential-bearing traffic that cannot be inspected. + pub allow_uninspected_credentials: bool, + /// Internal gateway-derived credential provenance for this endpoint. + pub provider_credentialed: bool, /// When true, client-to-server GraphQL-over-WebSocket operation messages /// are classified with the same operation policy used by GraphQL-over-HTTP. pub websocket_graphql_policy: bool, @@ -179,6 +216,9 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { get_object_bool(val, "websocket_credential_rewrite").unwrap_or(false); let request_body_credential_rewrite = get_object_bool(val, "request_body_credential_rewrite").unwrap_or(false); + let allow_uninspected_credentials = + get_object_bool(val, "allow_uninspected_credentials").unwrap_or(false); + let provider_credentialed = get_object_bool(val, "provider_credentialed").unwrap_or(false); let websocket_graphql_policy = protocol == L7Protocol::Websocket && endpoint_has_graphql_policy(val); let graphql_max_body_bytes = get_object_u64(val, "graphql_max_body_bytes") @@ -234,6 +274,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { allow_encoded_slash, websocket_credential_rewrite, request_body_credential_rewrite, + allow_uninspected_credentials, + provider_credentialed, websocket_graphql_policy, credential_signing, signing_service, @@ -241,6 +283,24 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { }) } +pub(crate) fn emit_uninspected_credential_finding(host: &str, policy_name: &str, surface: &str) { + let event = openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(openshell_ocsf::SeverityId::High) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.credentials.traffic_uninspectable", + "Credential-bearing traffic cannot be inspected", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("host", host), + ("surface", surface), + ("disposition", "denied"), + ]) + .message("Uninspected credential-bearing traffic denied") + .build(); + openshell_ocsf::ocsf_emit!(event); +} + impl L7EndpointConfig { pub fn matches_path(&self, path: &str) -> bool { endpoint_path_matches(&self.path, path) @@ -253,19 +313,14 @@ impl L7EndpointConfig { self.path.chars().filter(|c| *c != '*').count() } } + + pub fn deny_uninspected_body_credentials(&self, has_resolver: bool) -> bool { + !self.allow_uninspected_credentials && (self.provider_credentialed || has_resolver) + } } pub fn endpoint_path_matches(pattern: &str, path: &str) -> bool { - if pattern.is_empty() || pattern == "**" || pattern == "/**" { - return true; - } - if pattern == path { - return true; - } - if let Some(prefix) = pattern.strip_suffix("/**") { - return path == prefix || path.starts_with(&format!("{prefix}/")); - } - glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path)) + openshell_core::endpoint_path::matches(pattern, path) } /// Parse the `tls` field from an endpoint config, independent of L7 protocol. @@ -280,6 +335,34 @@ pub fn parse_tls_mode(val: ®orus::Value) -> TlsMode { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct EndpointCredentialGuard { + pub provider_credentialed: bool, + pub allow_uninspected_credentials: bool, + pub has_l7_protocol: bool, + pub tls: TlsMode, +} + +impl EndpointCredentialGuard { + pub fn blocks_uninspected(self) -> bool { + self.provider_credentialed && !self.allow_uninspected_credentials + } + + pub fn blocks_connect(self) -> bool { + self.blocks_uninspected() && (!self.has_l7_protocol || self.tls == TlsMode::Skip) + } +} + +pub fn parse_endpoint_credential_guard(val: ®orus::Value) -> EndpointCredentialGuard { + EndpointCredentialGuard { + provider_credentialed: get_object_bool(val, "provider_credentialed").unwrap_or(false), + allow_uninspected_credentials: get_object_bool(val, "allow_uninspected_credentials") + .unwrap_or(false), + has_l7_protocol: get_object_str(val, "protocol").is_some(), + tls: parse_tls_mode(val), + } +} + /// Extract a bool value from a regorus object. Returns `None` when the key /// is absent or not a boolean. fn get_object_bool(val: ®orus::Value, key: &str) -> Option { @@ -919,6 +1002,69 @@ fn json_endpoint_has_graphql_policy(ep: &serde_json::Value) -> bool { /// /// Returns a list of errors and warnings. Errors should prevent sandbox startup; /// warnings are logged but don't block. +fn additional_l7_fields(ep: &serde_json::Value) -> Vec<&'static str> { + let mut fields = Vec::new(); + let non_empty_string = |name| { + ep.get(name) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.is_empty()) + }; + let enabled = |name| { + ep.get(name) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }; + + for (name, present) in [ + ("enforcement", non_empty_string("enforcement")), + ("path", non_empty_string("path")), + ("allow_encoded_slash", enabled("allow_encoded_slash")), + ( + "websocket_credential_rewrite", + enabled("websocket_credential_rewrite"), + ), + ( + "request_body_credential_rewrite", + enabled("request_body_credential_rewrite"), + ), + ("persisted_queries", non_empty_string("persisted_queries")), + ( + "graphql_persisted_queries", + ep.get("graphql_persisted_queries").is_some(), + ), + ( + "graphql_max_body_bytes", + ep.get("graphql_max_body_bytes").is_some(), + ), + ( + "json_rpc_max_body_bytes", + ep.get("json_rpc_max_body_bytes").is_some(), + ), + ( + "mcp.strict_tool_names", + ep.get("mcp_strict_tool_names").is_some(), + ), + ( + "mcp.allow_all_known_mcp_methods", + ep.get("mcp_allow_all_known_mcp_methods").is_some(), + ), + ("credential_signing", non_empty_string("credential_signing")), + ("signing_service", non_empty_string("signing_service")), + ("signing_region", non_empty_string("signing_region")), + ( + "credential_binding", + ep.get("credential_binding") + .is_some_and(|value| !value.is_null()), + ), + ] { + if present { + fields.push(name); + } + } + + fields +} + pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec) { let mut errors = Vec::new(); let mut warnings = Vec::new(); @@ -1051,6 +1197,10 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< for msg in validate_l7_endpoint_semantics(&l7_fields) { errors.push(format!("{loc}: {msg}")); } + for msg in validate_explicit_tcp_additional_fields(protocol, &additional_l7_fields(ep)) + { + errors.push(format!("{loc}: {msg}")); + } if let Some(mode) = ep.get("persisted_queries").and_then(|v| v.as_str()) && !mode.is_empty() @@ -1674,6 +1824,43 @@ mod tests { assert!(parse_l7_config(&val).is_none()); } + #[test] + fn parse_endpoint_credential_guard_handles_l4_and_opt_in() { + let guarded = regorus::Value::from_json_str( + r#"{"host":"api.example.com","ports":[443],"provider_credentialed":true}"#, + ) + .unwrap(); + let guard = parse_endpoint_credential_guard(&guarded); + assert!(guard.blocks_connect()); + + let opted_in = regorus::Value::from_json_str( + r#"{"host":"api.example.com","ports":[443],"provider_credentialed":true,"allow_uninspected_credentials":true}"#, + ) + .unwrap(); + assert!(!parse_endpoint_credential_guard(&opted_in).blocks_connect()); + } + + #[test] + fn generic_resolver_enables_rest_body_backstop_without_provider_marker() { + let val = regorus::Value::from_json_str( + r#"{"protocol":"rest","host":"api.example.com","ports":[443]}"#, + ) + .unwrap(); + let config = parse_l7_config(&val).unwrap(); + assert!(!config.deny_uninspected_body_credentials(false)); + assert!(config.deny_uninspected_body_credentials(true)); + + let opted_in = regorus::Value::from_json_str( + r#"{"protocol":"rest","host":"api.example.com","ports":[443],"allow_uninspected_credentials":true}"#, + ) + .unwrap(); + assert!( + !parse_l7_config(&opted_in) + .unwrap() + .deny_uninspected_body_credentials(true) + ); + } + #[test] fn parse_l7_config_allow_encoded_slash_defaults_false() { let val = regorus::Value::from_json_str( @@ -1797,6 +1984,63 @@ mod tests { ); } + #[test] + fn validate_explicit_tcp_rejects_additional_l7_field_families() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "database.example.com", + "port": 5432, + "protocol": "tcp", + "enforcement": "enforce", + "path": "/query", + "allow_encoded_slash": true, + "websocket_credential_rewrite": true, + "request_body_credential_rewrite": true, + "persisted_queries": "allow_registered", + "graphql_persisted_queries": {}, + "graphql_max_body_bytes": 1024, + "json_rpc_max_body_bytes": 1024, + "mcp_strict_tool_names": false, + "mcp_allow_all_known_mcp_methods": false, + "credential_signing": "sigv4", + "signing_service": "rds", + "signing_region": "us-west-2", + "credential_binding": {"provider": "database"} + }], + "binaries": [] + } + } + }); + + let (errors, _) = validate_l7_policies(&data); + let tcp_error = errors + .iter() + .find(|error| error.contains("protocol tcp does not support L7-only fields")) + .expect("explicit TCP should reject additional L7 fields"); + + for field in [ + "enforcement", + "path", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "json_rpc_max_body_bytes", + "mcp.strict_tool_names", + "mcp.allow_all_known_mcp_methods", + "credential_signing", + "signing_service", + "signing_region", + "credential_binding", + ] { + assert!(tcp_error.contains(field), "missing {field}: {tcp_error}"); + } + } + #[test] fn validate_request_body_credential_rewrite_warns_unless_rest() { let data = serde_json::json!({ diff --git a/crates/openshell-supervisor-network/src/l7/path.rs b/crates/openshell-supervisor-network/src/l7/path.rs index db2e4e9847..078248f058 100644 --- a/crates/openshell-supervisor-network/src/l7/path.rs +++ b/crates/openshell-supervisor-network/src/l7/path.rs @@ -22,7 +22,11 @@ //! request-target, raw non-ASCII bytes, and paths that cannot be parsed //! as origin-form. //! - Strip trailing `;params` from each segment by default (Tomcat-class -//! `;jsessionid` ACL-bypass mitigation). +//! `;jsessionid` ACL-bypass mitigation). Stripping happens *before* +//! dot-segment resolution so that `..;` cannot evade the traversal guard +//! and then revert to `..` on the way out. +//! - Reject any `.`/`..` that survives to the end of canonicalization. The +//! policy engine trusts this function to have removed them. //! - Reject `%2F` (encoded slash) inside a segment by default. Operators //! can opt in per-endpoint for APIs that rely on encoded slashes in //! slugs. @@ -44,6 +48,8 @@ pub enum CanonicalizeError { NonAscii, #[error("request-target's `..` segment would escape the path root")] TraversalAboveRoot, + #[error("request-target still contains a `.`/`..` segment after canonicalization")] + ResidualDotSegment, #[error("request-target exceeds the configured maximum length")] PathTooLong, #[error("request-target is not a valid origin-form path")] @@ -135,13 +141,24 @@ pub fn canonicalize_request_target( None => (target, None), }; - // 4. Handle absolute-form by stripping scheme://authority. - let raw_path = path_part.find("://").map_or(path_part, |idx| { - let after_scheme = &path_part[idx + 3..]; - after_scheme - .find('/') - .map_or("/", |slash| &after_scheme[slash..]) - }); + // 4. Handle absolute-form by stripping the URI authority. Origin-form + // targets may legitimately embed `://` in a path segment, so only a URI + // with a scheme is absolute-form. + let absolute_form_uri = path_part + .parse::() + .ok() + .filter(|uri| uri.scheme().is_some()); + let raw_path = absolute_form_uri + .as_ref() + .map(http::Uri::path) + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| { + if absolute_form_uri.is_some() { + "/" + } else { + path_part + } + }); // 5. Empty is equivalent to "/". let raw_path = if raw_path.is_empty() { "/" } else { raw_path }; @@ -161,10 +178,32 @@ pub fn canonicalize_request_target( // with a real path separator. let decoded = percent_decode_with_sentinel(raw_path.as_bytes(), opts.allow_encoded_slash)?; - // 9. Split on literal `/` and resolve dot-segments. + // 9. Split on literal `/`, then strip `;params` *before* resolving + // dot-segments. Stripping afterwards would let a `..;` segment slip + // past the traversal guard (it is not byte-equal to `..`) and then + // revert to a bare `..` during reconstruction. let segments = split_path_segments(&decoded); + let segments: Vec<&[u8]> = if opts.strip_path_parameters { + segments.into_iter().map(strip_path_parameters).collect() + } else { + segments + }; let resolved = resolve_dot_segments(segments)?; + // 9b. Defense in depth: no `.`/`..` may survive canonicalization. The + // policy engine trusts this function to have removed them, so a + // residual dot-segment is always a bug or an attack. This also + // catches a `..` hidden behind a `%2F` sentinel on endpoints that + // opted into `allow_encoded_slash`, where the sentinel keeps the + // dot-segment inside its segment and out of `resolve_dot_segments`. + for seg in &resolved { + for part in seg.split(|&b| b == ENCODED_SLASH_SENTINEL) { + if part == b".." || part == b"." { + return Err(CanonicalizeError::ResidualDotSegment); + } + } + } + // 10. Reconstruct. Strip `;params` per segment if requested; re-encode // any byte that must be percent-encoded in the pchar set. let canonical = build_canonical_path(&resolved, decoded.last().copied() == Some(b'/'), *opts); @@ -179,6 +218,20 @@ pub fn canonicalize_request_target( )) } +/// Report whether a canonical path carries a `%2F` that survived +/// canonicalization. +/// +/// Callers that canonicalize before they know which endpoint config applies +/// use this to re-check the result against the config that actually matched. +/// +/// The test is exact: [`build_canonical_path`] emits the literal `%2F` only +/// for the encoded-slash sentinel, and percent-encodes any other `%` byte as +/// `%25`, so no `%2F` substring can reach the output by another route. +#[must_use] +pub fn canonical_path_has_encoded_slash(canonical_path: &str) -> bool { + canonical_path.contains("%2F") +} + // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- @@ -227,6 +280,13 @@ fn percent_decode_with_sentinel( Ok(out) } +/// Drop a trailing `;params` suffix from a single path segment. +fn strip_path_parameters(seg: &[u8]) -> &[u8] { + seg.iter() + .position(|&b| b == b';') + .map_or(seg, |pos| &seg[..pos]) +} + fn split_path_segments(decoded: &[u8]) -> Vec<&[u8]> { // decoded is guaranteed to start with `/`. Skip the leading `/` and // split on subsequent `/` bytes. The sentinel byte for encoded slashes @@ -275,10 +335,7 @@ fn build_canonical_path( out.push('/'); } let trimmed: &[u8] = if opts.strip_path_parameters { - match seg.iter().position(|&b| b == b';') { - Some(pos) => &seg[..pos], - None => seg, - } + strip_path_parameters(seg) } else { seg }; @@ -443,6 +500,20 @@ mod tests { assert_eq!(canon("http://host:443/foo").unwrap(), "/foo"); } + #[test] + fn origin_form_with_embedded_url_is_not_stripped_as_absolute_form() { + let (path, query) = canonicalize_request_target( + "/fetch/http://example.test?next=http://other.test", + &CanonicalizeOptions::default(), + ) + .expect("origin-form target with embedded URLs must be accepted"); + // Repeated slashes are canonicalized everywhere, but the embedded + // URL must remain part of the origin-form path rather than being + // treated as a new absolute-form authority. + assert_eq!(path.path, "/fetch/http:/example.test"); + assert_eq!(query.as_deref(), Some("next=http://other.test")); + } + #[test] fn legitimate_percent_encoded_bytes_round_trip() { assert_eq!( @@ -557,4 +628,124 @@ mod tests { fn regression_dot_slash_dotdot() { assert_eq!(canon("/public/./../secret").unwrap(), "/secret"); } + + // --------------------------------------------------------------------- + // A `;params` suffix on the dot segment itself must not let the segment + // slip past the traversal guard. Stripping used to run after dot-segment + // resolution, so `..;` was not byte-equal to `..` when the guard looked + // at it, and reverted to a bare `..` during reconstruction — handing the + // policy engine and the upstream a path that still escaped its prefix. + // --------------------------------------------------------------------- + + #[test] + fn regression_dotdot_with_path_parameter_is_resolved_not_smuggled() { + assert_eq!(canon("/public/..;/secret").unwrap(), "/secret"); + assert_eq!(canon("/public/..;x/secret").unwrap(), "/secret"); + assert_eq!( + canon("/public/..;jsessionid=xyz/secret").unwrap(), + "/secret" + ); + assert_eq!(canon("/public/.;/secret").unwrap(), "/public/secret"); + assert_eq!(canon("/public/.;x/secret").unwrap(), "/public/secret"); + } + + #[test] + fn regression_percent_encoded_dotdot_with_path_parameter() { + // `%2e%2e;` and `..%3B` both decode to `..;` before segmentation. + assert_eq!(canon("/public/%2e%2e;/secret").unwrap(), "/secret"); + assert_eq!(canon("/public/..%3B/secret").unwrap(), "/secret"); + assert_eq!(canon("/public/..%3b/secret").unwrap(), "/secret"); + } + + #[test] + fn regression_chained_dotdot_with_path_parameters_hits_root_guard() { + assert_eq!( + canon("/public/..;/..;/secret"), + Err(CanonicalizeError::TraversalAboveRoot) + ); + assert_eq!( + canon("/api/v1/..;/..;/..;/admin/keys"), + Err(CanonicalizeError::TraversalAboveRoot) + ); + assert_eq!(canon("/..;"), Err(CanonicalizeError::TraversalAboveRoot)); + } + + #[test] + fn regression_dotdot_behind_encoded_slash_is_rejected_when_opted_in() { + // With `allow_encoded_slash`, the `%2F` sentinel keeps the dot-segment + // inside its segment, so `resolve_dot_segments` never sees it. The + // residual guard is what closes this. + let opts = CanonicalizeOptions { + allow_encoded_slash: true, + ..CanonicalizeOptions::default() + }; + assert_eq!( + canon_with("/public/..%2fsecret", opts), + Err(CanonicalizeError::ResidualDotSegment) + ); + assert_eq!( + canon_with("/public/..%2f..%2fsecret", opts), + Err(CanonicalizeError::ResidualDotSegment) + ); + assert_eq!( + canon_with("/public/.%2fsecret", opts), + Err(CanonicalizeError::ResidualDotSegment) + ); + // A legitimate encoded-slash slug still round-trips. + assert_eq!( + canon_with("/repos/group%2fproject/issues", opts).unwrap(), + "/repos/group%2Fproject/issues" + ); + } + + #[test] + fn encoded_slash_detection_on_canonical_paths_is_exact() { + let opts = CanonicalizeOptions { + allow_encoded_slash: true, + ..CanonicalizeOptions::default() + }; + + // A surviving sentinel is detected. + let slug = canon_with("/repos/group%2fproject/issues", opts).unwrap(); + assert_eq!(slug, "/repos/group%2Fproject/issues"); + assert!(canonical_path_has_encoded_slash(&slug)); + + // Ordinary paths are not. + assert!(!canonical_path_has_encoded_slash( + &canon("/public/secret").unwrap() + )); + + // A literal `%` in the input is re-emitted as `%25`, so it cannot + // fabricate a `%2F` substring — including when the input spells out + // `%252F`, which decodes to the three characters `%`, `2`, `F`. + let escaped = canon("/a/%252F/b").unwrap(); + assert_eq!(escaped, "/a/%252F/b"); + assert!(!canonical_path_has_encoded_slash(&escaped)); + + let percent = canon("/a/100%25/b").unwrap(); + assert_eq!(percent, "/a/100%25/b"); + assert!(!canonical_path_has_encoded_slash(&percent)); + } + + #[test] + fn canonical_output_never_contains_dot_segments() { + // The contract the policy engine relies on: whatever comes back is + // free of `.`/`..`, so rego never has to defend against them. + for target in [ + "/public/..;/secret", + "/public/.;/secret", + "/public/%2e%2e;/secret", + "/a;jsessionid=xyz/b", + "/public//../secret", + "/a/b/..", + "/a/b/.", + ] { + if let Ok(path) = canon(target) { + assert!( + !path.split('/').any(|seg| seg == ".." || seg == "."), + "{target} canonicalized to {path}, which still has a dot-segment" + ); + } + } + } } diff --git a/crates/openshell-supervisor-network/src/l7/provider.rs b/crates/openshell-supervisor-network/src/l7/provider.rs index 864d94ad26..3a51bd1d8c 100644 --- a/crates/openshell-supervisor-network/src/l7/provider.rs +++ b/crates/openshell-supervisor-network/src/l7/provider.rs @@ -30,6 +30,7 @@ pub enum RelayOutcome { Upgraded { overflow: Vec, websocket_permessage_deflate: bool, + websocket_subprotocol: Option, }, } diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index fa2eab4ad7..2697fedb3c 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -24,8 +24,9 @@ use miette::{IntoDiagnostic, Result, miette}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::secrets::{self, SecretResolver}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, - NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, + HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, + ocsf_emit, }; #[cfg(test)] use std::collections::BTreeMap; @@ -34,12 +35,18 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, warn}; /// Context for L7 request policy evaluation. +#[derive(Clone)] #[cfg_attr(test, derive(Default))] pub struct L7EvalContext { /// Host from the CONNECT request. pub host: String, /// Port from the CONNECT request. pub port: u16, + /// Workspace the sandbox belongs to, learned from `GetSandboxConfigResponse`. + pub workspace: String, + /// Default authority port for the inspected HTTP transport (80 for + /// plaintext, 443 after TLS termination). + pub(crate) request_default_port: Option, /// Matched policy name from L4 evaluation. pub policy_name: String, /// Binary path (for cross-layer Rego evaluation). @@ -50,6 +57,13 @@ pub struct L7EvalContext { pub cmdline_paths: Vec, /// Supervisor-only placeholder resolver for outbound headers. pub(crate) secret_resolver: Option>, + /// Live provider state used to scope static credentials to each request. + pub(crate) provider_credentials: + Option, + /// Provider credential revision captured atomically with the request-scoped + /// resolver. Used to reject a request if credentials change again before + /// its first upstream write. + pub(crate) provider_credential_revision: Option, /// Anonymous activity counter channel. pub(crate) activity_tx: Option, /// Dynamic credentials (token grants) keyed by endpoint-bound provider metadata. @@ -67,22 +81,256 @@ pub struct L7EvalContext { pub(crate) agent_proposals: openshell_core::proposals::AgentProposals, } +fn request_default_port(ctx: &L7EvalContext) -> Option { + ctx.request_default_port +} + +fn scoped_context_for_request( + ctx: &L7EvalContext, + request: &crate::l7::provider::L7Request, +) -> Option { + let mut scoped = ctx.clone(); + if matches!( + crate::l7::rest::request_authority(&request.raw_header, request_default_port(ctx)), + Ok(None) + ) { + // HTTP/1.0 permits an origin-form request without Host. Such requests + // remain compatible, but an absent authority cannot authorize static + // credential use. Clearing the resolver makes any placeholder or + // signing attempt fail closed before an upstream write. + scoped.secret_resolver = None; + scoped.provider_credential_revision = None; + return Some(scoped); + } + let credentials = ctx.provider_credentials.as_ref()?; + let (resolver, revision) = + credentials.resolver_for_endpoint_with_revision(&ctx.host, ctx.port, &request.target); + scoped.secret_resolver = resolver; + scoped.provider_credential_revision = Some(revision); + Some(scoped) +} + +fn credential_generation_guard( + ctx: &L7EvalContext, +) -> Option> { + Some(crate::l7::rest::CredentialGenerationGuard::new( + ctx.provider_credentials.as_ref()?, + ctx.provider_credential_revision?, + )) +} + +fn request_authority_matches_endpoint( + request: &crate::l7::provider::L7Request, + ctx: &L7EvalContext, +) -> bool { + let authority = + match crate::l7::rest::request_authority(&request.raw_header, request_default_port(ctx)) { + Ok(Some(authority)) => authority, + Ok(None) => { + return std::str::from_utf8(&request.raw_header) + .is_ok_and(|request| !secrets::contains_reserved_credential_marker(request)); + } + Err(_) => return false, + }; + let request_host = normalized_endpoint_host(authority.authority.host()); + let endpoint_host = normalized_endpoint_host(&ctx.host); + request_host.eq_ignore_ascii_case(endpoint_host) && authority.effective_port == ctx.port +} + +fn normalized_endpoint_host(host: &str) -> &str { + host.trim() + .trim_start_matches('[') + .trim_end_matches(']') + .trim_end_matches('.') +} + +async fn reject_request_authority_mismatch(client: &mut W, ctx: &L7EvalContext) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let body = r#"{"error":"request_authority_mismatch","message":"HTTP request authority does not match the authorized tunnel endpoint"}"#; + let response = format!( + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + + ocsf_emit!(build_request_authority_mismatch_event(ctx)); + ocsf_emit!(build_request_authority_mismatch_finding(ctx)); + Ok(()) +} + +fn build_request_authority_mismatch_event(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "request-authority") + .message(format!( + "HTTP request authority does not match authorized tunnel endpoint {}:{}", + ctx.host, ctx.port + )) + .status_detail("request_authority_mismatch") + .build() +} + +fn build_request_authority_mismatch_finding(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { + DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .finding_info(FindingInfo::new( + "openshell.http.request_authority_mismatch", + "HTTP request authority does not match the authorized tunnel endpoint", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("disposition", "denied"), + ]) + .message("HTTP request authority mismatch; request denied") + .build() +} + +fn build_credential_resolution_event( + ctx: &L7EvalContext, + endpoint_mismatch: bool, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(if endpoint_mismatch { + SeverityId::High + } else { + SeverityId::Medium + }) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "credential-binding") + .message(if endpoint_mismatch { + format!( + "Credential use denied: credential is not authorized for {}:{}", + ctx.host, ctx.port + ) + } else { + format!( + "Credential use denied: credential is unavailable for {}:{}", + ctx.host, ctx.port + ) + }) + .status_detail(if endpoint_mismatch { + "credential_endpoint_mismatch" + } else { + "credential_unavailable" + }) + .build() +} + +fn build_credential_endpoint_mismatch_finding(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { + crate::l7::build_credential_endpoint_mismatch_finding( + &ctx.policy_name, + &ctx.host, + None, + "Provider credential endpoint binding mismatch; request denied", + ) +} + +pub(crate) async fn reject_credential_resolution( + client: &mut W, + ctx: &L7EvalContext, + error: &secrets::UnresolvedPlaceholderError, +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let endpoint_mismatch = error.is_endpoint_mismatch(); + let status = if endpoint_mismatch { + "403 Forbidden" + } else { + "500 Internal Server Error" + }; + let body = if endpoint_mismatch { + r#"{"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"}"# + } else { + r#"{"error":"credential_unavailable","message":"Credential placeholder could not be resolved"}"# + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + + ocsf_emit!(build_credential_resolution_event(ctx, endpoint_mismatch)); + + if endpoint_mismatch { + ocsf_emit!(build_credential_endpoint_mismatch_finding(ctx)); + } + Ok(()) +} + +async fn relay_http_request_with_credential_rejection( + request: &crate::l7::provider::L7Request, + client: &mut C, + upstream: &mut U, + options: crate::l7::rest::RelayRequestOptions<'_>, + ctx: &L7EvalContext, +) -> Result> +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + match crate::l7::rest::relay_http_request_with_options_guarded( + request, client, upstream, options, + ) + .await + { + Ok(outcome) => Ok(Some(outcome)), + Err(report) => { + if let Some(error) = report.downcast_ref::() { + reject_credential_resolution(client, ctx, error).await?; + Ok(None) + } else { + Err(report) + } + } + } +} + #[derive(Default)] pub(crate) struct UpgradeRelayOptions<'a> { pub(crate) websocket_request: bool, pub(crate) websocket: WebSocketUpgradeBehavior, + pub(crate) assembly_budget: Option, pub(crate) secret_resolver: Option>, + pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, pub(crate) engine: Option<&'a TunnelPolicyEngine>, pub(crate) ctx: Option<&'a L7EvalContext>, pub(crate) enforcement: EnforcementMode, pub(crate) target: String, pub(crate) query_params: std::collections::HashMap>, pub(crate) policy_name: String, + pub(crate) middleware_session: Option, + pub(crate) selected_subprotocol: Option, } #[derive(Default)] pub(crate) struct WebSocketUpgradeBehavior { pub(crate) credential_rewrite: bool, + pub(crate) deny_uninspected_credentials: bool, pub(crate) message_policy: WebSocketMessagePolicy, pub(crate) permessage_deflate: bool, } @@ -298,13 +546,26 @@ where return Ok(()); } }; + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } - let Some(config) = select_l7_config_for_path(configs, &req.target) else { + let route_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); + } + }; + let Some(config) = select_l7_config_for_path(configs, &route_target) else { + let reason = "no L7 endpoint path matched request"; + emit_l7_request_log(ctx, &req.action, &route_target, "deny", "l7", reason, None); crate::l7::rest::RestProvider::default() .deny_with_redacted_target( &req, &ctx.policy_name, - "no L7 endpoint path matched request", + reason, client, None, Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), @@ -312,7 +573,32 @@ where .await?; return Ok(()); }; - + // The request was canonicalized before the matching config was known, + // so `allow_encoded_slash` was taken permissively across every config + // on this host:port. Re-check it against the config that actually + // matched: the opt-in is per-endpoint, and one endpoint enabling it + // must not loosen parsing for the others. + // Check `req.target`, not `route_target`: redaction percent-decodes any + // segment holding a credential placeholder and re-inserts the redacted + // form without re-encoding, so a `%2F` sharing that segment becomes a + // literal `/` and would escape this check. + if !config.allow_encoded_slash + && crate::l7::path::canonical_path_has_encoded_slash(&req.target) + { + let detail = "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint"; + emit_parse_rejection(ctx, detail, engine_type_for_protocol(config.protocol)); + crate::l7::rest::RestProvider::default() + .deny_with_redacted_target( + &req, + &ctx.policy_name, + detail, + client, + None, + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), + ) + .await?; + return Ok(()); + } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); } @@ -387,24 +673,12 @@ where return Ok(()); } - let (eval_target, redacted_target) = if let Some(ref resolver) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, resolver) { - Ok(result) => (result.resolved, result.redacted), - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - (req.target.clone(), req.target.clone()) }; let request_info = L7RequestInfo { @@ -447,35 +721,28 @@ where (false, EnforcementMode::Audit) => "audit", (false, EnforcementMode::Enforce) => "deny", }; - let engine_type = match config.protocol { - L7Protocol::Graphql => "l7-graphql", - L7Protocol::Websocket => "l7-websocket", - L7Protocol::JsonRpc => "l7-jsonrpc", - L7Protocol::Mcp => "l7-mcp", - L7Protocol::Rest | L7Protocol::Sql => "l7", - }; + let engine_type = engine_type_for_protocol(config.protocol); let protocol_summary = l7_protocol_log_summary(graphql_info.as_ref(), jsonrpc_info.as_ref()); emit_l7_request_log( ctx, - &request_info, + &request_info.action, &redacted_target, decision_str, engine_type, &reason, - &protocol_summary, + protocol_summary.as_deref(), ); - let _ = &eval_target; - if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let websocket_chain = websocket_request.then(|| chain.clone()); // Route selection resolved `config` per request, so re-check the // body against that protocol's policy after every transforming // stage (a no-op for REST and websocket, whose policy inputs the // chain cannot mutate). let validate = transformed_body_validator(config, &engine, ctx, &request_info); - let req = match apply_middleware_chain( + let middleware_result = apply_middleware_chain( req, client, ctx, @@ -484,8 +751,8 @@ where engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), ) - .await? - { + .await; + let req = match middleware_result? { MiddlewareApplyResult::Allowed(request) => request, MiddlewareApplyResult::Denied { denial, .. } => { let denied_request = crate::l7::provider::L7Request { @@ -505,31 +772,125 @@ where .await?; return Ok(()); } + MiddlewareApplyResult::AdmissionExhausted => { + let unavailable_request = crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + crate::l7::middleware::send_middleware_admission_exhausted_response( + &unavailable_request, + client, + ctx, + &redacted_target, + ) + .await?; + return Ok(()); + } + }; + let scoped_ctx = scoped_context_for_request(ctx, &req); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + let mut middleware_session = if let Some(chain) = websocket_chain.as_deref() { + let preflight = websocket_middleware_preflight( + &req, + chain, + engine.middleware_runner(), + ctx, + "wss", + ) + .await; + let preflight = match preflight { + Ok(preflight) => preflight, + Err(error) => { + warn!(error = %error, "WebSocket middleware preflight failed"); + write_bad_gateway_response(client).await?; + return Ok(()); + } + }; + crate::l7::middleware::emit_websocket_preflight_events(ctx, &preflight); + if preflight.terminal_reason.is_some() { + crate::l7::middleware::send_middleware_rejection_response( + &req, + client, + ctx, + preflight.denial.as_ref(), + &redacted_target, + ) + .await?; + return Ok(()); + } + preflight.session + } else { + None }; - let outcome = crate::l7::rest::relay_http_request_with_options_guarded( + let outcome_result = relay_http_request_with_credential_rejection( &req, client, upstream, crate::l7::rest::RelayRequestOptions { resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), generation_guard: Some(engine.generation_guard()), - websocket_extensions: websocket_extension_mode(config), + websocket_extensions: websocket_extension_mode( + config, + middleware_session.is_some(), + ), request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, + deny_uninspected_credentials: config + .deny_uninspected_body_credentials(ctx.secret_resolver.is_some()), credential_signing: config.credential_signing, signing_service: &config.signing_service, signing_region: &config.signing_region, host: &ctx.host, port: ctx.port, }, + ctx, + ) + .await; + let outcome_result = match outcome_result { + Ok(Some(outcome)) => Ok(outcome), + Ok(None) => { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .await; + } + return Ok(()); + } + Err(error) => Err(error), + }; + let outcome = finalize_websocket_pre_upgrade( + &mut middleware_session, + engine.generation_guard(), + &ctx.host, + ctx.port, + &ctx.policy_name, + outcome_result, ) .await?; match outcome { - RelayOutcome::Reusable => {} - RelayOutcome::Consumed => return Ok(()), + RelayOutcome::Reusable => { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .await; + } + } + RelayOutcome::Consumed => { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .await; + } + return Ok(()); + } RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, + websocket_subprotocol, } => { let mut options = upgrade_options( config, @@ -540,6 +901,8 @@ where Some(&engine), ); options.websocket.permessage_deflate = websocket_permessage_deflate; + options.middleware_session = middleware_session.take(); + options.selected_subprotocol = websocket_subprotocol; return handle_upgrade( client, upstream, overflow, &ctx.host, ctx.port, options, ) @@ -574,13 +937,35 @@ fn select_l7_config_for_path<'a>( fn emit_l7_request_log( ctx: &L7EvalContext, - request_info: &L7RequestInfo, + action: &str, redacted_target: &str, decision_str: &str, engine_type: &str, reason: &str, - protocol_summary: &str, + protocol_summary: Option<&str>, ) { + let event = build_l7_request_event( + ctx, + action, + redacted_target, + decision_str, + engine_type, + reason, + protocol_summary, + ); + ocsf_emit!(event); + emit_activity(ctx, decision_str == "deny", "l7_policy"); +} + +fn build_l7_request_event( + ctx: &L7EvalContext, + action: &str, + redacted_target: &str, + decision_str: &str, + engine_type: &str, + reason: &str, + protocol_summary: Option<&str>, +) -> openshell_ocsf::OcsfEvent { let (action_id, disposition_id, severity) = match decision_str { "deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium), "allow" | "audit" => ( @@ -594,43 +979,44 @@ fn emit_l7_request_log( SeverityId::Informational, ), }; - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let protocol_suffix = + protocol_summary.map_or_else(String::new, |summary| format!(" {summary}")); + let message = format!( + "L7_REQUEST {decision_str} {action} {}:{}{}{protocol_suffix} reason={reason}", + ctx.host, ctx.port, redacted_target, + ); + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(action_id) .disposition(disposition_id) .severity(severity) .http_request(HttpRequest::new( - &request_info.action, + action, OcsfUrl::new("http", &ctx.host, redacted_target, ctx.port), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, engine_type) - .message(format!( - "L7_REQUEST {decision_str} {} {}:{}{}{} reason={}", - request_info.action, ctx.host, ctx.port, redacted_target, protocol_summary, reason, - )) - .build(); - ocsf_emit!(event); - emit_activity(ctx, decision_str == "deny", "l7_policy"); + .message(message) + .build() } fn l7_protocol_log_summary( graphql_info: Option<&crate::l7::graphql::GraphqlRequestInfo>, jsonrpc_info: Option<&crate::l7::jsonrpc::JsonRpcRequestInfo>, -) -> String { +) -> Option { if let Some(info) = graphql_info { - return format!(" {}", graphql_log_summary(info)); + return Some(crate::l7::graphql::log_summary(info)); } if let Some(info) = jsonrpc_info { - return format!( - " rule_methods={} tools={}", + return Some(format!( + "rule_methods={} tools={}", rule_method_names_for_log(info), tool_names_for_log(info) - ); + )); } - String::new() + None } fn emit_activity(ctx: &L7EvalContext, denied: bool, deny_group: &'static str) { @@ -639,6 +1025,55 @@ fn emit_activity(ctx: &L7EvalContext, denied: bool, deny_group: &'static str) { } } +pub(crate) async fn websocket_middleware_preflight( + req: &crate::l7::provider::L7Request, + chain: &[openshell_supervisor_middleware::ChainEntry], + runner: &openshell_supervisor_middleware::ChainRunner, + ctx: &L7EvalContext, + scheme: &str, +) -> Result { + let header_end = req + .raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(req.raw_header.len(), |position| position + 4); + let requested_subprotocols = + crate::l7::rest::websocket_requested_subprotocols(&req.raw_header[..header_end])?; + let input = websocket_preflight_input( + openshell_ocsf::ctx::ctx(), + ctx, + req, + scheme, + requested_subprotocols, + ); + runner.preflight_websocket(chain, input).await +} + +/// Build the WebSocket preflight input from the sandbox and evaluation +/// contexts. Kept separate from `websocket_middleware_preflight` (and taking an +/// explicit `SandboxContext`) so the identifier copy is unit-testable with a +/// real sandbox name, mirroring `middleware_request_input` on the HTTP path. +fn websocket_preflight_input( + sandbox: &openshell_ocsf::SandboxContext, + ctx: &L7EvalContext, + req: &crate::l7::provider::L7Request, + scheme: &str, + requested_subprotocols: Vec, +) -> openshell_supervisor_middleware::WebSocketPreflightInput { + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: uuid::Uuid::new_v4().to_string(), + request_id: uuid::Uuid::new_v4().to_string(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), + scheme: scheme.to_string(), + host: ctx.host.clone(), + port: ctx.port, + path: req.target.clone(), + requested_subprotocols, + } +} + /// Handle an upgraded connection (101 Switching Protocols). /// /// Forwards any overflow bytes from the upgrade response to the client, then @@ -650,16 +1085,38 @@ pub(crate) async fn handle_upgrade( overflow: Vec, host: &str, port: u16, - options: UpgradeRelayOptions<'_>, + mut options: UpgradeRelayOptions<'_>, ) -> Result<()> where C: AsyncRead + AsyncWrite + Unpin + Send, U: AsyncRead + AsyncWrite + Unpin + Send, { + ensure_upgrade_generation_current(client, upstream, host, port, &mut options).await?; + let start_terminal_reason = if let Some(session) = options.middleware_session.as_mut() { + let start = session + .start(options.selected_subprotocol.as_deref().unwrap_or_default()) + .await; + if let Some(ctx) = options.ctx { + crate::l7::middleware::emit_websocket_session_start_events(ctx, &start); + } + start.terminal_reason + } else { + None + }; + ensure_upgrade_generation_current(client, upstream, host, port, &mut options).await?; + if let Some(reason) = start_terminal_reason { + if let Some(session) = options.middleware_session.take() { + session.end(reason).await; + } + send_websocket_close(client, upstream, 1008).await; + return Ok(()); + } let use_websocket_relay = options.websocket_request && (options.websocket.message_policy.inspects_messages() || options.websocket.permessage_deflate - || (options.websocket.credential_rewrite && options.secret_resolver.is_some())); + || options.websocket.credential_rewrite + || options.middleware_session.is_some() + || options.websocket.deny_uninspected_credentials); let relay_mode = if use_websocket_relay { "websocket parsed relay" } else { @@ -678,6 +1135,7 @@ where .build() ); if use_websocket_relay { + let assembly_budget = options.assembly_budget.take().unwrap_or_default(); let resolver = if options.websocket.credential_rewrite { options.secret_resolver.as_deref() } else { @@ -715,9 +1173,18 @@ where port, crate::l7::websocket::RelayOptions { policy_name: &options.policy_name, + assembly_budget, resolver, + generation_guard: options.generation_guard, + provider_credentials: options + .ctx + .and_then(|ctx| ctx.provider_credentials.as_ref()), + target: &options.target, inspector, compression, + middleware_session: options.middleware_session.take(), + middleware_context: options.ctx, + deny_uninspected_credentials: options.websocket.deny_uninspected_credentials, }, ) .await; @@ -732,6 +1199,45 @@ where Ok(()) } +async fn ensure_upgrade_generation_current( + client: &mut C, + upstream: &mut U, + host: &str, + port: u16, + options: &mut UpgradeRelayOptions<'_>, +) -> Result<()> +where + C: AsyncWrite + Unpin, + U: AsyncWrite + Unpin, +{ + let Some(guard) = options.generation_guard else { + return Ok(()); + }; + if let Err(error) = guard.ensure_current() { + emit_policy_reload(guard, host, port, &options.policy_name); + if let Some(session) = options.middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .await; + } + send_websocket_close(client, upstream, 1012).await; + return Err(error); + } + Ok(()) +} + +async fn send_websocket_close(client: &mut C, upstream: &mut U, code: u16) +where + C: AsyncWrite + Unpin, + U: AsyncWrite + Unpin, +{ + let payload = code.to_be_bytes(); + let _ = crate::l7::websocket::write_unmasked_close(client, &payload).await; + let _ = crate::l7::websocket::write_masked_close(upstream, &payload).await; + let _ = client.shutdown().await; + let _ = upstream.shutdown().await; +} + pub(crate) fn upgrade_options<'a>( config: &L7EndpointConfig, ctx: &'a L7EvalContext, @@ -743,6 +1249,8 @@ pub(crate) fn upgrade_options<'a>( let websocket_credential_rewrite = matches!(config.protocol, L7Protocol::Rest | L7Protocol::Websocket) && config.websocket_credential_rewrite; + let deny_uninspected_credentials = + config.provider_credentialed && !config.allow_uninspected_credentials; let websocket_message_policy = if config.protocol == L7Protocol::Websocket { if config.websocket_graphql_policy { WebSocketMessagePolicy::Graphql @@ -756,26 +1264,36 @@ pub(crate) fn upgrade_options<'a>( websocket_request, websocket: WebSocketUpgradeBehavior { credential_rewrite: websocket_credential_rewrite, + deny_uninspected_credentials, message_policy: websocket_message_policy, permessage_deflate: false, }, + assembly_budget: engine.map(TunnelPolicyEngine::websocket_assembly_budget), secret_resolver: if websocket_credential_rewrite { ctx.secret_resolver.clone() } else { None }, + generation_guard: engine.map(TunnelPolicyEngine::generation_guard), engine, - ctx: engine.map(|_| ctx), + ctx: (engine.is_some() || websocket_credential_rewrite).then_some(ctx), enforcement: config.enforcement, target: target.to_string(), query_params: query_params.clone(), policy_name: ctx.policy_name.clone(), + middleware_session: None, + selected_subprotocol: None, } } -pub(crate) fn websocket_extension_mode(config: &L7EndpointConfig) -> WebSocketExtensionMode { - if config.protocol == L7Protocol::Websocket +pub(crate) fn websocket_extension_mode( + config: &L7EndpointConfig, + inspecting_middleware_session: bool, +) -> WebSocketExtensionMode { + if inspecting_middleware_session + || config.protocol == L7Protocol::Websocket || (config.protocol == L7Protocol::Rest && config.websocket_credential_rewrite) + || (config.provider_credentialed && !config.allow_uninspected_credentials) { WebSocketExtensionMode::PermessageDeflate } else { @@ -835,7 +1353,10 @@ where return Ok(()); // Close connection on parse error } }; - + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); } @@ -844,27 +1365,14 @@ where return Ok(()); } - // Rewrite credential placeholders in the request target BEFORE OPA - // evaluation. OPA sees the redacted path; the resolved path goes only - // to the upstream write. - let (eval_target, redacted_target) = if let Some(ref resolver) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, resolver) { - Ok(result) => (result.resolved, result.redacted), - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + // Redact placeholder syntax before OPA evaluation without consulting + // real credential material. Resolution happens only at upstream write. + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - (req.target.clone(), req.target.clone()) }; let request_info = L7RequestInfo { @@ -951,15 +1459,13 @@ where ocsf_emit!(event); } - // Store the resolved target for the deny response redaction - let _ = &eval_target; - if allowed || config.enforcement == EnforcementMode::Audit { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let websocket_chain = websocket_request.then(|| chain.clone()); // REST and websocket-upgrade policy evaluates only the method, // path, and query, which a middleware result cannot mutate, so no // per-stage body re-check is needed. - let req = match apply_middleware_chain( + let middleware_result = apply_middleware_chain( req, client, ctx, @@ -968,8 +1474,8 @@ where engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, ) - .await? - { + .await; + let req = match middleware_result? { MiddlewareApplyResult::Allowed(request) => request, MiddlewareApplyResult::Denied { denial, .. } => { let denied_request = crate::l7::provider::L7Request { @@ -989,7 +1495,57 @@ where .await?; return Ok(()); } - }; + MiddlewareApplyResult::AdmissionExhausted => { + let unavailable_request = crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + crate::l7::middleware::send_middleware_admission_exhausted_response( + &unavailable_request, + client, + ctx, + &redacted_target, + ) + .await?; + return Ok(()); + } + }; + let mut middleware_session = if let Some(chain) = websocket_chain.as_deref() { + let preflight = websocket_middleware_preflight( + &req, + chain, + engine.middleware_runner(), + ctx, + "wss", + ) + .await; + let preflight = match preflight { + Ok(preflight) => preflight, + Err(error) => { + warn!(error = %error, "WebSocket middleware preflight failed"); + write_bad_gateway_response(client).await?; + return Ok(()); + } + }; + crate::l7::middleware::emit_websocket_preflight_events(ctx, &preflight); + if preflight.terminal_reason.is_some() { + crate::l7::middleware::send_middleware_rejection_response( + &req, + client, + ctx, + preflight.denial.as_ref(), + &redacted_target, + ) + .await?; + return Ok(()); + } + preflight.session + } else { + None + }; let req_with_auth = match crate::l7::token_grant_injection::inject_if_needed(req, ctx).await { Ok(req) => req, @@ -1004,29 +1560,70 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); // Forward request to upstream and relay response - let outcome = crate::l7::rest::relay_http_request_with_options_guarded( + let outcome_result = relay_http_request_with_credential_rejection( &req_with_auth, client, upstream, crate::l7::rest::RelayRequestOptions { resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), generation_guard: Some(engine.generation_guard()), - websocket_extensions: websocket_extension_mode(config), + websocket_extensions: websocket_extension_mode( + config, + middleware_session.is_some(), + ), request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, + deny_uninspected_credentials: config + .deny_uninspected_body_credentials(ctx.secret_resolver.is_some()), credential_signing: config.credential_signing, signing_service: &config.signing_service, signing_region: &config.signing_region, host: &ctx.host, port: ctx.port, }, + ctx, + ) + .await; + let outcome_result = match outcome_result { + Ok(Some(outcome)) => Ok(outcome), + Ok(None) => { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .await; + } + return Ok(()); + } + Err(error) => Err(error), + }; + let outcome = finalize_websocket_pre_upgrade( + &mut middleware_session, + engine.generation_guard(), + &ctx.host, + ctx.port, + &ctx.policy_name, + outcome_result, ) .await?; match outcome { - RelayOutcome::Reusable => {} // continue loop + RelayOutcome::Reusable => { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .await; + } + } RelayOutcome::Consumed => { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .await; + } debug!( host = %ctx.host, port = ctx.port, @@ -1037,6 +1634,7 @@ where RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, + websocket_subprotocol, } => { let mut options = upgrade_options( config, @@ -1047,6 +1645,8 @@ where Some(engine), ); options.websocket.permessage_deflate = websocket_permessage_deflate; + options.middleware_session = middleware_session.take(); + options.selected_subprotocol = websocket_subprotocol; return handle_upgrade( client, upstream, overflow, &ctx.host, ctx.port, options, ) @@ -1075,6 +1675,16 @@ fn close_if_stale(guard: &PolicyGenerationGuard, ctx: &L7EvalContext) -> bool { return false; } + emit_policy_reload(guard, &ctx.host, ctx.port, &ctx.policy_name); + true +} + +pub(crate) fn emit_policy_reload( + guard: &PolicyGenerationGuard, + host: &str, + port: u16, + policy_name: &str, +) { ocsf_emit!( NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) @@ -1082,18 +1692,55 @@ fn close_if_stale(guard: &PolicyGenerationGuard, ctx: &L7EvalContext) -> bool { .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) - .firewall_rule(&ctx.policy_name, "l7") + .dst_endpoint(Endpoint::from_domain(host, port)) + .firewall_rule(policy_name, "l7") .message(format!( "L7 tunnel closed after policy reload [host:{} port:{} captured_generation:{} current_generation:{}]", - ctx.host, - ctx.port, + host, + port, guard.captured_generation(), guard.current_generation(), )) .build() ); - true +} + +pub(crate) async fn finalize_websocket_pre_upgrade( + session: &mut Option, + guard: &PolicyGenerationGuard, + host: &str, + port: u16, + policy_name: &str, + result: Result, +) -> Result { + match result { + Ok(value @ RelayOutcome::Upgraded { .. }) => Ok(value), + Ok(value) => { + if let Err(error) = guard.ensure_current() { + emit_policy_reload(guard, host, port, policy_name); + if let Some(session) = session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .await; + } + Err(error) + } else { + Ok(value) + } + } + Err(error) => { + let reason = if guard.is_stale() { + emit_policy_reload(guard, host, port, policy_name); + openshell_core::proto::WebSocketSessionEndReason::PolicyReload + } else { + openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected + }; + if let Some(session) = session.take() { + session.end(reason).await; + } + Err(error) + } + } } async fn relay_jsonrpc( @@ -1147,12 +1794,21 @@ where let req = parsed.request; let jsonrpc_info = parsed.info; - + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } if close_if_stale(engine.generation_guard(), ctx) { return Ok(()); } - let redacted_target = req.target.clone(); + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); + } + }; let request_info = L7RequestInfo { action: req.action.clone(), @@ -1254,20 +1910,47 @@ where .await?; return Ok(()); } + MiddlewareApplyResult::AdmissionExhausted => { + let unavailable_request = crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + crate::l7::middleware::send_middleware_admission_exhausted_response( + &unavailable_request, + client, + ctx, + &redacted_target, + ) + .await?; + return Ok(()); + } }; + let scoped_ctx = scoped_context_for_request(ctx, &req); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); // Future MCP response/SSE introspection or rewrite would hook here // before returning upstream bytes. The current policy schema has no // trusted-annotations or version-profile field, so MCP responses and // SSE streams are relayed unchanged; see McpOptions in // proto/sandbox.proto for planned policy extensions. - let outcome = crate::l7::rest::relay_http_request_with_resolver_guarded( + let Some(outcome) = relay_http_request_with_credential_rejection( &req, client, upstream, - ctx.secret_resolver.as_deref(), - Some(engine.generation_guard()), + crate::l7::rest::RelayRequestOptions { + resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), + generation_guard: Some(engine.generation_guard()), + ..Default::default() + }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} RelayOutcome::Consumed => { @@ -1345,7 +2028,10 @@ where let req = parsed.request; let graphql_info = parsed.info; - + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); } @@ -1354,24 +2040,12 @@ where return Ok(()); } - let (eval_target, redacted_target) = if let Some(ref resolver) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, resolver) { - Ok(result) => (result.resolved, result.redacted), - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in GraphQL request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - (req.target.clone(), req.target.clone()) }; let request_info = L7RequestInfo { @@ -1419,7 +2093,7 @@ where SeverityId::Informational, ), }; - let gql_summary = graphql_log_summary(&graphql_info); + let gql_summary = crate::l7::graphql::log_summary(&graphql_info); let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(action_id) @@ -1439,8 +2113,6 @@ where ocsf_emit!(event); } - let _ = &eval_target; - if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; // Policy admitted the original body above; re-check the body @@ -1478,15 +2150,42 @@ where .await?; return Ok(()); } + MiddlewareApplyResult::AdmissionExhausted => { + let unavailable_request = crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + crate::l7::middleware::send_middleware_admission_exhausted_response( + &unavailable_request, + client, + ctx, + &redacted_target, + ) + .await?; + return Ok(()); + } }; - let outcome = crate::l7::rest::relay_http_request_with_resolver_guarded( + let scoped_ctx = scoped_context_for_request(ctx, &req); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + let Some(outcome) = relay_http_request_with_credential_rejection( &req, client, upstream, - ctx.secret_resolver.as_deref(), - Some(engine.generation_guard()), + crate::l7::rest::RelayRequestOptions { + resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), + generation_guard: Some(engine.generation_guard()), + ..Default::default() + }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} RelayOutcome::Consumed => { @@ -1500,8 +2199,12 @@ where RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, + .. } => { let options = UpgradeRelayOptions { + assembly_budget: Some( + crate::l7::websocket::WebSocketAssemblyBudget::default(), + ), websocket: WebSocketUpgradeBehavior { permessage_deflate: websocket_permessage_deflate, ..Default::default() @@ -1530,34 +2233,6 @@ where } } -fn graphql_log_summary(info: &crate::l7::graphql::GraphqlRequestInfo) -> String { - if let Some(error) = &info.error { - return format!("graphql_error={error:?}"); - } - let ops: Vec = info - .operations - .iter() - .map(|op| { - let name = op.operation_name.as_deref().unwrap_or("-"); - let fields = if op.fields.is_empty() { - "-".to_string() - } else { - op.fields.join(",") - }; - let persisted = op - .persisted_query_hash - .as_deref() - .or(op.persisted_query_id.as_deref()) - .unwrap_or("-"); - format!( - "type={} name={} fields={} persisted={}", - op.operation_type, name, fields, persisted - ) - }) - .collect(); - format!("graphql_ops={}", ops.join(";")) -} - pub(crate) fn jsonrpc_log_message( decision: &str, http_method: &str, @@ -1929,7 +2604,7 @@ fn evaluate_l7_request_once( )); } - let input_json = serde_json::json!({ + let input = serde_json::json!({ "network": { "host": ctx.host, "port": ctx.port, @@ -1953,9 +2628,7 @@ fn evaluate_l7_request_once( .lock() .map_err(|_| miette!("OPA engine lock poisoned"))?; - engine - .set_input_json(&input_json.to_string()) - .map_err(|e| miette!("{e}"))?; + crate::opa::set_regorus_input(&mut engine, input)?; let allowed = engine .eval_rule("data.openshell.sandbox.allow_request".into()) @@ -2000,8 +2673,6 @@ where // `allow_encoded_slash` opt-in applies. let provider = crate::l7::rest::RestProvider::default(); let mut request_count: u64 = 0; - let resolver = ctx.secret_resolver.as_deref(); - loop { if close_if_stale(generation_guard, ctx) { return Ok(()); @@ -2021,37 +2692,28 @@ where return Ok(()); } }; - + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } if close_if_stale(generation_guard, ctx) { return Ok(()); } request_count += 1; - // Resolve and redact the target for logging. - let redacted_target = if let Some(ref res) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, res) { - Ok(result) => result.redacted, - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + // Build the logging representation without materializing a secret. + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - req.target.clone() }; // Log for observability via OCSF HTTP Activity event. // Uses redacted_target (path only, no query params) to avoid logging secrets. - let has_creds = resolver.is_some(); + let has_creds = ctx.provider_credentials.is_some() || ctx.secret_resolver.is_some(); { let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) @@ -2110,6 +2772,23 @@ where .await?; return Ok(()); } + MiddlewareApplyResult::AdmissionExhausted => { + let unavailable_request = crate::l7::provider::L7Request { + action: "HTTP".into(), + target: redacted_target.clone(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + crate::l7::middleware::send_middleware_admission_exhausted_response( + &unavailable_request, + client, + ctx, + &redacted_target, + ) + .await?; + return Ok(()); + } } } else { req @@ -2129,21 +2808,29 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + let resolver = ctx.secret_resolver.as_deref(); // Forward request with credential rewriting and relay the response. // relay_http_request_with_resolver handles both directions: it sends // the request upstream and reads the response back to the client. - let outcome = crate::l7::rest::relay_http_request_with_options_guarded( + let Some(outcome) = relay_http_request_with_credential_rejection( &req_with_auth, client, upstream, crate::l7::rest::RelayRequestOptions { resolver, + credential_generation: credential_generation_guard(ctx), generation_guard: Some(generation_guard), ..Default::default() }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} // continue loop @@ -2186,92 +2873,480 @@ where mod tests { use super::*; use crate::opa::{NetworkInput, OpaEngine}; + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + use openshell_core::provider_credentials::ProviderCredentialState; + use std::collections::HashMap as TestHashMap; use std::path::PathBuf; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); - fn install_builtin_middleware(engine: &OpaEngine) { - engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( - openshell_supervisor_middleware_builtins::services() - .into_iter() - .next() - .expect("built-in middleware service"), - )); + fn endpoint_binding(identity: &str) -> StaticCredentialBinding { + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.test".to_string(), + port: 443, + path: "/allowed/**".to_string(), + }], + credential_identity: identity.to_string(), + workload_credential_handle: String::new(), + } } - fn assert_middleware_failure_response(response: &str, policy_name: &str) { - assert!(response.contains("403 Forbidden"), "{response}"); - let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); - let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); - assert_eq!(body["error"], "middleware_failed"); - assert_eq!( - body["detail"], - "Request could not be processed by configured middleware" - ); - assert_eq!(body["policy"], policy_name); - assert!(body.get("rule").is_none()); - assert!(body.get("rule_missing").is_none()); - assert!(body.get("next_steps").is_none()); - assert!(body.get("agent_guidance").is_none()); + fn endpoint_mismatch_resolver( + values: TestHashMap, + ) -> (ProviderCredentialState, Arc) { + let bindings = values + .keys() + .map(|key| (key.clone(), endpoint_binding(&format!("provider-a:{key}")))) + .collect(); + let state = ProviderCredentialState::from_bound_environment( + 1, + values, + TestHashMap::new(), + TestHashMap::new(), + bindings, + Vec::new(), + ) + .expect("bound provider state"); + let resolver = state + .resolver_for_endpoint("denied.example.test", 443, "/outside") + .expect("endpoint-scoped resolver"); + (state, resolver) } - fn rest_token_grant_relay_context( - resolver_response: std::result::Result<&str, &str>, - ) -> ( - L7EndpointConfig, - TunnelPolicyEngine, - L7EvalContext, - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, - ) { - let data = r#" -network_policies: - rest_api: - name: rest_api - endpoints: - - host: api.example.test - port: 8080 - protocol: rest - enforcement: enforce - rules: - - allow: - method: GET - path: "/v1/**" - binaries: - - { path: /usr/bin/curl } -"#; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); - let input = NetworkInput { - host: "api.example.test".into(), - port: 8080, - binary_path: PathBuf::from("/usr/bin/curl"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let (endpoint_config, generation) = engine - .query_endpoint_config_with_generation(&input) - .unwrap(); - let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); - let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); - let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; - let fixture = match resolver_response { - Ok(token) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( - provider_key, - token, - ) - } - Err(error) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( - provider_key, - error, - ) - } + #[test] + fn websocket_preflight_input_carries_real_sandbox_name() { + let sandbox = openshell_ocsf::SandboxContext { + sandbox_id: "sbx-123".into(), + sandbox_name: "nightly-build".into(), + container_image: String::new(), + hostname: "h".into(), + product_version: "0".into(), + proxy_ip: [127, 0, 0, 1].into(), + proxy_port: 3128, }; - let ctx = L7EvalContext { + + let eval = L7EvalContext { host: "api.example.test".into(), - port: 8080, + port: 443, + workspace: "team-a".into(), + policy_name: "api-policy".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + secret_resolver: None, + ..Default::default() + }; + let req = crate::l7::provider::L7Request { + action: "GET".into(), + target: "/v1/stream".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + + let input = + websocket_preflight_input(&sandbox, &eval, &req, "wss", vec!["chat".to_string()]); + + assert_eq!(input.sandbox_id, "sbx-123"); + assert_eq!(input.sandbox_name, "nightly-build"); + assert_eq!(input.workspace, "team-a"); + } + + #[test] + fn scoped_context_captures_endpoint_resolver_and_revision_together() { + let state = ProviderCredentialState::from_bound_environment( + 42, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + endpoint_binding("provider-a:API_TOKEN"), + )]), + Vec::new(), + ) + .expect("bound provider state"); + let ctx = L7EvalContext { + host: "allowed.example.test".to_string(), + port: 443, + request_default_port: Some(443), + provider_credentials: Some(state), + ..Default::default() + }; + let request = crate::l7::provider::L7Request { + action: "GET".to_string(), + target: "/allowed/v1".to_string(), + query_params: TestHashMap::new(), + raw_header: b"GET /allowed/v1 HTTP/1.1\r\nHost: allowed.example.test\r\n\r\n".to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + + let scoped = scoped_context_for_request(&ctx, &request).expect("scoped context"); + assert_eq!(scoped.provider_credential_revision, Some(42)); + assert_eq!( + scoped + .secret_resolver + .expect("endpoint resolver") + .resolve_placeholder("openshell:resolve:env:v42_API_TOKEN"), + Some("secret") + ); + } + + #[test] + fn bracketed_ipv6_host_matches_bracket_free_connect_endpoint() { + let request = crate::l7::provider::L7Request { + action: "GET".to_string(), + target: "/v1".to_string(), + query_params: TestHashMap::new(), + raw_header: b"GET /v1 HTTP/1.1\r\nHost: [2001:db8::1]:8443\r\n\r\n".to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + let ctx = L7EvalContext { + host: "2001:db8::1".to_string(), + port: 8443, + request_default_port: Some(8443), + ..Default::default() + }; + + let authority = crate::l7::rest::request_authority(&request.raw_header, Some(8443)) + .expect("valid authority") + .expect("Host header"); + assert_eq!(authority.authority.host(), "[2001:db8::1]"); + assert!(request_authority_matches_endpoint(&request, &ctx)); + } + + #[test] + fn missing_request_default_port_does_not_infer_the_connect_port() { + let request = crate::l7::provider::L7Request { + action: "GET".to_string(), + target: "/v1".to_string(), + query_params: TestHashMap::new(), + raw_header: b"GET /v1 HTTP/1.1\r\nHost: api.example.test\r\n\r\n".to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + let ctx = L7EvalContext { + host: "api.example.test".to_string(), + port: 443, + request_default_port: None, + ..Default::default() + }; + + assert!(!request_authority_matches_endpoint(&request, &ctx)); + } + + async fn run_single_config_credential_mismatch( + config: L7EndpointConfig, + engine: TunnelPolicyEngine, + mut ctx: L7EvalContext, + request: String, + resolver: Arc, + ) -> (String, Vec) { + ctx.secret_resolver = Some(resolver); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all(request.as_bytes()).await.unwrap(); + let mut response = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read_to_string(&mut response), + ) + .await + .expect("typed credential denial should close the client stream") + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + (response, forwarded) + } + + fn assert_single_config_credential_mismatch( + response: &str, + forwarded: &[u8], + ctx: &L7EvalContext, + ) { + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("credential_endpoint_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "credential mismatch must not write upstream" + ); + + let activity = build_credential_resolution_event(ctx, true) + .to_json() + .expect("serialize credential mismatch activity"); + assert_eq!(activity["status_detail"], "credential_endpoint_mismatch"); + assert_eq!(activity["action"], "Denied"); + assert_eq!(activity["disposition"], "Blocked"); + let finding = build_credential_endpoint_mismatch_finding(ctx) + .to_json() + .expect("serialize credential mismatch finding"); + assert_eq!( + finding["finding_info"]["uid"], + "openshell.provider_credential.endpoint_mismatch" + ); + } + + async fn assert_credential_relay_rejected( + request: crate::l7::provider::L7Request, + resolver: &SecretResolver, + options: crate::l7::rest::RelayRequestOptions<'_>, + ) { + let (mut client_peer, mut client) = tokio::io::duplex(8192); + let (mut upstream, mut upstream_peer) = tokio::io::duplex(8192); + let ctx = L7EvalContext { + host: "denied.example.test".to_string(), + port: 443, + request_default_port: Some(443), + policy_name: "bound".to_string(), + secret_resolver: Some(Arc::new(resolver.clone())), + ..Default::default() + }; + + let outcome = relay_http_request_with_credential_rejection( + &request, + &mut client, + &mut upstream, + crate::l7::rest::RelayRequestOptions { + resolver: Some(resolver), + ..options + }, + &ctx, + ) + .await + .expect("typed credential denial"); + assert!(outcome.is_none()); + drop(client); + drop(upstream); + + let mut response = String::new(); + client_peer.read_to_string(&mut response).await.unwrap(); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("credential_endpoint_mismatch"), + "{response}" + ); + let mut forwarded = Vec::new(); + upstream_peer.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "credential mismatch must not write upstream" + ); + + let activity = build_credential_resolution_event(&ctx, true) + .to_json() + .unwrap(); + assert_eq!(activity["status_detail"], "credential_endpoint_mismatch"); + assert_eq!(activity["action"], "Denied"); + assert_eq!(activity["disposition"], "Blocked"); + + let finding = build_credential_endpoint_mismatch_finding(&ctx) + .to_json() + .unwrap(); + assert_eq!( + finding["finding_info"]["uid"], + "openshell.provider_credential.endpoint_mismatch" + ); + assert_eq!(finding["action"], "Denied"); + assert_eq!(finding["disposition"], "Blocked"); + } + + #[tokio::test] + async fn body_only_endpoint_mismatch_returns_typed_403() { + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = br#"{"token":"openshell:resolve:env:v1_API_TOKEN"}"#; + let request = crate::l7::provider::L7Request { + action: "POST".to_string(), + target: "/outside".to_string(), + query_params: TestHashMap::new(), + raw_header: format!( + "POST /outside HTTP/1.1\r\nHost: denied.example.test\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes() + .into_iter() + .chain(body.iter().copied()) + .collect(), + body_length: crate::l7::provider::BodyLength::ContentLength(body.len() as u64), + }; + + assert_credential_relay_rejected( + request, + resolver.as_ref(), + crate::l7::rest::RelayRequestOptions { + request_body_credential_rewrite: true, + ..Default::default() + }, + ) + .await; + } + + #[tokio::test] + async fn implicit_sigv4_endpoint_mismatch_returns_typed_403() { + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([ + ("AWS_ACCESS_KEY_ID".to_string(), "access".to_string()), + ("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string()), + ("AWS_SESSION_TOKEN".to_string(), "session".to_string()), + ])); + let request = crate::l7::provider::L7Request { + action: "GET".to_string(), + target: "/outside".to_string(), + query_params: TestHashMap::new(), + raw_header: + b"GET /outside HTTP/1.1\r\nHost: denied.example.test\r\nContent-Length: 0\r\n\r\n" + .to_vec(), + body_length: crate::l7::provider::BodyLength::ContentLength(0), + }; + + assert_credential_relay_rejected( + request, + resolver.as_ref(), + crate::l7::rest::RelayRequestOptions { + credential_signing: crate::l7::CredentialSigning::SigV4NoBody, + signing_service: "execute-api", + signing_region: "us-west-2", + host: "denied.example.test", + port: 443, + ..Default::default() + }, + ) + .await; + } + + fn install_builtin_middleware(engine: &OpaEngine) { + engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( + openshell_supervisor_middleware_builtins::services() + .into_iter() + .next() + .expect("built-in middleware service"), + )); + } + + fn assert_middleware_failure_response(response: &str, policy_name: &str) { + assert!(response.contains("403 Forbidden"), "{response}"); + let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + assert_eq!(body["error"], "middleware_failed"); + assert_eq!( + body["detail"], + "Request could not be processed by configured middleware" + ); + assert_eq!(body["policy"], policy_name); + assert!(body.get("rule").is_none()); + assert!(body.get("rule_missing").is_none()); + assert!(body.get("next_steps").is_none()); + assert!(body.get("agent_guidance").is_none()); + } + + fn assert_middleware_unavailable_response(response: &str, policy_name: &str) { + assert!( + response.starts_with("HTTP/1.1 503 Service Unavailable\r\n"), + "{response}" + ); + assert!(!response.contains("100 Continue"), "{response}"); + assert!(!response.to_ascii_lowercase().contains("retry-after")); + let (headers, body) = response.split_once("\r\n\r\n").expect("HTTP response"); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length: ") + .and_then(|value| value.parse::().ok()) + }) + .expect("Content-Length"); + assert_eq!(content_length, body.len()); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + assert_eq!(body["error"], "middleware_failed"); + assert_eq!( + body["detail"], + "Request could not be processed by configured middleware" + ); + assert_eq!(body["policy"], policy_name); + assert!(body.get("middleware").is_none()); + assert!(body.get("reason_code").is_none()); + assert!(body.get("rule_missing").is_none()); + assert!(body.get("next_steps").is_none()); + } + + fn rest_token_grant_relay_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + L7EndpointConfig, + TunnelPolicyEngine, + L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let data = r#" +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/v1/**" + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "api.example.test".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = match resolver_response { + Ok(token) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( + provider_key, + token, + ) + } + Err(error) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( + provider_key, + error, + ) + } + }; + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + request_default_port: Some(8080), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -2285,6 +3360,37 @@ network_policies: (config, tunnel_engine, ctx, fixture) } + fn rest_token_exchange_relay_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + L7EndpointConfig, + TunnelPolicyEngine, + L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (config, tunnel_engine, mut ctx, _) = + rest_token_grant_relay_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = match resolver_response { + Ok(token) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + Err(error) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + }; + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (config, tunnel_engine, ctx, fixture) + } + fn middleware_relay_context( middleware_impl: &str, on_error: &str, @@ -2339,6 +3445,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 8080, + request_default_port: Some(8080), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -2380,6 +3487,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 8080, + request_default_port: Some(8080), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -2393,24 +3501,360 @@ network_policies: (generation_guard, ctx, fixture) } - fn jsonrpc_test_relay_context() -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { - let data = r" + #[derive(Clone)] + struct ControllableWebSocketPreflight { + seen: tokio::sync::mpsc::UnboundedSender<()>, + release: Arc, + action: openshell_core::proto::WebSocketPreflightAction, + } + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware + for ControllableWebSocketPreflight + { + type EvaluateWebSocketSessionStream = + openshell_supervisor_middleware::WebSocketResponseStream; + + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::MiddlewareManifest { + name: "test/controllable-websocket-preflight".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + operation: + openshell_core::proto::SupervisorMiddlewareOperation::WebsocketMessage + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials + as i32, + max_payload_bytes: + openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, + timeout: "2s".into(), + }], + expected_audience: String::new(), + }, + )) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Err(tonic::Status::unimplemented("WebSocket-only middleware")) + } + + async fn evaluate_web_socket_session( + &self, + request: tonic::Request>, + ) -> std::result::Result, tonic::Status> + { + use openshell_core::proto::{ + WebSocketPreflightDecision, WebSocketSessionEventResult, web_socket_session_event, + web_socket_session_event_result, + }; + use tokio_stream::wrappers::ReceiverStream; + + let mut requests = request.into_inner(); + let seen = self.seen.clone(); + let release = Arc::clone(&self.release); + let action = self.action; + let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Ok(Some(request)) = requests.message().await { + if matches!( + request.event, + Some(web_socket_session_event::Event::Preflight(_)) + ) { + let _ = seen.send(()); + release.notified().await; + let _ = responses_tx + .send(Ok(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::PreflightDecision( + WebSocketPreflightDecision { + action: action as i32, + ..Default::default() + }, + ), + ), + })) + .await; + } + } + }); + Ok(tonic::Response::new(Box::pin(ReceiverStream::new( + responses_rx, + )))) + } + } + + async fn assert_websocket_preflight_precedes_token_grant( + action: openshell_core::proto::WebSocketPreflightAction, + admitted: bool, + ) { + use openshell_core::proto::SupervisorMiddlewareService; + use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddlewareServer; + use openshell_supervisor_middleware::{ChainRunner, MiddlewareRegistry}; + use tokio_stream::wrappers::TcpListenerStream; + + let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel(); + let release = Arc::new(tokio::sync::Notify::new()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new( + ControllableWebSocketPreflight { + seen: seen_tx, + release: Arc::clone(&release), + action, + }, + )) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = MiddlewareRegistry::connect_services( + Vec::new(), + vec![SupervisorMiddlewareService { + name: "preflight-service".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "2s".into(), + tls_ca_cert_pem: Vec::new(), + audience: String::new(), + allow_insecure_transport: false, + }], + ) + .await + .expect("connect middleware"); + + let data = r#" +network_middlewares: + websocket-preflight: + middleware: preflight-service + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/v1/**" + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("test policy"); + engine.set_middleware_runner_for_tests(ChainRunner::from_registry(registry)); + let input = NetworkInput { + host: "api.example.test".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint_config.expect("configured endpoint")) + .expect("REST config"); + let tunnel_engine = engine + .clone_engine_for_tunnel(generation) + .expect("tunnel engine"); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( + provider_key, + "grant-token", + ); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + request_default_port: Some(8080), + policy_name: "rest_api".into(), + binary_path: "/usr/bin/curl".into(), + dynamic_credentials: Some(fixture.dynamic_credentials()), + token_grant_resolver: Some(fixture.resolver()), + ..Default::default() + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_rest( + &config, + &tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /v1/ws HTTP/1.1\r\nHost: api.example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ) + .await + .expect("send upgrade request"); + seen_rx.recv().await.expect("preflight reached middleware"); + fixture.assert_no_requests(); + release.notify_one(); + + if admitted { + let mut forwarded = Vec::new(); + let mut buffer = [0u8; 512]; + while !forwarded.windows(4).any(|window| window == b"\r\n\r\n") { + let count = upstream.read(&mut buffer).await.expect("read upstream"); + assert!(count > 0, "upstream closed before request headers"); + forwarded.extend_from_slice(&buffer[..count]); + } + let forwarded = String::from_utf8_lossy(&forwarded); + assert!(forwarded.contains("Authorization: Bearer grant-token\r\n")); + upstream + .write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .expect("reject upgrade"); + let mut response = [0u8; 512]; + let count = app.read(&mut response).await.expect("read client response"); + assert!( + String::from_utf8_lossy(&response[..count]).contains("400 Bad Request"), + "unexpected client response" + ); + fixture.assert_one_request(provider_key); + } else { + let mut response = [0u8; 1024]; + let count = app.read(&mut response).await.expect("read client response"); + let response = String::from_utf8_lossy(&response[..count]); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!(response.contains(r#""error":"middleware_denied""#)); + assert!(response.contains(r#""middleware":"websocket-preflight""#)); + let mut byte = [0u8; 1]; + let count = upstream.read(&mut byte).await.expect("upstream close"); + assert_eq!(count, 0, "denied preflight must not reach upstream"); + fixture.assert_no_requests(); + } + + drop(app); + relay + .await + .expect("join REST relay") + .expect("REST relay result"); + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn denied_websocket_preflight_has_no_token_grant_side_effects() { + assert_websocket_preflight_precedes_token_grant( + openshell_core::proto::WebSocketPreflightAction::Deny, + false, + ) + .await; + } + + #[tokio::test] + async fn admitted_websocket_preflight_precedes_token_grant() { + assert_websocket_preflight_precedes_token_grant( + openshell_core::proto::WebSocketPreflightAction::Inspect, + true, + ) + .await; + } + + fn passthrough_token_exchange_relay_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + PolicyGenerationGuard, + L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (generation_guard, mut ctx, _) = + passthrough_token_grant_relay_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = match resolver_response { + Ok(token) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + Err(error) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + }; + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (generation_guard, ctx, fixture) + } + + fn jsonrpc_test_relay_context() -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + jsonrpc_test_relay_context_with_path("/rpc") + } + + fn jsonrpc_test_relay_context_with_path( + endpoint_path: &str, + ) -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = format!( + r#" network_policies: jsonrpc_api: name: jsonrpc_api endpoints: - host: jsonrpc.example.test port: 8000 - path: /rpc + path: "{endpoint_path}" protocol: json-rpc enforcement: enforce rules: - allow: method: initialize binaries: - - { path: /usr/bin/python3 } -"; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + - {{ path: /usr/bin/python3 }} +"# + ); + let engine = OpaEngine::from_strings(TEST_POLICY, &data).unwrap(); let input = NetworkInput { host: "jsonrpc.example.test".into(), port: 8000, @@ -2427,6 +3871,7 @@ network_policies: let ctx = L7EvalContext { host: "jsonrpc.example.test".into(), port: 8000, + request_default_port: Some(8000), policy_name: "jsonrpc_api".into(), binary_path: "/usr/bin/python3".into(), ancestors: vec![], @@ -2471,6 +3916,7 @@ network_policies: let ctx = L7EvalContext { host: "mcp.example.test".into(), port: 8000, + request_default_port: Some(8000), policy_name: "mcp_api".into(), binary_path: "/usr/bin/python3".into(), ancestors: vec![], @@ -2481,6 +3927,121 @@ network_policies: (config, tunnel_engine, ctx) } + fn graphql_test_relay_context() -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = r" +network_policies: + graphql_api: + name: graphql_api + endpoints: + - host: graphql.example.test + port: 8000 + path: /graphql + protocol: graphql + enforcement: enforce + rules: + - allow: + operation_type: query + fields: [viewer] + binaries: + - { path: /usr/bin/python3 } +"; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "graphql.example.test".into(), + port: 8000, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "graphql.example.test".into(), + port: 8000, + request_default_port: Some(8000), + policy_name: "graphql_api".into(), + binary_path: "/usr/bin/python3".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + ..Default::default() + }; + (config, tunnel_engine, ctx) + } + + #[tokio::test] + async fn single_config_jsonrpc_credential_mismatch_is_typed_and_telemetry_safe() { + let (config, engine, ctx) = jsonrpc_test_relay_context_with_path("/rpc/**"); + let event_ctx = ctx.clone(); + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#; + let request = format!( + "POST /rpc/openshell:resolve:env:v1_API_TOKEN HTTP/1.1\r\nHost: jsonrpc.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + let (response, forwarded) = + run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; + assert_single_config_credential_mismatch(&response, &forwarded, &event_ctx); + + let redacted_target = + secrets::redact_target_for_policy("/rpc/openshell:resolve:env:v1_API_TOKEN") + .expect("policy target redaction"); + assert!( + redacted_target.contains("[CREDENTIAL]"), + "policy telemetry should contain the syntax-only redaction marker: {redacted_target}" + ); + assert!( + !redacted_target.contains("API_TOKEN"), + "policy telemetry must not expose credential environment keys: {redacted_target}" + ); + } + + #[tokio::test] + async fn single_config_mcp_credential_mismatch_returns_typed_denial() { + let (config, engine, ctx) = mcp_test_relay_context(); + let event_ctx = ctx.clone(); + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#; + let request = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + let (response, forwarded) = + run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; + assert_single_config_credential_mismatch(&response, &forwarded, &event_ctx); + } + + #[tokio::test] + async fn single_config_graphql_credential_mismatch_returns_typed_denial() { + let (config, engine, ctx) = graphql_test_relay_context(); + let event_ctx = ctx.clone(); + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = r#"{"query":"query { viewer }"}"#; + let request = format!( + "POST /graphql HTTP/1.1\r\nHost: graphql.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + let (response, forwarded) = + run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; + assert_single_config_credential_mismatch(&response, &forwarded, &event_ctx); + } + fn authorization_header_count(headers: &str) -> usize { headers .lines() @@ -2643,9 +4204,9 @@ network_policies: } #[tokio::test] - async fn l7_rest_middleware_redacts_body_before_upstream() { - let (config, tunnel_engine, ctx) = - middleware_relay_context("openshell/regex", "fail_closed"); + async fn l7_rest_relay_injects_token_exchange_authorization_header() { + let (config, tunnel_engine, ctx, fixture) = + rest_token_exchange_relay_context(Ok("grant-token")); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { @@ -2659,8 +4220,130 @@ network_policies: .await }); - let body = br#"{"api_key":"sk-1234567890abcdef"}"#; - let request = format!( + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + + assert!( + upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n"), + "unexpected upstream request: {upstream_request:?}" + ); + assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); + assert!(!upstream_request.contains("stale-token")); + assert_eq!(authorization_header_count(&upstream_request), 1); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + + #[tokio::test] + async fn l7_rest_relay_token_exchange_failure_does_not_forward_request() { + let (config, tunnel_engine, ctx, fixture) = + rest_token_exchange_relay_context(Err("oauth unavailable")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("bad gateway response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("502 Bad Gateway")); + + let mut upstream_request = [0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("upstream should close without forwarded data") + .unwrap(); + assert_eq!(n, 0, "unauthenticated request must not reach upstream"); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + + #[tokio::test] + async fn l7_rest_middleware_redacts_body_before_upstream() { + let (config, tunnel_engine, ctx) = + middleware_relay_context("openshell/regex", "fail_closed"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"api_key":"sk-1234567890abcdef"}"#; + let request = format!( "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), std::str::from_utf8(body).unwrap() @@ -2679,148 +4362,589 @@ network_policies: assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#)); assert!(!upstream_request.contains("sk-1234567890abcdef")); - upstream - .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") - .await - .unwrap(); - let mut client_response = [0u8; 512]; - let n = tokio::time::timeout( + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn l7_rest_middleware_acknowledges_expect_continue_before_reading_body() { + let (config, tunnel_engine, ctx) = + middleware_relay_context("openshell/regex", "fail_closed"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"api_key":"sk-1234567890abcdef"}"#; + let headers = format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(headers.as_bytes()).await.unwrap(); + + let mut interim = [0u8; 64]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut interim)) + .await + .expect("middleware buffering should acknowledge Expect before reading the body") + .unwrap(); + assert_eq!(&interim[..n], b"HTTP/1.1 100 Continue\r\n\r\n"); + + app.write_all(body).await.unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream after the body is released") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#)); + assert!(!upstream_request.contains("Expect: 100-continue")); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn l7_rest_exhausted_middleware_admission_returns_503_before_body_or_upstream() { + let (config, tunnel_engine, ctx) = middleware_relay_context("openshell/regex", "fail_open"); + let runner = tunnel_engine.middleware_runner().clone(); + + let mut active = Vec::new(); + for _ in 0..openshell_supervisor_middleware::MAX_CONCURRENT_MIDDLEWARE_WORK { + active.push( + runner + .reserve_middleware_work_admission() + .await + .expect("fill active middleware work"), + ); + } + let mut waiters = Vec::new(); + for _ in 0..openshell_supervisor_middleware::MAX_QUEUED_MIDDLEWARE_WORK { + let runner = runner.clone(); + waiters.push(Box::pin( + async move { runner.reserve_middleware_work().await }, + )); + } + for waiter in &mut waiters { + assert!( + futures::poll!(waiter.as_mut()).is_pending(), + "every bounded waiter slot must be occupied" + ); + } + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + // The declared body is within the built-in regex HTTP capability, but + // the client intentionally withholds it behind Expect: 100-continue. + // Queue exhaustion must be answered before buffering begins. + app.write_all( + b"POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 32\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n", + ) + .await + .expect("send headers without body"); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should shed immediately") + .expect("join relay") + .expect("relay returns a complete HTTP response"); + + let mut response = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read_to_end(&mut response), + ) + .await + .expect("client should receive 503 without sending its body") + .expect("read client response"); + let response = String::from_utf8(response).expect("UTF-8 response"); + assert_middleware_unavailable_response(&response, "rest_api"); + + let mut upstream_bytes = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read_to_end(&mut upstream_bytes), + ) + .await + .expect("upstream side should close") + .expect("read upstream"); + assert!( + upstream_bytes.is_empty(), + "admission-exhausted request must not reach upstream" + ); + + drop(waiters); + drop(active); + runner + .reserve_middleware_work_admission() + .await + .expect("work capacity recovers after saturation fixture"); + } + + #[tokio::test] + async fn l7_rest_middleware_fail_closed_does_not_reach_upstream() { + let (config, tunnel_engine, ctx) = + middleware_relay_context("example/unavailable", "fail_closed"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + ) + .await + .unwrap(); + + let mut response = [0u8; 512]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden")); + let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + assert_eq!(body["error"], "middleware_failed"); + assert_eq!( + body["detail"], + "Request could not be processed by configured middleware" + ); + assert_eq!(body["policy"], "rest_api"); + assert!(body.get("rule").is_none()); + assert!(body.get("rule_missing").is_none()); + assert!(body.get("next_steps").is_none()); + assert!(body.get("agent_guidance").is_none()); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive request bytes" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn l7_denial_precedes_credential_endpoint_resolution() { + let (config, tunnel_engine, mut ctx) = + middleware_relay_context("openshell/regex", "fail_closed"); + let (_credential_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + ctx.secret_resolver = Some(resolver); + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /outside HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut response = [0u8; 2048]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("policy denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + !response.contains("credential_endpoint_mismatch"), + "L7-denied request must not expose credential binding state: {response}" + ); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "L7-denied request must not reach upstream" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn connect_rejects_credential_request_with_mismatched_host_authority() { + let engine = OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").unwrap(); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: 8080, + path: "/v1/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = state + .snapshot() + .child_env + .get("API_TOKEN") + .expect("placeholder") + .clone(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + request_default_port: Some(8080), + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + provider_credentials: Some(state), + ..Default::default() + }; + let event_ctx = ctx.clone(); + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + let request = format!( + "POST /v1/messages HTTP/1.1\r\nHost: attacker.example.test\r\nAuthorization: Bearer {placeholder}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ); + app.write_all(request.as_bytes()).await.unwrap(); + + let mut response = String::new(); + tokio::time::timeout( std::time::Duration::from_secs(1), - app.read(&mut client_response), + app.read_to_string(&mut response), ) .await - .expect("response should reach client") + .expect("authority denial should close the client stream") .unwrap(); - assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); - drop(app); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) .await .expect("relay should finish") .unwrap() .unwrap(); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "mismatched request authority must not write upstream" + ); + let activity = build_request_authority_mismatch_event(&event_ctx) + .to_json() + .expect("serialize authority mismatch activity"); + assert_eq!(activity["status_detail"], "request_authority_mismatch"); + assert_eq!(activity["action"], "Denied"); + assert_eq!(activity["disposition"], "Blocked"); + let finding = build_request_authority_mismatch_finding(&event_ctx) + .to_json() + .expect("serialize authority mismatch finding"); + assert_eq!( + finding["finding_info"]["uid"], + "openshell.http.request_authority_mismatch" + ); } - #[tokio::test] - async fn l7_rest_middleware_acknowledges_expect_continue_before_reading_body() { - let (config, tunnel_engine, ctx) = - middleware_relay_context("openshell/regex", "fail_closed"); + async fn run_bound_credential_request( + port: u16, + request_default_port: u16, + request: impl FnOnce(&str) -> String, + ) -> (String, Vec) { + let engine = OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").unwrap(); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: u32::from(port), + path: "/v1/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = state + .snapshot() + .child_env + .get("API_TOKEN") + .expect("placeholder") + .clone(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port, + request_default_port: Some(request_default_port), + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + provider_credentials: Some(state), + ..Default::default() + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { - relay_with_inspection( - &config, - tunnel_engine, + relay_passthrough_with_credentials( &mut relay_client, &mut relay_upstream, &ctx, + &generation_guard, + None, ) .await }); - let body = br#"{"api_key":"sk-1234567890abcdef"}"#; - let headers = format!( - "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n", - body.len() - ); - app.write_all(headers.as_bytes()).await.unwrap(); - - let mut interim = [0u8; 64]; - let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut interim)) - .await - .expect("middleware buffering should acknowledge Expect before reading the body") - .unwrap(); - assert_eq!(&interim[..n], b"HTTP/1.1 100 Continue\r\n\r\n"); - - app.write_all(body).await.unwrap(); - - let mut upstream_request = [0u8; 1024]; - let n = tokio::time::timeout( - std::time::Duration::from_secs(1), - upstream.read(&mut upstream_request), - ) - .await - .expect("request should reach upstream after the body is released") - .unwrap(); - let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); - assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#)); - assert!(!upstream_request.contains("Expect: 100-continue")); - - upstream - .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + app.write_all(request(&placeholder).as_bytes()) .await .unwrap(); - let mut client_response = [0u8; 512]; - let n = tokio::time::timeout( + let mut response = String::new(); + tokio::time::timeout( std::time::Duration::from_secs(1), - app.read(&mut client_response), + app.read_to_string(&mut response), ) .await - .expect("response should reach client") + .expect("credential denial should close the client stream") .unwrap(); - assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); - drop(app); tokio::time::timeout(std::time::Duration::from_secs(1), relay) .await .expect("relay should finish") .unwrap() .unwrap(); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + (response, forwarded) } #[tokio::test] - async fn l7_rest_middleware_fail_closed_does_not_reach_upstream() { - let (config, tunnel_engine, ctx) = - middleware_relay_context("example/unavailable", "fail_closed"); + async fn connect_http10_without_authority_cannot_resolve_static_credential() { + let (response, forwarded) = run_bound_credential_request(80, 80, |placeholder| { + format!( + "GET /v1/messages HTTP/1.0\r\nAuthorization: Bearer {placeholder}\r\nConnection: close\r\n\r\n" + ) + }) + .await; + + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "authority-less credential request must not write upstream" + ); + } + + #[tokio::test] + async fn connect_origin_form_omitted_port_rejects_non_default_tunnel() { + let (response, forwarded) = run_bound_credential_request(8080, 80, |placeholder| { + format!( + "GET /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer {placeholder}\r\nConnection: close\r\n\r\n" + ) + }) + .await; + + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "origin-form request with the wrong effective port must not write upstream" + ); + } + + #[tokio::test] + async fn connect_absolute_form_omitted_port_rejects_non_default_tunnel() { + let (response, forwarded) = run_bound_credential_request(8080, 80, |placeholder| { + format!( + "GET http://api.example.test/v1/messages HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer {placeholder}\r\nConnection: close\r\n\r\n" + ) + }) + .await; + + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "absolute-form request with the wrong effective port must not write upstream" + ); + } + + #[tokio::test] + async fn connect_http10_without_authority_forwards_credential_free_request() { + let engine = OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").unwrap(); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 80, + request_default_port: Some(80), + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + ..Default::default() + }; let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { - relay_with_inspection( - &config, - tunnel_engine, + relay_passthrough_with_credentials( &mut relay_client, &mut relay_upstream, &ctx, + &generation_guard, + None, ) .await }); - app.write_all( - b"POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + app.write_all(b"GET /v1/messages HTTP/1.0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut forwarded = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut forwarded), ) .await + .expect("credential-free HTTP/1.0 request should reach upstream") .unwrap(); - - let mut response = [0u8; 512]; - let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + assert!(String::from_utf8_lossy(&forwarded[..n]).starts_with("GET /v1/messages HTTP/1.0")); + upstream + .write_all(b"HTTP/1.0 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .await - .expect("denial should reach client") .unwrap(); - let response = String::from_utf8_lossy(&response[..n]); - assert!(response.contains("403 Forbidden")); - let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); - let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); - assert_eq!(body["error"], "middleware_failed"); - assert_eq!( - body["detail"], - "Request could not be processed by configured middleware" - ); - assert_eq!(body["policy"], "rest_api"); - assert!(body.get("rule").is_none()); - assert!(body.get("rule_missing").is_none()); - assert!(body.get("next_steps").is_none()); - assert!(body.get("agent_guidance").is_none()); - - let mut upstream_request = [0u8; 32]; - let result = tokio::time::timeout( - std::time::Duration::from_millis(100), - upstream.read(&mut upstream_request), - ) - .await; - assert!( - matches!(result, Err(_) | Ok(Ok(0))), - "upstream should not receive request bytes" - ); - - drop(app); + let mut response = String::new(); + app.read_to_string(&mut response).await.unwrap(); + assert!(response.contains("204 No Content"), "{response}"); tokio::time::timeout(std::time::Duration::from_secs(1), relay) .await .expect("relay should finish") @@ -2984,6 +5108,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "jsonrpc_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -3099,6 +5224,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "p".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -3165,100 +5291,230 @@ network_policies: config: prost_types::Struct::default(), on_error: OnError::FailClosed, }; - let unresolved = ChainEntry { - name: "missing".into(), - implementation: "third-party/missing".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, + let unresolved = ChainEntry { + name: "missing".into(), + implementation: "third-party/missing".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }; + + // A single unresolved (0-limit) entry must not drag the chain limit to + // zero: the buffer limit reflects only the resolved built-in. + let mixed = ChainRunner::new( + openshell_supervisor_middleware_builtins::services() + .into_iter() + .next() + .expect("built-in middleware service"), + ) + .describe_chain(&[resolved, unresolved.clone()]) + .await + .expect("describe mixed chain"); + assert_eq!(middleware_chain_body_limit(&mixed), Some(256 * 1024)); + + // When nothing resolves, there is no body limit and the caller skips + // buffering entirely. + let none = ChainRunner::default() + .describe_chain(std::slice::from_ref(&unresolved)) + .await + .expect("describe unresolved chain"); + assert_eq!(middleware_chain_body_limit(&none), None); + } + + /// A middleware service whose single binding replaces every request body + /// with a fixed payload, for exercising post-transformation policy + /// re-evaluation. + struct BodyReplacingService { + replacement: &'static [u8], + } + + struct BlockingAllowService { + entered: Arc, + release: Arc, + } + + #[tonic::async_trait] + impl openshell_core::middleware::InProcessMiddleware for BlockingAllowService { + async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { + openshell_core::proto::MiddlewareManifest { + name: "test/blocking-allow".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 8192, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: openshell_core::middleware::HttpRequestView<'_>, + ) -> Result { + self.entered.notify_one(); + self.release.notified().await; + Ok(openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + ..Default::default() + }) + } + } + + #[tokio::test] + async fn connect_reacquires_static_credentials_after_blocked_middleware() { + let data = r#" +network_middlewares: + blocker: + middleware: test/blocking-allow + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/allowed" + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( + Arc::new(BlockingAllowService { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + )); + let input = NetworkInput { + host: "api.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint.expect("REST endpoint")) + .expect("parse REST config"); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "real-secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: 443, + path: "/allowed".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + request_default_port: Some(443), + policy_name: "rest_api".into(), + binary_path: "/usr/bin/node".into(), + provider_credentials: Some(state.clone()), + secret_resolver: state.resolver(), + ..Default::default() }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_rest( + &config, + &tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); - // A single unresolved (0-limit) entry must not drag the chain limit to - // zero: the buffer limit reflects only the resolved built-in. - let mixed = ChainRunner::new( - openshell_supervisor_middleware_builtins::services() - .into_iter() - .next() - .expect("built-in middleware service"), + app.write_all( + b"GET /allowed HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nConnection: close\r\n\r\n", ) - .describe_chain(&[resolved, unresolved.clone()]) .await - .expect("describe mixed chain"); - assert_eq!(middleware_chain_body_limit(&mixed), Some(256 * 1024)); - - // When nothing resolves, there is no body limit and the caller skips - // buffering entirely. - let none = ChainRunner::default() - .describe_chain(std::slice::from_ref(&unresolved)) - .await - .expect("describe unresolved chain"); - assert_eq!(middleware_chain_body_limit(&none), None); - } + .unwrap(); + entered.notified().await; + state.revoke_static_provider_environment(2); + release.notify_one(); - /// A middleware service whose single binding replaces every request body - /// with a fixed payload, for exercising post-transformation policy - /// re-evaluation. - struct BodyReplacingService { - replacement: &'static [u8], + relay.await.unwrap().expect("relay should fail closed"); + drop(app); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "revoked CONNECT credential request must not reach upstream" + ); } #[tonic::async_trait] - impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware - for BodyReplacingService - { - async fn describe( - &self, - _request: tonic::Request<()>, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new( - openshell_core::proto::MiddlewareManifest { - name: "test/rewriter".into(), - service_version: "test".into(), - bindings: vec![openshell_core::proto::MiddlewareBinding { - operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest - as i32, - phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials - as i32, - max_body_bytes: 8192, - timeout: String::new(), - }], - }, - )) + impl openshell_core::middleware::InProcessMiddleware for BodyReplacingService { + async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { + openshell_core::proto::MiddlewareManifest { + name: "test/rewriter".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 8192, + timeout: String::new(), + }], + expected_audience: String::new(), + } } async fn validate_config( &self, - _request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new( - openshell_core::proto::ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - )) + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) } async fn evaluate_http_request( &self, - _request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new( - openshell_core::proto::HttpRequestResult { - decision: openshell_core::proto::Decision::Allow as i32, - body: self.replacement.to_vec(), - has_body: true, - ..Default::default() - }, - )) + _request: openshell_core::middleware::HttpRequestView<'_>, + ) -> Result { + Ok(openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + body: self.replacement.to_vec(), + has_body: true, + ..Default::default() + }) } } @@ -3310,6 +5566,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "jsonrpc_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -3472,6 +5729,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "graphql_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -3571,6 +5829,7 @@ network_policies: let ctx = L7EvalContext { host: "db.example.test".into(), port: 5432, + request_default_port: Some(5432), policy_name: "sql_db".into(), binary_path: "/usr/bin/psql".into(), ancestors: vec![], @@ -3690,55 +5949,37 @@ network_policies: } #[tonic::async_trait] - impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware - for LimitService - { - async fn describe( - &self, - _request: tonic::Request<()>, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { + impl openshell_core::middleware::InProcessMiddleware for LimitService { + async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { use openshell_core::proto::{ MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, }; - Ok(tonic::Response::new(MiddlewareManifest { + MiddlewareManifest { name: self.name.into(), service_version: "test".into(), bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_body_bytes: self.max_body_bytes, + max_payload_bytes: self.max_body_bytes, timeout: String::new(), }], - })) + expected_audience: String::new(), + } } async fn validate_config( &self, - _request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new( - openshell_core::proto::ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - )) + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) } async fn evaluate_http_request( &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - let _evaluation = request.into_inner(); + _request: openshell_core::middleware::HttpRequestView<'_>, + ) -> Result { let mut result = openshell_core::proto::HttpRequestResult { decision: openshell_core::proto::Decision::Allow as i32, ..Default::default() @@ -3747,7 +5988,7 @@ network_policies: result.body = replacement.to_vec(); result.has_body = true; } - Ok(tonic::Response::new(result)) + Ok(result) } } @@ -3838,6 +6079,9 @@ network_policies: MiddlewareApplyResult::Denied { .. } => { panic!("body within the largest stage limit must not fail the chain") } + MiddlewareApplyResult::AdmissionExhausted => { + panic!("test middleware work admission must be available") + } } } @@ -3924,6 +6168,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 80, + request_default_port: Some(80), policy_name: "api".into(), binary_path: "/usr/bin/curl".into(), ancestors: Vec::new(), @@ -3933,6 +6178,7 @@ network_policies: }; let input = middleware_request_input( + openshell_ocsf::ctx::ctx(), "http", &req, &ctx, @@ -3956,6 +6202,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -4145,6 +6392,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 8080, + request_default_port: Some(8080), policy_name: "passthrough_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -4255,6 +6503,7 @@ network_policies: let ctx = L7EvalContext { host: "gateway.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "ws_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -4319,9 +6568,124 @@ network_policies: } #[tokio::test] - async fn passthrough_relay_injects_token_grant_authorization_header() { + async fn passthrough_relay_injects_token_grant_authorization_header() { + let (generation_guard, ctx, fixture) = + passthrough_token_grant_relay_context(Ok("grant-token")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + + assert!(upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); + assert!(!upstream_request.contains("stale-token")); + assert_eq!(authorization_header_count(&upstream_request), 1); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + } + + #[tokio::test] + async fn passthrough_relay_token_grant_failure_returns_bad_gateway_without_forwarding() { + let (generation_guard, ctx, fixture) = + passthrough_token_grant_relay_context(Err("oauth unavailable")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("bad gateway response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("502 Bad Gateway")); + + let mut upstream_request = [0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("upstream should close without forwarded data") + .unwrap(); + assert_eq!(n, 0, "unauthenticated request must not reach upstream"); + + fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + } + + #[tokio::test] + async fn passthrough_relay_injects_token_exchange_authorization_header() { let (generation_guard, ctx, fixture) = - passthrough_token_grant_relay_context(Ok("grant-token")); + passthrough_token_exchange_relay_context(Ok("grant-token")); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { @@ -4378,13 +6742,15 @@ network_policies: .unwrap() .unwrap(); - fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); } #[tokio::test] - async fn passthrough_relay_token_grant_failure_returns_bad_gateway_without_forwarding() { + async fn passthrough_relay_token_exchange_failure_returns_bad_gateway_without_forwarding() { let (generation_guard, ctx, fixture) = - passthrough_token_grant_relay_context(Err("oauth unavailable")); + passthrough_token_exchange_relay_context(Err("oauth unavailable")); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { @@ -4430,7 +6796,9 @@ network_policies: .unwrap(); assert_eq!(n, 0, "unauthenticated request must not reach upstream"); - fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); } #[test] @@ -4468,6 +6836,7 @@ network_policies: let ctx = L7EvalContext { host: "gateway.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "ws_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -4543,6 +6912,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "jsonrpc_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -4664,6 +7034,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "jsonrpc_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -4727,6 +7098,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "mcp_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -4830,71 +7202,374 @@ network_policies: "", ); - assert!(batch_message.starts_with("JSONRPC_L7_REQUEST ")); - assert!(batch_message.contains("rule_methods=reports.list,reports.archive")); - assert!(batch_message.contains("policy_version=43")); - assert!(!batch_message.contains("delete_resource")); + assert!(batch_message.starts_with("JSONRPC_L7_REQUEST ")); + assert!(batch_message.contains("rule_methods=reports.list,reports.archive")); + assert!(batch_message.contains("policy_version=43")); + assert!(!batch_message.contains("delete_resource")); + + let no_params = crate::l7::jsonrpc::parse_jsonrpc_body( + br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + crate::l7::jsonrpc::JsonRpcInspectionMode::JsonRpc, + ); + let no_params_message = jsonrpc_log_message( + "allow", + "POST", + "jsonrpc.example.com:443/rpc", + &no_params, + 44, + "", + ); + assert!(no_params_message.contains("rule_methods=initialize")); + } + + #[tokio::test] + async fn route_selected_jsonrpc_response_frame_hard_denies_under_audit() { + let data = r" +network_policies: + route_api: + name: route_api + endpoints: + - host: gateway.example.test + port: 443 + path: /rpc + protocol: json-rpc + enforcement: audit + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/node } +"; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "gateway.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let configs = vec![ + crate::l7::parse_l7_config(&endpoint.expect("JSON-RPC endpoint")) + .expect("parse JSON-RPC config"), + ]; + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "gateway.example.test".into(), + port: 443, + request_default_port: Some(443), + policy_name: "route_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + ..Default::default() + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#; + let request = format!( + "POST /rpc HTTP/1.1\r\nHost: gateway.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(request.as_bytes()).await.unwrap(); + app.write_all(body).await.unwrap(); + + let mut response = [0u8; 1024]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("hard denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!(response.contains("response frames"), "{response}"); + + let mut upstream_bytes = [0u8; 16]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_bytes), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "hard-denied response frame must not reach upstream" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + /// Policy allowing GET on both `/repos/**` and `/admin/**` for the same + /// host:port, so an encoded-slash denial can only come from the + /// per-endpoint `allow_encoded_slash` scoping. + const ENCODED_SLASH_SCOPING_POLICY: &str = r#" +network_policies: + route_api: + name: route_api + endpoints: + - host: gateway.example.test + port: 443 + path: /repos/** + protocol: rest + enforcement: enforce + allow_encoded_slash: true + rules: + - allow: + method: GET + path: "/repos/**" + - host: gateway.example.test + port: 443 + path: /admin/** + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/admin/**" + binaries: + - { path: /usr/bin/node } +"#; + + fn encoded_slash_scoping_configs() -> Vec { + let rest = |path: &str, allow_encoded_slash: bool| L7EndpointConfig { + protocol: L7Protocol::Rest, + path: path.into(), + tls: crate::l7::TlsMode::Auto, + enforcement: EnforcementMode::Enforce, + graphql_max_body_bytes: 0, + json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, + mcp_strict_tool_names: true, + allow_encoded_slash, + websocket_credential_rewrite: false, + request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, + websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), + signing_region: String::new(), + }; + // One endpoint opts in, the other does not. + vec![rest("/repos/**", true), rest("/admin/**", false)] + } + + fn encoded_slash_scoping_ctx() -> L7EvalContext { + L7EvalContext { + host: "gateway.example.test".into(), + port: 443, + request_default_port: Some(443), + policy_name: "route_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + ..Default::default() + } + } + + #[test] + fn unmatched_route_path_builds_denied_http_activity_event() { + let ctx = L7EvalContext { + host: "gateway.example.test".into(), + port: 443, + policy_name: "route_api".into(), + ..Default::default() + }; + + let event = build_l7_request_event( + &ctx, + "GET", + "/other", + "deny", + "l7", + "no L7 endpoint path matched request", + None, + ); + + assert_eq!(event.class_uid(), 4002); + assert_eq!(event.base().severity, SeverityId::Medium); + assert_eq!( + event.format_shorthand(), + "HTTP:GET [MED] DENIED GET http://gateway.example.test:443/other [policy:route_api engine:l7] [reason:L7_REQUEST deny GET gateway.example.test:443/other reason=no L7 endpoint path matched request]" + ); + } + + #[tokio::test] + async fn route_selected_unmatched_path_emits_denied_policy_activity() { + let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap(); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .unwrap(); + let configs = encoded_slash_scoping_configs(); + let (activity_tx, mut activity_rx) = tokio::sync::mpsc::channel(1); + let ctx = L7EvalContext { + activity_tx: Some(activity_tx), + ..encoded_slash_scoping_ctx() + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /other HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut response = [0u8; 1024]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&response[..n]).contains("403 Forbidden")); + + let mut upstream_bytes = [0u8; 16]; + assert!(matches!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_bytes) + ) + .await, + Err(_) | Ok(Ok(0)) + )); + let activity = tokio::time::timeout(std::time::Duration::from_secs(1), activity_rx.recv()) + .await + .expect("policy activity should be emitted") + .expect("activity channel should remain open"); + assert!(activity.denied); + assert_eq!(activity.deny_group, "l7_policy"); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + /// Canonicalization runs before the matching config is known, so + /// `allow_encoded_slash` is taken permissively across the whole + /// host:port. The endpoint that did *not* opt in must still reject a + /// `%2F`, otherwise one endpoint's opt-in silently loosens every other + /// endpoint sharing that host:port. + #[tokio::test] + async fn route_selected_encoded_slash_optin_does_not_leak_to_other_endpoints() { + let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap(); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .unwrap(); + let configs = encoded_slash_scoping_configs(); + let (activity_tx, mut activity_rx) = tokio::sync::mpsc::channel(1); + let ctx = L7EvalContext { + activity_tx: Some(activity_tx), + ..encoded_slash_scoping_ctx() + }; + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /admin/x%2Fy HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); - let no_params = crate::l7::jsonrpc::parse_jsonrpc_body( - br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, - crate::l7::jsonrpc::JsonRpcInspectionMode::JsonRpc, + let mut response = [0u8; 1024]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("not allowed on this endpoint"), + "denial must name the encoded-slash reason: {response}" ); - let no_params_message = jsonrpc_log_message( - "allow", - "POST", - "jsonrpc.example.com:443/rpc", - &no_params, - 44, - "", + + let mut upstream_bytes = [0u8; 16]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_bytes), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "request must not reach upstream" ); - assert!(no_params_message.contains("rule_methods=initialize")); + let activity = tokio::time::timeout(std::time::Duration::from_secs(1), activity_rx.recv()) + .await + .expect("parse rejection activity should be emitted") + .expect("activity channel should remain open"); + assert!(activity.denied); + assert_eq!(activity.deny_group, "l7_parse_rejection"); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); } + /// Credential redaction percent-decodes any segment holding a placeholder + /// and re-inserts the redacted form without re-encoding it. A `%2F` sharing + /// that segment therefore becomes a literal `/` in the redacted target, so + /// the scoping check must read the canonical target rather than the + /// redacted one — otherwise a placeholder is enough to smuggle an encoded + /// slash past an endpoint that never opted in. #[tokio::test] - async fn route_selected_jsonrpc_response_frame_hard_denies_under_audit() { - let data = r" -network_policies: - route_api: - name: route_api - endpoints: - - host: gateway.example.test - port: 443 - path: /rpc - protocol: json-rpc - enforcement: audit - rules: - - allow: - method: initialize - binaries: - - { path: /usr/bin/node } -"; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); - let input = NetworkInput { - host: "gateway.example.test".into(), - port: 443, - binary_path: PathBuf::from("/usr/bin/node"), - binary_sha256: "unused".into(), - ancestors: vec![], - cmdline_paths: vec![], - }; - let (endpoint, generation) = engine - .query_endpoint_config_with_generation(&input) - .expect("endpoint config"); - let configs = vec![ - crate::l7::parse_l7_config(&endpoint.expect("JSON-RPC endpoint")) - .expect("parse JSON-RPC config"), - ]; - let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + async fn route_selected_encoded_slash_check_survives_credential_redaction() { + let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap(); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .unwrap(); + let configs = encoded_slash_scoping_configs(); + let (child_env, resolver) = SecretResolver::from_provider_env( + std::iter::once(("TOKEN".to_string(), "real-token".to_string())).collect(), + ); + let placeholder = child_env.get("TOKEN").expect("placeholder env").clone(); let ctx = L7EvalContext { - host: "gateway.example.test".into(), - port: 443, - policy_name: "route_api".into(), - binary_path: "/usr/bin/node".into(), - ancestors: vec![], - cmdline_paths: vec![], - secret_resolver: None, - ..Default::default() + secret_resolver: resolver.map(Arc::new), + ..encoded_slash_scoping_ctx() }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { @@ -4908,22 +7583,24 @@ network_policies: .await }); - let body = br#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#; + // Placeholder and encoded slash in the same segment, on the endpoint + // that did NOT opt into encoded slashes. let request = format!( - "POST /rpc HTTP/1.1\r\nHost: gateway.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() + "GET /admin/{placeholder}%2Fx HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n" ); app.write_all(request.as_bytes()).await.unwrap(); - app.write_all(body).await.unwrap(); let mut response = [0u8; 1024]; let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) .await - .expect("hard denial should reach client") + .expect("denial should reach client") .unwrap(); let response = String::from_utf8_lossy(&response[..n]); assert!(response.contains("403 Forbidden"), "{response}"); - assert!(response.contains("response frames"), "{response}"); + assert!( + response.contains("not allowed on this endpoint"), + "redaction must not hide the encoded slash: {response}" + ); let mut upstream_bytes = [0u8; 16]; let result = tokio::time::timeout( @@ -4933,7 +7610,7 @@ network_policies: .await; assert!( matches!(result, Err(_) | Ok(Ok(0))), - "hard-denied response frame must not reach upstream" + "request must not reach upstream" ); drop(app); @@ -4944,6 +7621,156 @@ network_policies: .unwrap(); } + /// The converse: tightening the scope must not break the endpoint that + /// legitimately opted in. A GitLab-style encoded slug still reaches the + /// upstream verbatim. + #[tokio::test] + async fn route_selected_encoded_slash_still_allowed_on_opted_in_endpoint() { + let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap(); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .unwrap(); + let configs = encoded_slash_scoping_configs(); + let ctx = encoded_slash_scoping_ctx(); + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /repos/group%2Fproject HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_bytes = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_bytes), + ) + .await + .expect("opted-in request should reach upstream") + .unwrap(); + let forwarded = String::from_utf8_lossy(&upstream_bytes[..n]); + assert!( + forwarded.contains("GET /repos/group%2Fproject "), + "encoded slug must be forwarded verbatim: {forwarded}" + ); + + drop(app); + drop(upstream); + let _ = tokio::time::timeout(std::time::Duration::from_secs(1), relay).await; + } + + #[tokio::test] + async fn rest_websocket_middleware_inspects_compressed_wss_messages() { + let data = r#" +network_middlewares: + redact: + middleware: openshell/regex + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/ws" + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + install_builtin_middleware(&engine); + let input = NetworkInput { + host: "api.example.test".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + request_default_port: Some(8080), + policy_name: "rest_api".into(), + binary_path: "/usr/bin/node".into(), + ..Default::default() + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_rest( + &config, + &tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let scenario = tokio::time::timeout(std::time::Duration::from_secs(60), async { + app.write_all( + b"GET /ws HTTP/1.1\r\nHost: api.example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n\r\n", + ) + .await + .unwrap(); + + let upstream_headers = read_http_headers(&mut upstream).await; + let upstream_headers = String::from_utf8_lossy(&upstream_headers); + assert!(upstream_headers.contains( + "Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n" + )); + upstream + .write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nSec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n\r\n", + ) + .await + .unwrap(); + let response = read_http_headers(&mut app).await; + assert!(String::from_utf8_lossy(&response).contains("101 Switching Protocols")); + + app.write_all( + &crate::l7::websocket::compressed_masked_text_frame_for_test( + br#"{"token":"sk-1234567890abcdef"}"#, + ), + ) + .await + .unwrap(); + let frame = crate::l7::websocket::read_frame_for_test(&mut upstream).await; + assert_eq!( + crate::l7::websocket::decode_compressed_masked_text_frame_for_test(&frame), + r#"{"token":"[REDACTED]"}"# + ); + }) + .await; + relay.abort(); + let _ = relay.await; + scenario.expect("compressed WSS scenario should complete"); + } + #[tokio::test] async fn route_selected_websocket_upgrade_rejects_invalid_accept_without_forwarding_101() { let data = r#" @@ -4977,6 +7804,8 @@ network_policies: allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), @@ -4985,6 +7814,7 @@ network_policies: let ctx = L7EvalContext { host: "gateway.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "route_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -5046,6 +7876,99 @@ network_policies: assert_eq!(n, 0, "invalid response must not forward 101 headers"); } + #[tokio::test] + async fn websocket_rewrite_stays_parsed_when_live_credentials_are_revoked_before_upgrade() { + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "real-token".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.test".to_string(), + port: 443, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = state + .snapshot() + .child_env + .get("API_TOKEN") + .expect("placeholder") + .clone(); + state.revoke_static_provider_environment(2); + + let ctx = L7EvalContext { + host: "allowed.example.test".into(), + port: 443, + request_default_port: Some(443), + policy_name: "route_api".into(), + provider_credentials: Some(state), + ..Default::default() + }; + let (mut app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + handle_upgrade( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "allowed.example.test", + 443, + UpgradeRelayOptions { + websocket_request: true, + websocket: WebSocketUpgradeBehavior { + credential_rewrite: true, + ..Default::default() + }, + ctx: Some(&ctx), + target: "/allowed/socket".to_string(), + policy_name: "route_api".to_string(), + ..Default::default() + }, + ) + .await + }); + + app.write_all(&masked_text_frame(placeholder.as_bytes())) + .await + .unwrap(); + app.flush().await.unwrap(); + + let mut forwarded = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read_to_end(&mut forwarded), + ) + .await + .expect("revoked parsed relay should close upstream") + .unwrap(); + assert!( + forwarded.is_empty(), + "revoked credential frame must not be raw-relayed upstream" + ); + + let error = tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("parsed relay should finish after credential rejection") + .unwrap() + .expect_err("revoked placeholder must fail closed"); + assert!( + error + .to_string() + .contains("credential placeholder resolution"), + "{error}" + ); + } + #[tokio::test] async fn route_selected_websocket_rewrites_text_credentials_after_upgrade() { let data = r#" @@ -5083,6 +8006,8 @@ network_policies: allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), @@ -5095,6 +8020,7 @@ network_policies: let ctx = L7EvalContext { host: "gateway.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "route_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -5206,6 +8132,8 @@ network_policies: allow_encoded_slash: false, websocket_credential_rewrite: true, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, websocket_graphql_policy: true, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), @@ -5218,6 +8146,7 @@ network_policies: let ctx = L7EvalContext { host: "gateway.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "route_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -5315,6 +8244,18 @@ network_policies: frame } + async fn read_http_headers(reader: &mut R) -> Vec { + let mut bytes = Vec::new(); + let mut byte = [0u8; 1]; + loop { + reader.read_exact(&mut byte).await.unwrap(); + bytes.push(byte[0]); + if bytes.ends_with(b"\r\n\r\n") { + return bytes; + } + } + } + async fn read_text_frame( reader: &mut R, ) -> std::io::Result<(bool, String)> { @@ -5389,6 +8330,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 8080, + request_default_port: Some(8080), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -5480,6 +8422,7 @@ network_policies: let ctx = L7EvalContext { host: "api.example.test".into(), port: 8080, + request_default_port: Some(8080), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index fd9d373f81..93315a671a 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -14,7 +14,8 @@ use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, header_mutation}; use openshell_core::secrets::{ - SecretResolver, contains_reserved_credential_marker, rewrite_http_header_block, + CREDENTIAL_MARKER_SCAN_TAIL_BYTES, SecretResolver, contains_reserved_credential_marker, + contains_reserved_credential_marker_bytes, rewrite_http_header_block, }; use openshell_ocsf::ctx::ctx as ocsf_ctx; use sha1::{Digest, Sha1}; @@ -45,7 +46,7 @@ async fn max_middleware_body_bytes() -> usize { }]) .await .expect("describe built-in middleware"); - chain[0].max_body_bytes() + chain[0].max_payload_bytes() } const RELAY_BUF_SIZE: usize = 8192; const HTTP_METHOD_PREFIXES: &[&[u8]] = &[ @@ -240,6 +241,11 @@ async fn parse_http_request( if version != "HTTP/1.1" && version != "HTTP/1.0" { return Err(miette!("Unsupported HTTP version: {version}")); } + let host_authority = request_host_authority_from_str(header_str)?; + if version == "HTTP/1.1" && host_authority.is_none() { + return Err(miette!("HTTP/1.1 request is missing a Host header")); + } + validate_absolute_form_authority(&target, host_authority.as_ref())?; // Determine body framing from headers let body_length = parse_body_length(header_str)?; @@ -385,6 +391,130 @@ pub(crate) fn validate_http_request_header_block(headers: &[u8]) -> Result<()> { Ok(()) } +#[derive(Debug)] +pub(crate) struct RequestAuthority { + pub(crate) authority: http::uri::Authority, + pub(crate) effective_port: u16, +} + +pub(crate) fn request_authority( + raw_header: &[u8], + transport_default_port: Option, +) -> Result> { + let header_end = raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| miette!("HTTP request headers are missing the CRLF terminator"))? + + 4; + let headers = std::str::from_utf8(&raw_header[..header_end]) + .map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + let Some(host_authority) = request_host_authority_from_str(headers)? else { + return Ok(None); + }; + + let request_target = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| miette!("HTTP request is missing a request target"))?; + let absolute_uri = absolute_form_uri(request_target)?; + let (authority, default_port) = if let Some(uri) = absolute_uri { + let authority = uri + .authority() + .ok_or_else(|| miette!("HTTP absolute-form request target is missing an authority"))? + .clone(); + let default_port = default_port_for_scheme(uri.scheme_str()).ok_or_else(|| { + miette!("HTTP absolute-form request target uses an unsupported scheme") + })?; + (authority, default_port) + } else { + let default_port = transport_default_port + .ok_or_else(|| miette!("HTTP origin-form request transport scheme is unavailable"))?; + (host_authority, default_port) + }; + + let effective_port = authority.port_u16().unwrap_or(default_port); + Ok(Some(RequestAuthority { + authority, + effective_port, + })) +} + +fn request_host_authority_from_str(headers: &str) -> Result> { + let mut authorities = headers.lines().skip(1).filter_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("host").then_some(value.trim()) + }); + let Some(authority) = authorities.next() else { + return Ok(None); + }; + if authorities.next().is_some() { + return Err(miette!("HTTP request contains multiple Host headers")); + } + authority + .parse() + .map(Some) + .map_err(|_| miette!("HTTP request Host header contains an invalid authority")) +} + +fn validate_absolute_form_authority( + target: &str, + host_authority: Option<&http::uri::Authority>, +) -> Result<()> { + let Some(uri) = absolute_form_uri(target)? else { + return Ok(()); + }; + let request_authority = uri + .authority() + .ok_or_else(|| miette!("HTTP absolute-form request target is missing an authority"))?; + let host_authority = host_authority + .ok_or_else(|| miette!("HTTP absolute-form request is missing a Host header"))?; + if !authorities_match(request_authority, host_authority, uri.scheme_str()) { + return Err(miette!( + "HTTP absolute-form request authority does not match the Host header" + )); + } + Ok(()) +} + +/// Return a URI only when the request-target uses HTTP absolute form. +/// +/// Origin-form request-targets can legitimately contain `://` in a path or +/// query value, so a substring check would incorrectly make those requests +/// participate in authority validation. +fn absolute_form_uri(target: &str) -> Result> { + let uri = target + .parse::() + .map_err(|_| miette!("HTTP absolute-form request target contains an invalid URI"))?; + Ok(uri.scheme().is_some().then_some(uri)) +} + +fn authorities_match( + left: &http::uri::Authority, + right: &http::uri::Authority, + scheme: Option<&str>, +) -> bool { + if !normalized_authority_host(left.host()) + .eq_ignore_ascii_case(normalized_authority_host(right.host())) + { + return false; + } + let default_port = default_port_for_scheme(scheme); + left.port_u16().or(default_port) == right.port_u16().or(default_port) +} + +fn default_port_for_scheme(scheme: Option<&str>) -> Option { + match scheme { + Some(scheme) if scheme.eq_ignore_ascii_case("http") => Some(80), + Some(scheme) if scheme.eq_ignore_ascii_case("https") => Some(443), + _ => None, + } +} + +fn normalized_authority_host(host: &str) -> &str { + host.trim_end_matches('.') +} + fn validate_http_request_line(request_line: &str) -> Result<()> { let mut parts = request_line.split(' '); let method = parts @@ -593,9 +723,11 @@ where upstream, RelayRequestOptions { resolver, + credential_generation: None, generation_guard, websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, + deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", @@ -616,9 +748,11 @@ pub(crate) enum WebSocketExtensionMode { #[derive(Clone, Copy, Default)] pub(crate) struct RelayRequestOptions<'a> { pub(crate) resolver: Option<&'a SecretResolver>, + pub(crate) credential_generation: Option>, pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, pub(crate) websocket_extensions: WebSocketExtensionMode, pub(crate) request_body_credential_rewrite: bool, + pub(crate) deny_uninspected_credentials: bool, pub(crate) credential_signing: crate::l7::CredentialSigning, pub(crate) signing_service: &'a str, pub(crate) signing_region: &'a str, @@ -626,6 +760,38 @@ pub(crate) struct RelayRequestOptions<'a> { pub(crate) port: u16, } +#[derive(Clone, Copy)] +pub(crate) struct CredentialGenerationGuard<'a> { + state: &'a openshell_core::provider_credentials::ProviderCredentialState, + revision: u64, +} + +impl<'a> CredentialGenerationGuard<'a> { + pub(crate) fn new( + state: &'a openshell_core::provider_credentials::ProviderCredentialState, + revision: u64, + ) -> Self { + Self { state, revision } + } + + pub(crate) fn ensure_current(self) -> Result<()> { + if self.state.revision() == self.revision { + Ok(()) + } else { + Err(miette!( + "provider credential generation changed before upstream write" + )) + } + } +} + +fn ensure_credential_generation_current(options: RelayRequestOptions<'_>) -> Result<()> { + if let Some(guard) = options.credential_generation { + guard.ensure_current()?; + } + Ok(()) +} + pub(crate) async fn relay_http_request_with_options_guarded( req: &L7Request, client: &mut C, @@ -636,6 +802,7 @@ where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { + ensure_credential_generation_current(options)?; let header_end = req .raw_header .windows(4) @@ -677,8 +844,8 @@ where offered_subprotocols: request.subprotocols.clone(), }); - let rewrite_result = rewrite_http_header_block(&header_bytes, options.resolver) - .map_err(|e| miette!("credential injection failed: {e}"))?; + let rewrite_result = + rewrite_http_header_block(&header_bytes, options.resolver).map_err(miette::Report::new)?; if let Some(guard) = options.generation_guard { guard.ensure_current()?; @@ -706,19 +873,18 @@ where client.flush().await.into_diagnostic()?; } if let Some(resolver) = options.resolver { - let access_key_placeholder = - openshell_core::secrets::placeholder_for_env_key("AWS_ACCESS_KEY_ID"); - let secret_key_placeholder = - openshell_core::secrets::placeholder_for_env_key("AWS_SECRET_ACCESS_KEY"); - let session_token_placeholder = - openshell_core::secrets::placeholder_for_env_key("AWS_SESSION_TOKEN"); - - match ( - resolver.resolve_placeholder(&access_key_placeholder), - resolver.resolve_placeholder(&secret_key_placeholder), - ) { + let access_key = resolver + .resolve_current_env_key_checked("AWS_ACCESS_KEY_ID", "sigv4") + .map_err(miette::Report::new)?; + let secret_key = resolver + .resolve_current_env_key_checked("AWS_SECRET_ACCESS_KEY", "sigv4") + .map_err(miette::Report::new)?; + let session_token = resolver + .resolve_current_env_key_checked("AWS_SESSION_TOKEN", "sigv4") + .map_err(miette::Report::new)?; + + match (access_key, secret_key) { (Some(access_key), Some(secret_key)) => { - let session_token = resolver.resolve_placeholder(&session_token_placeholder); // Use explicit signing_region from policy if set, // otherwise extract from hostname. let region = if options.signing_region.is_empty() { @@ -815,6 +981,7 @@ where secret_key, session_token, )?; + ensure_credential_generation_current(options)?; upstream.write_all(&signed).await.into_diagnostic()?; } else { // Sign headers only, stream body through. @@ -834,6 +1001,7 @@ where session_token, signable_body, )?; + ensure_credential_generation_current(options)?; upstream .write_all(&signed_headers) .await @@ -917,11 +1085,29 @@ where options.generation_guard, ) .await?; + ensure_credential_generation_current(options)?; upstream.write_all(&body.headers).await.into_diagnostic()?; if !body.body.is_empty() { upstream.write_all(&body.body).await.into_diagnostic()?; } + } else if options.deny_uninspected_credentials { + if let Err(error) = relay_request_body_with_marker_guard( + req, + client, + upstream, + &rewrite_result.rewritten, + &req.raw_header[header_end..], + options.generation_guard, + ) + .await + { + if error.to_string().contains("credential placeholder") { + emit_uninspected_body_credential_denial(req, &options); + } + return Err(error); + } } else { + ensure_credential_generation_current(options)?; upstream .write_all(&rewrite_result.rewritten) .await @@ -972,6 +1158,218 @@ where Ok(outcome) } +#[derive(Default)] +struct ReservedMarkerStreamGuard { + pending: Vec, +} + +impl ReservedMarkerStreamGuard { + fn push(&mut self, bytes: &[u8]) -> Result> { + self.pending.extend_from_slice(bytes); + if contains_reserved_credential_marker_bytes(&self.pending) { + return Err(miette!( + "request body credential placeholder denied because rewrite is disabled" + )); + } + let safe_len = self + .pending + .len() + .saturating_sub(CREDENTIAL_MARKER_SCAN_TAIL_BYTES); + Ok(self.pending.drain(..safe_len).collect()) + } + + fn finish(mut self) -> Result> { + if contains_reserved_credential_marker_bytes(&self.pending) { + return Err(miette!( + "request body credential placeholder denied because rewrite is disabled" + )); + } + Ok(std::mem::take(&mut self.pending)) + } +} + +async fn relay_request_body_with_marker_guard( + req: &L7Request, + client: &mut C, + upstream: &mut U, + headers: &[u8], + already_read: &[u8], + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result<()> +where + C: AsyncRead + Unpin, + U: AsyncWrite + Unpin, +{ + upstream.write_all(headers).await.into_diagnostic()?; + match req.body_length { + BodyLength::None => { + let mut scanner = ReservedMarkerStreamGuard::default(); + let safe = scanner.push(already_read)?; + upstream.write_all(&safe).await.into_diagnostic()?; + upstream + .write_all(&scanner.finish()?) + .await + .into_diagnostic()?; + } + BodyLength::ContentLength(len) => { + let initial_len = usize::try_from(len) + .unwrap_or(usize::MAX) + .min(already_read.len()); + let mut scanner = ReservedMarkerStreamGuard::default(); + let safe = scanner.push(&already_read[..initial_len])?; + upstream.write_all(&safe).await.into_diagnostic()?; + let mut remaining = len.saturating_sub(initial_len as u64); + let mut buf = vec![0u8; RELAY_BUF_SIZE]; + while remaining > 0 { + let to_read = usize::try_from(remaining) + .unwrap_or(buf.len()) + .min(buf.len()); + let n = client.read(&mut buf[..to_read]).await.into_diagnostic()?; + if n == 0 { + return Err(miette!( + "Connection closed with {remaining} body bytes remaining" + )); + } + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + let safe = scanner.push(&buf[..n])?; + upstream.write_all(&safe).await.into_diagnostic()?; + remaining -= n as u64; + } + upstream + .write_all(&scanner.finish()?) + .await + .into_diagnostic()?; + } + BodyLength::Chunked => { + relay_chunked_with_marker_guard(client, upstream, already_read, generation_guard) + .await?; + } + } + Ok(()) +} + +async fn write_chunk(writer: &mut W, payload: &[u8]) -> Result<()> { + if payload.is_empty() { + return Ok(()); + } + writer + .write_all(format!("{:X}\r\n", payload.len()).as_bytes()) + .await + .into_diagnostic()?; + writer.write_all(payload).await.into_diagnostic()?; + writer.write_all(b"\r\n").await.into_diagnostic()?; + Ok(()) +} + +async fn relay_chunked_with_marker_guard( + client: &mut C, + upstream: &mut U, + already_read: &[u8], + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result<()> +where + C: AsyncRead + Unpin, + U: AsyncWrite + Unpin, +{ + let mut read_state = ChunkedReadState { + buffered_pos: 0, + wire_bytes: 0, + max_wire_bytes: None, + }; + let mut scanner = ReservedMarkerStreamGuard::default(); + + loop { + 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:?}"))?; + + if chunk_size == 0 { + write_chunk(upstream, &scanner.finish()?).await?; + upstream.write_all(b"0\r\n").await.into_diagnostic()?; + loop { + let trailer = + read_chunked_line(client, already_read, &mut read_state, generation_guard) + .await + .map_err(CollectChunkedError::into_report)?; + if contains_reserved_credential_marker_bytes(&trailer) { + return Err(miette!( + "request body credential placeholder denied because rewrite is disabled" + )); + } + upstream.write_all(&trailer).await.into_diagnostic()?; + upstream.write_all(b"\r\n").await.into_diagnostic()?; + if trailer.is_empty() { + return Ok(()); + } + } + } + + let mut remaining = chunk_size; + while remaining > 0 { + let block_len = remaining.min(RELAY_BUF_SIZE); + let mut block = Vec::with_capacity(block_len); + read_buffered_exact( + client, + already_read, + &mut read_state, + block_len, + &mut block, + generation_guard, + ) + .await + .map_err(CollectChunkedError::into_report)?; + write_chunk(upstream, &scanner.push(&block)?).await?; + remaining -= block_len; + } + + let mut terminator = Vec::with_capacity(2); + read_buffered_exact( + client, + already_read, + &mut read_state, + 2, + &mut terminator, + generation_guard, + ) + .await + .map_err(CollectChunkedError::into_report)?; + if terminator.as_slice() != b"\r\n" { + return Err(miette!("Chunk missing terminating CRLF")); + } + } +} + +fn emit_uninspected_body_credential_denial(req: &L7Request, options: &RelayRequestOptions<'_>) { + let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(openshell_ocsf::ActivityId::Traffic) + .action(openshell_ocsf::ActionId::Denied) + .disposition(openshell_ocsf::DispositionId::Blocked) + .severity(openshell_ocsf::SeverityId::High) + .status(openshell_ocsf::StatusId::Failure) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain( + options.host, + options.port, + )) + .message(format!( + "{} request body credential traffic denied for {}:{}", + req.action, options.host, options.port + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + crate::l7::emit_uninspected_credential_finding(options.host, "", "http-request-body"); +} + struct PreparedRequestBody { headers: Vec, body: Vec, @@ -1295,7 +1693,7 @@ fn rewrite_buffered_body( } else { resolver .rewrite_text_placeholders(&mut text, "request_body") - .map_err(|e| miette!("credential injection failed: {e}"))? + .map_err(miette::Report::new)? }; if replacements == 0 || contains_reserved_credential_marker(&text) { return Err(miette!( @@ -1342,7 +1740,7 @@ fn rewrite_form_urlencoded_body(body: &str, resolver: &SecretResolver) -> Result let mut rewritten_value = decoded_value; let field_replacements = resolver .rewrite_text_placeholders(&mut rewritten_value, "request_body") - .map_err(|e| miette!("credential injection failed: {e}"))?; + .map_err(miette::Report::new)?; if field_replacements == 0 || contains_reserved_credential_marker(&rewritten_value) { return Err(miette!( "request body credential rewrite left unresolved credential placeholders" @@ -1673,12 +2071,7 @@ fn is_rewritable_content_type(content_type: Option<&str>) -> bool { } fn body_bytes_contain_reserved_marker(body: &[u8]) -> bool { - if body.is_empty() { - return false; - } - String::from_utf8_lossy(body) - .split('\0') - .any(contains_reserved_credential_marker) + contains_reserved_credential_marker_bytes(body) } fn set_content_length(headers: &[u8], len: usize) -> Result> { @@ -2020,6 +2413,12 @@ fn validate_websocket_upgrade_request(raw_header: &[u8]) -> Result { parse_websocket_upgrade_request(raw_header).map(|request| request.is_some()) } +pub(crate) fn websocket_requested_subprotocols(raw_header: &[u8]) -> Result> { + Ok(parse_websocket_upgrade_request(raw_header)? + .map(|request| request.subprotocols) + .unwrap_or_default()) +} + fn parse_websocket_upgrade_request(raw_header: &[u8]) -> Result> { let header_str = std::str::from_utf8(raw_header) .map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; @@ -2219,14 +2618,36 @@ pub(crate) async fn send_middleware_failure_response( send_forbidden_json(policy_name, body, client).await } +/// Send a platform-owned availability response when shared middleware work +/// admission is exhausted before the request body is buffered. +pub(crate) async fn send_middleware_unavailable_response( + req: &L7Request, + policy_name: &str, + client: &mut C, + redacted_target: Option<&str>, + context: Option>, +) -> Result<()> { + let body = middleware_failure_response_body(req, policy_name, redacted_target, context); + send_json_response(policy_name, body, client, "503 Service Unavailable").await +} + async fn send_forbidden_json( policy_name: &str, body: serde_json::Value, client: &mut C, +) -> Result<()> { + send_json_response(policy_name, body, client, "403 Forbidden").await +} + +async fn send_json_response( + policy_name: &str, + body: serde_json::Value, + client: &mut C, + status: &str, ) -> Result<()> { let body_bytes = body.to_string(); let response = format!( - "HTTP/1.1 403 Forbidden\r\n\ + "HTTP/1.1 {status}\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ X-OpenShell-Policy: {}\r\n\ @@ -2760,7 +3181,7 @@ where if !options.client_requested_upgrade { return Ok(RelayOutcome::Consumed); } - let websocket_permessage_deflate = validate_websocket_response( + let (websocket_permessage_deflate, websocket_subprotocol) = validate_websocket_response( &header_str, options.websocket_extensions, options.websocket.as_ref(), @@ -2779,6 +3200,7 @@ where return Ok(RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, + websocket_subprotocol, }); } @@ -2908,9 +3330,10 @@ fn validate_websocket_response( headers: &str, mode: WebSocketExtensionMode, websocket: Option<&WebSocketResponseValidation>, -) -> Result { +) -> Result<(bool, Option)> { let Some(validation) = websocket else { - return validate_websocket_response_extensions_preserved(headers, mode); + return validate_websocket_response_extensions_preserved(headers, mode) + .map(|compressed| (compressed, None)); }; let mut upgrade_websocket = false; @@ -2970,11 +3393,11 @@ fn validate_websocket_response( "websocket upgrade response has multiple Sec-WebSocket-Protocol headers" )); } - if let Some(protocol) = selected_subprotocol + if let Some(ref protocol) = selected_subprotocol && !validation .offered_subprotocols .iter() - .any(|offered| offered == &protocol) + .any(|offered| offered == protocol) { return Err(miette!( "upstream selected WebSocket subprotocol that was not offered" @@ -2986,8 +3409,10 @@ fn validate_websocket_response( (None, Some(_)) => Err(miette!( "upstream negotiated WebSocket extension that was not offered" )), - (None | Some(_), None) => Ok(false), - (Some(expected), Some(actual)) if expected.eq_ignore_ascii_case(actual) => Ok(true), + (None | Some(_), None) => Ok((false, selected_subprotocol)), + (Some(expected), Some(actual)) if expected.eq_ignore_ascii_case(actual) => { + Ok((true, selected_subprotocol)) + } (Some(_), Some(_)) => Err(miette!( "upstream negotiated WebSocket extension that does not match the safe offer" )), @@ -3562,6 +3987,7 @@ mod tests { let RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, + .. } = outcome else { panic!("expected upgraded relay outcome"); @@ -3575,6 +4001,7 @@ mod tests { 443, crate::l7::relay::UpgradeRelayOptions { websocket_request: true, + assembly_budget: Some(crate::l7::websocket::WebSocketAssemblyBudget::default()), websocket: crate::l7::relay::WebSocketUpgradeBehavior { credential_rewrite, permessage_deflate: websocket_permessage_deflate, @@ -4380,7 +4807,7 @@ mod tests { &mut client, &[], None, - Some(openshell_supervisor_middleware::MAX_MIDDLEWARE_BODY_BYTES), + Some(openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES), ) .await .expect("chunked body should decode"); @@ -4408,7 +4835,7 @@ mod tests { &req, &mut client, None, - openshell_supervisor_middleware::MAX_MIDDLEWARE_BODY_BYTES, + openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES, ) .await .expect("oversized body should produce a capacity result"); @@ -4712,6 +5139,73 @@ mod tests { ); } + #[tokio::test] + async fn parse_http_request_rejects_absolute_authority_mismatched_with_host() { + let (mut client, mut peer) = tokio::io::duplex(1024); + peer.write_all( + b"GET http://attacker.example.test/v1 HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ) + .await + .unwrap(); + + let error = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect_err("absolute-form authority mismatch must fail closed"); + assert!( + error + .to_string() + .contains("request authority does not match the Host header"), + "{error}" + ); + } + + #[test] + fn origin_form_targets_with_embedded_urls_use_host_authority() { + let host: http::uri::Authority = "api.example.test".parse().unwrap(); + + for target in ["/fetch/http://example.test", "/?next=http://example.test"] { + assert!( + absolute_form_uri(target).unwrap().is_none(), + "{target} must remain origin-form" + ); + validate_absolute_form_authority(target, Some(&host)) + .expect("embedded URL must not trigger absolute-form validation"); + + let raw = format!("GET {target} HTTP/1.1\r\nHost: api.example.test\r\n\r\n"); + let authority = request_authority(raw.as_bytes(), Some(443)) + .unwrap() + .expect("origin-form request with Host must have an authority"); + assert_eq!(authority.authority, host); + assert_eq!(authority.effective_port, 443); + } + } + + #[tokio::test] + async fn parse_http_request_keeps_embedded_url_in_origin_form_path() { + let (mut client, mut peer) = tokio::io::duplex(1024); + peer.write_all( + b"GET /fetch/http://example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ) + .await + .unwrap(); + + let request = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect("embedded URL origin-form request must parse") + .expect("request must be present"); + assert_eq!(request.target, "/fetch/http:/example.test"); + assert_eq!( + request.raw_header, + b"GET /fetch/http:/example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ); + } + #[tokio::test] async fn parse_http_request_canonicalization_preserves_query_string() { let (mut client, mut writer) = tokio::io::duplex(4096); @@ -6075,7 +6569,7 @@ mod tests { offered_subprotocols: Vec::new(), }; - let negotiated = validate_websocket_response( + let (negotiated, subprotocol) = validate_websocket_response( "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nSec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n\r\n", WebSocketExtensionMode::PermessageDeflate, Some(&validation), @@ -6083,6 +6577,7 @@ mod tests { .expect("reordered safe extension params should canonicalize"); assert!(negotiated); + assert_eq!(subprotocol, None); } #[test] @@ -6105,7 +6600,7 @@ mod tests { #[test] fn preserve_mode_leaves_malformed_extension_response_raw() { - let negotiated = validate_websocket_response( + let (negotiated, subprotocol) = validate_websocket_response( "HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover=\"true\"\r\n\r\n", WebSocketExtensionMode::Preserve, None, @@ -6113,6 +6608,7 @@ mod tests { .expect("preserve mode should not parse or reject raw extension negotiation"); assert!(!negotiated); + assert_eq!(subprotocol, None); } #[test] @@ -6136,7 +6632,7 @@ mod tests { offered_subprotocols: vec!["chat".to_string(), "superchat".to_string()], }; - let negotiated = validate_websocket_response( + let (negotiated, subprotocol) = validate_websocket_response( "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nSec-WebSocket-Protocol: superchat\r\n\r\n", WebSocketExtensionMode::PermessageDeflate, Some(&validation), @@ -6144,6 +6640,7 @@ mod tests { .expect("offered subprotocol should validate"); assert!(!negotiated); + assert_eq!(subprotocol.as_deref(), Some("superchat")); } #[test] @@ -6714,6 +7211,109 @@ mod tests { assert!(!lower.contains("upgrade: h2c")); } + #[test] + fn streamed_body_guard_detects_marker_split_across_reads() { + let mut guard = ReservedMarkerStreamGuard::default(); + assert!(guard.push(b"prefix-openshell:res").is_ok()); + let error = guard + .push(b"olve:env:API_TOKEN-suffix") + .expect_err("split marker must be detected"); + assert!(error.to_string().contains("rewrite is disabled")); + } + + /// Worst case for the retained window: a fully percent-encoded marker, the + /// longest detectable wire form, delivered one byte short and then + /// completed. A window narrower than that form drains its leading bytes + /// before the match completes, and the remainder decodes to a non-marker. + #[test] + fn streamed_body_guard_detects_fully_encoded_marker_completed_by_one_byte() { + const ENCODED: &str = "%6F%70%65%6E%73%68%65%6C%6C%3A%72%65%73%6F%6C%76%65%3A%65%6E%76%3A"; + assert!( + contains_reserved_credential_marker(ENCODED), + "fixture must be a recognized marker form" + ); + assert_eq!( + ENCODED.len(), + 3 * openshell_core::secrets::PLACEHOLDER_PREFIX_PUBLIC.len(), + "fixture must stay the fully encoded form of the current marker" + ); + let (head, tail) = ENCODED.as_bytes().split_at(ENCODED.len() - 1); + + let mut guard = ReservedMarkerStreamGuard::default(); + let forwarded = guard.push(head).expect("partial marker is not a match"); + let error = guard + .push(tail) + .expect_err("encoded marker completed across reads must be detected"); + + assert!(error.to_string().contains("rewrite is disabled")); + assert!( + forwarded.is_empty(), + "no marker byte may be forwarded early" + ); + } + + #[tokio::test] + async fn guarded_content_length_does_not_forward_placeholder() { + let body = b"prefix-openshell:resolve:env:API_TOKEN-suffix"; + let split = 20; + let mut raw_header = format!( + "POST /api HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + raw_header.extend_from_slice(&body[..split]); + let req = L7Request { + action: "POST".to_string(), + target: "/api".to_string(), + query_params: HashMap::new(), + raw_header, + body_length: BodyLength::ContentLength(body.len() as u64), + }; + let (mut client_side, mut proxy_client) = tokio::io::duplex(1024); + client_side.write_all(&body[split..]).await.unwrap(); + drop(client_side); + let (mut proxy_upstream, mut upstream_side) = tokio::io::duplex(4096); + + let error = relay_http_request_with_options_guarded( + &req, + &mut proxy_client, + &mut proxy_upstream, + RelayRequestOptions { + deny_uninspected_credentials: true, + host: "api.example.com", + port: 443, + ..Default::default() + }, + ) + .await + .expect_err("placeholder must fail closed"); + assert!(error.to_string().contains("rewrite is disabled")); + + drop(proxy_upstream); + let mut forwarded = Vec::new(); + upstream_side.read_to_end(&mut forwarded).await.unwrap(); + assert!(!contains_reserved_credential_marker_bytes(&forwarded)); + } + + #[tokio::test] + async fn guarded_chunked_body_detects_encoded_placeholder() { + let wire = b"8\r\nprefix-o\r\n1D\r\npenshell%3Aresolve%3Aenv%3AKEY\r\n0\r\n\r\n"; + let (mut upstream_writer, mut upstream_reader) = tokio::io::duplex(4096); + let error = relay_chunked_with_marker_guard( + &mut tokio::io::empty(), + &mut upstream_writer, + wire, + None, + ) + .await + .expect_err("encoded placeholder must fail closed"); + assert!(error.to_string().contains("rewrite is disabled")); + drop(upstream_writer); + let mut forwarded = Vec::new(); + upstream_reader.read_to_end(&mut forwarded).await.unwrap(); + assert!(!String::from_utf8_lossy(&forwarded).contains("env%3AKEY")); + } + #[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/tls.rs b/crates/openshell-supervisor-network/src/l7/tls.rs index f7c923c690..2275a60d34 100644 --- a/crates/openshell-supervisor-network/src/l7/tls.rs +++ b/crates/openshell-supervisor-network/src/l7/tls.rs @@ -8,7 +8,7 @@ //! store, terminates TLS from the client (presenting dynamic certs per hostname), //! inspects the plaintext HTTP, then re-encrypts to upstream using real root CAs. -use miette::{IntoDiagnostic, Result}; +use miette::{IntoDiagnostic, Result, miette}; use rcgen::{CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; use rustls::{ClientConfig, ServerConfig}; @@ -180,7 +180,7 @@ pub async fn tls_terminate_client( Ok(tls_stream) } -/// Connect TLS to an upstream server, verifying against webpki-roots. +/// Connect TLS to an upstream server, verifying against the configured CA roots. /// /// Returns a TLS stream for re-encrypted upstream communication. pub async fn tls_connect_upstream( @@ -197,34 +197,75 @@ pub async fn tls_connect_upstream( Ok(tls_stream) } -/// Build a rustls `ClientConfig` with Mozilla + system root CAs for upstream connections. +/// Build a rustls `ClientConfig` using the configured CA root source. /// -/// `system_ca_bundle` is the pre-read PEM contents of the system CA bundle -/// (from [`read_system_ca_bundle`]). Pass the same string to [`write_ca_files`] -/// to avoid reading the bundle from disk twice. -pub fn build_upstream_client_config(system_ca_bundle: &str) -> Arc { +/// In `bundled-ca-roots` mode this uses Mozilla roots from `webpki-roots` overlaid +/// with any locally-installed CAs from `system_ca_bundle` (e.g. corporate or private +/// CAs added to `/etc/pki/ca-trust`). Duplicates with the Mozilla bundle are harmless. +/// +/// Without `bundled-ca-roots` this uses the platform/native trust store exclusively; +/// `system_ca_bundle` is ignored because the native store already reflects all +/// operator-installed trust anchors. +pub fn build_upstream_client_config(system_ca_bundle: &str) -> Result> { + let mut config = ClientConfig::builder() + .with_root_certificates(build_upstream_root_store(system_ca_bundle)?) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + Ok(Arc::new(config)) +} + +fn build_upstream_root_store(system_ca_bundle: &str) -> Result { let mut root_store = rustls::RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - // System bundles typically overlap with webpki-roots (Mozilla roots); - // duplicates are harmless and ensure we also pick up any custom/corporate CAs. - let (added, ignored) = load_pem_certs_into_store(&mut root_store, system_ca_bundle); - if added > 0 { - tracing::debug!(added, "Loaded system CA certificates for upstream TLS"); + #[cfg(feature = "bundled-ca-roots")] + { + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + // Overlay system/corporate CAs so custom trust anchors are honoured in + // default upstream builds. Duplicates with webpki-roots are harmless. + let (added, ignored) = load_pem_certs_into_store(&mut root_store, system_ca_bundle); + if added > 0 { + tracing::debug!(added, "loaded system CA certificates for upstream TLS"); + } + if ignored > 0 { + tracing::warn!( + ignored, + "some system CA certificates could not be parsed and were ignored" + ); + } + } + + #[cfg(not(feature = "bundled-ca-roots"))] + { + let _ = system_ca_bundle; // native store already includes operator-installed CAs + add_native_roots(&mut root_store)?; } + + if root_store.is_empty() { + return Err(miette!("no TLS root certificates available")); + } + + Ok(root_store) +} + +#[cfg(not(feature = "bundled-ca-roots"))] +fn add_native_roots(root_store: &mut rustls::RootCertStore) -> Result<()> { + let native_certs = rustls_native_certs::load_native_certs(); + let cert_count = native_certs.certs.len(); + let (added, ignored) = root_store.add_parsable_certificates(native_certs.certs); + let ignored = ignored + native_certs.errors.len(); + if ignored > 0 { - tracing::warn!( - ignored, - "Some system CA certificates could not be parsed and were ignored" - ); + tracing::debug!(ignored, "ignored unparsable native root certificates"); } - let mut config = ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; + if added == 0 { + return Err(miette!( + "no usable native TLS root certificates found ({cert_count} loaded, {ignored} ignored)" + )); + } - Arc::new(config) + Ok(()) } /// Write CA certificate files for the sandbox trust store. @@ -234,8 +275,7 @@ pub fn build_upstream_client_config(system_ca_bundle: &str) -> Arc /// 2. Combined bundle: system CAs + sandbox CA (for `SSL_CERT_FILE` which replaces default) /// /// `system_ca_bundle` is the pre-read PEM contents of the system CA bundle -/// (from [`read_system_ca_bundle`]). Pass the same string to -/// [`build_upstream_client_config`] to avoid reading the bundle from disk twice. +/// (from [`read_system_ca_bundle`]). /// /// Returns `(ca_cert_path, combined_bundle_path)`. pub fn write_ca_files( @@ -266,6 +306,7 @@ pub fn write_ca_files( /// Returns `(added, ignored)` counts. Invalid or unparseable certificates /// are silently ignored, matching the behavior of /// `RootCertStore::add_parsable_certificates`. +#[cfg_attr(not(feature = "bundled-ca-roots"), allow(dead_code))] fn load_pem_certs_into_store( root_store: &mut rustls::RootCertStore, pem_data: &str, @@ -289,7 +330,7 @@ fn load_pem_certs_into_store( /// /// Returns the PEM contents of the first non-empty bundle found, or an empty /// string if none of the well-known paths exist. Call once and pass the result -/// to both [`write_ca_files`] and [`build_upstream_client_config`]. +/// to [`write_ca_files`]. pub fn read_system_ca_bundle() -> String { for path in SYSTEM_CA_PATHS { if let Ok(contents) = std::fs::read_to_string(path) @@ -299,7 +340,6 @@ pub fn read_system_ca_bundle() -> String { } } // No system bundle found — combined file will contain only the sandbox CA. - // This is acceptable since the proxy uses webpki-roots independently. String::new() } @@ -426,7 +466,7 @@ mod tests { #[test] fn upstream_config_alpn() { let _ = rustls::crypto::ring::default_provider().install_default(); - let config = build_upstream_client_config(""); + let config = build_upstream_client_config("").unwrap(); assert_eq!(config.alpn_protocols, vec![b"http/1.1".to_vec()]); } diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index 2ca25e6e81..fc440c6547 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -26,6 +26,8 @@ pub struct TokenGrantRequest<'a> { pub audience: &'a str, pub scopes: &'a [String], pub cache_ttl_seconds: i64, + pub grant_type: i32, + pub requested_token_type: &'a str, } pub trait TokenGrantResolver: Send + Sync { @@ -45,13 +47,17 @@ impl TokenGrantResolver for SpiffeTokenGrantResolver { ) -> Pin> + Send + 'a>> { Box::pin(async move { crate::token_grant::obtain_provider_token( - request.provider_key, - request.token_endpoint, - request.jwt_svid_audience, - request.client_assertion_type, - request.audience, - request.scopes, - request.cache_ttl_seconds, + crate::token_grant::ObtainProviderTokenRequest { + provider_name: request.provider_key, + token_endpoint: request.token_endpoint, + jwt_svid_audience: request.jwt_svid_audience, + client_assertion_type: request.client_assertion_type, + audience: request.audience, + scopes: request.scopes, + cache_ttl_override: request.cache_ttl_seconds, + grant_type: request.grant_type, + requested_token_type: request.requested_token_type, + }, ) .await }) @@ -127,7 +133,7 @@ pub async fn inject_if_needed(req: L7Request, ctx: &L7EvalContext) -> Result( audience: &token_grant.audience, scopes: &token_grant.scopes, cache_ttl_seconds: token_grant.cache_ttl_seconds, + grant_type: token_grant.grant_type, + requested_token_type: &token_grant.requested_token_type, } } @@ -348,7 +356,10 @@ fn inject_header(raw_header: &[u8], header_name: &str, header_value: &str) -> Re #[cfg(test)] pub mod test_support { use super::*; - use openshell_core::proto::{ProviderCredentialTokenGrant, ProviderProfileCredential}; + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfileCredential, + }; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -366,6 +377,8 @@ pub mod test_support { audience: String, scopes: Vec, cache_ttl_seconds: i64, + grant_type: i32, + requested_token_type: String, } pub struct TokenGrantTestFixture { @@ -379,11 +392,27 @@ pub mod test_support { Self::new(key, Ok(token)) } + pub fn success_token_exchange(key: &str, token: &str) -> Self { + Self::new_with_grant(key, Ok(token), token_exchange_grant()) + } + pub fn failure(key: &str, error: &str) -> Self { Self::new(key, Err(error)) } + pub fn failure_token_exchange(key: &str, error: &str) -> Self { + Self::new_with_grant(key, Err(error), token_exchange_grant()) + } + fn new(key: &str, response: std::result::Result<&str, &str>) -> Self { + Self::new_with_grant(key, response, token_grant()) + } + + fn new_with_grant( + key: &str, + response: std::result::Result<&str, &str>, + token_grant: ProviderCredentialTokenGrant, + ) -> Self { let requests = Arc::new(Mutex::new(Vec::new())); let resolver = Arc::new(FakeTokenGrantResolver { requests: requests.clone(), @@ -397,7 +426,7 @@ pub mod test_support { name: "access_token".to_string(), auth_style: "bearer".to_string(), header_name: "Authorization".to_string(), - token_grant: Some(token_grant()), + token_grant: Some(token_grant), ..Default::default() }, ); @@ -419,6 +448,14 @@ pub mod test_support { self.resolver.clone() } + pub fn assert_no_requests(&self) { + let requests = self + .requests + .lock() + .expect("fake token grant requests lock poisoned"); + assert!(requests.is_empty(), "unexpected token grant requests"); + } + pub fn assert_one_request(&self, expected_provider_key: &str) { let requests = self .requests @@ -437,6 +474,39 @@ pub mod test_support { assert_eq!(request.audience, "api://example"); assert_eq!(request.scopes, ["read"]); assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!( + request.grant_type, + ProviderCredentialTokenGrantType::ClientCredentials as i32 + ); + assert!(request.requested_token_type.is_empty()); + } + + pub fn assert_one_token_exchange_request(&self, expected_provider_key: &str) { + let requests = self + .requests + .lock() + .expect("fake token grant requests lock poisoned"); + assert_eq!(requests.len(), 1); + + let request = &requests[0]; + assert_eq!(request.provider_key, expected_provider_key); + assert_eq!(request.token_endpoint, "https://auth.example.com/token"); + assert_eq!(request.jwt_svid_audience, "https://auth.example.com"); + assert_eq!( + request.client_assertion_type, + "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + ); + assert_eq!(request.audience, "api://example"); + assert_eq!(request.scopes, ["read"]); + assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!( + request.grant_type, + ProviderCredentialTokenGrantType::TokenExchange as i32 + ); + assert_eq!( + request.requested_token_type, + "urn:ietf:params:oauth:token-type:access_token" + ); } } @@ -450,6 +520,24 @@ pub mod test_support { scopes: vec!["read".to_string()], cache_ttl_seconds: 300, audience_overrides: Vec::new(), + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, + subject_token: None, + requested_token_type: String::new(), + } + } + + fn token_exchange_grant() -> ProviderCredentialTokenGrant { + ProviderCredentialTokenGrant { + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + .to_string(), + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "user_oidc_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:id_token".to_string(), + }), + requested_token_type: "urn:ietf:params:oauth:token-type:access_token".to_string(), + ..token_grant() } } @@ -466,6 +554,8 @@ pub mod test_support { audience: request.audience.to_string(), scopes: request.scopes.to_vec(), cache_ttl_seconds: request.cache_ttl_seconds, + grant_type: request.grant_type, + requested_token_type: request.requested_token_type.to_string(), }; Box::pin(async move { self.requests @@ -502,6 +592,12 @@ mod tests { 443, "/repos/owner/repo" )); + assert!(dynamic_credential_key_matches( + "api.example.com\t443\t/repos/**\trev:42\tgithub:access_token", + "api.example.com", + 443, + "/repos/owner/repo" + )); assert!(!dynamic_credential_key_matches( key, "uploads.example.com", @@ -743,6 +839,46 @@ mod tests { fixture.assert_one_request("api.example.com\t443\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn inject_if_needed_passes_token_exchange_grant_to_resolver() { + let fixture = TokenGrantTestFixture::success_token_exchange( + "api.example.com\t443\t/v1/**\tprovider:access_token", + "grant-token", + ); + + let ctx = L7EvalContext { + host: "api.example.com".into(), + port: 443, + policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: Some(fixture.dynamic_credentials()), + token_grant_resolver: Some(fixture.resolver()), + ..Default::default() + }; + let req = L7Request { + action: "GET".to_string(), + target: "/v1/projects".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /v1/projects HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let rewritten = inject_if_needed(req, &ctx) + .await + .expect("fake token exchange grant should inject"); + let rewritten = + String::from_utf8(rewritten.raw_header).expect("rewritten request should be UTF-8"); + + assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); + fixture.assert_one_token_exchange_request( + "api.example.com\t443\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn inject_if_needed_rejects_malformed_resolver_token() { let fixture = TokenGrantTestFixture::success( diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index bb59c5227b..6cd8aa4818 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -8,19 +8,30 @@ use crate::l7::relay::{L7EvalContext, evaluate_l7_request}; use crate::l7::{EnforcementMode, L7RequestInfo}; -use crate::opa::TunnelPolicyEngine; +use crate::opa::{PolicyGenerationGuard, TunnelPolicyEngine}; use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; use miette::{IntoDiagnostic, Result, miette}; -use openshell_core::secrets::SecretResolver; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_core::secrets::{SecretResolver, contains_reserved_credential_marker}; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, Endpoint, NetworkActivityBuilder, SeverityId, StatusId, ocsf_emit, }; use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration as StdDuration; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; -const MAX_TEXT_MESSAGE_BYTES: usize = 1024 * 1024; +const MAX_TEXT_MESSAGE_BYTES: usize = openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES; +pub const MAX_CONCURRENT_WEBSOCKET_ASSEMBLIES: usize = 32; +pub const MAX_QUEUED_WEBSOCKET_ASSEMBLIES: usize = MAX_CONCURRENT_WEBSOCKET_ASSEMBLIES * 2; const MAX_RAW_FRAME_PAYLOAD_BYTES: u64 = 16 * 1024 * 1024; +const MAX_MESSAGE_FRAGMENTS: usize = 4096; +const TEXT_MESSAGE_ASSEMBLY_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(30); +const TEXT_MESSAGE_ASSEMBLY_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(120); +const TEXT_MESSAGE_FORWARD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(120); const COPY_BUF_SIZE: usize = 8192; const OPCODE_CONTINUATION: u8 = 0x0; const OPCODE_TEXT: u8 = 0x1; @@ -28,6 +39,275 @@ const OPCODE_BINARY: u8 = 0x2; const OPCODE_CLOSE: u8 = 0x8; const OPCODE_PING: u8 = 0x9; const OPCODE_PONG: u8 = 0xA; +const CREDENTIAL_ENDPOINT_MISMATCH: &str = "websocket credential endpoint mismatch"; + +#[derive(Clone, Debug)] +pub struct WebSocketAssemblyBudget { + active: Arc, + waiters: Arc, +} + +impl Default for WebSocketAssemblyBudget { + fn default() -> Self { + Self::new( + MAX_CONCURRENT_WEBSOCKET_ASSEMBLIES, + MAX_QUEUED_WEBSOCKET_ASSEMBLIES, + ) + } +} + +impl WebSocketAssemblyBudget { + pub fn new(active: usize, waiters: usize) -> Self { + Self { + active: Arc::new(Semaphore::new(active)), + waiters: Arc::new(Semaphore::new(waiters)), + } + } + + pub async fn reserve(&self) -> Result { + if let Ok(active) = Arc::clone(&self.active).try_acquire_owned() { + return Ok(WebSocketAssemblyAdmissionOutcome::Admitted( + WebSocketAssemblyAdmission { _active: active }, + )); + } + let Ok(waiter) = Arc::clone(&self.waiters).try_acquire_owned() else { + return Ok(WebSocketAssemblyAdmissionOutcome::QueueExhausted); + }; + let active = Arc::clone(&self.active) + .acquire_owned() + .await + .map_err(|_| miette!("websocket assembly admission semaphore closed"))?; + drop(waiter); + Ok(WebSocketAssemblyAdmissionOutcome::Admitted( + WebSocketAssemblyAdmission { _active: active }, + )) + } +} + +#[derive(Debug)] +pub struct WebSocketAssemblyAdmission { + _active: OwnedSemaphorePermit, +} + +#[derive(Debug)] +pub enum WebSocketAssemblyAdmissionOutcome { + Admitted(WebSocketAssemblyAdmission), + QueueExhausted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WebSocketTerminationCause { + PeerDisconnect, + PolicyReload, + CapacityExhausted, + MiddlewareDenial, + MiddlewareFailure, + PolicyDenial, + InvalidUtf8, + ProtocolError, + MessageTooBig, +} + +impl WebSocketTerminationCause { + fn close_code(self) -> Option { + match self { + Self::PeerDisconnect => None, + Self::PolicyReload => Some(1012), + Self::CapacityExhausted => Some(1013), + Self::MiddlewareDenial | Self::MiddlewareFailure | Self::PolicyDenial => Some(1008), + Self::InvalidUtf8 => Some(1007), + Self::ProtocolError => Some(1002), + Self::MessageTooBig => Some(1009), + } + } + + fn session_end_reason(self) -> openshell_core::proto::WebSocketSessionEndReason { + match self { + Self::PeerDisconnect => { + openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect + } + Self::PolicyReload => openshell_core::proto::WebSocketSessionEndReason::PolicyReload, + Self::MiddlewareDenial => { + openshell_core::proto::WebSocketSessionEndReason::MiddlewareDenial + } + Self::PolicyDenial => openshell_core::proto::WebSocketSessionEndReason::PolicyDenial, + Self::CapacityExhausted | Self::MiddlewareFailure => { + openshell_core::proto::WebSocketSessionEndReason::MiddlewareFailure + } + Self::InvalidUtf8 | Self::ProtocolError | Self::MessageTooBig => { + openshell_core::proto::WebSocketSessionEndReason::ProtocolError + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrameFailureClass { + MessageAssemblyIdleTimeout, + MessageAssemblyTotalTimeout, + InvalidUtf8, + InvalidFragmentation, + InvalidCloseFrame, + InvalidControlFrame, + InvalidLength, + ReservedOpcode, + UnmaskedClientFrame, + RsvBits, + ProtocolError, +} + +impl FrameFailureClass { + fn as_str(self) -> &'static str { + match self { + Self::MessageAssemblyIdleTimeout => "message_assembly_idle_timeout", + Self::MessageAssemblyTotalTimeout => "message_assembly_total_timeout", + Self::InvalidUtf8 => "invalid_utf8", + Self::InvalidFragmentation => "invalid_fragmentation", + Self::InvalidCloseFrame => "invalid_close_frame", + Self::InvalidControlFrame => "invalid_control_frame", + Self::InvalidLength => "invalid_length", + Self::ReservedOpcode => "reserved_opcode", + Self::UnmaskedClientFrame => "unmasked_client_frame", + Self::RsvBits => "rsv_bits", + Self::ProtocolError => "protocol_error", + } + } +} + +#[derive(Debug)] +struct WebSocketTermination { + cause: WebSocketTerminationCause, + failure_class: Option, + error: miette::Report, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AssemblyTimeoutKind { + Idle, + Total, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrameErrorKind { + PeerDisconnect, + Protocol(FrameFailureClass), + InvalidUtf8, + MessageTooBig, + AssemblyTimeout(AssemblyTimeoutKind), +} + +#[derive(Debug)] +struct FrameError { + kind: FrameErrorKind, + error: miette::Report, +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(formatter) + } +} + +impl FrameError { + fn peer_io(context: &str, error: std::io::Error) -> Self { + Self { + kind: FrameErrorKind::PeerDisconnect, + error: miette!("{context}: {error}"), + } + } + + fn peer_disconnect(error: miette::Report) -> Self { + Self { + kind: FrameErrorKind::PeerDisconnect, + error, + } + } + + fn protocol(failure_class: FrameFailureClass, error: miette::Report) -> Self { + Self { + kind: FrameErrorKind::Protocol(failure_class), + error, + } + } + + fn invalid_utf8(error: miette::Report) -> Self { + Self { + kind: FrameErrorKind::InvalidUtf8, + error, + } + } + + fn message_too_big(error: miette::Report) -> Self { + Self { + kind: FrameErrorKind::MessageTooBig, + error, + } + } + + fn assembly_timeout(kind: AssemblyTimeoutKind) -> Self { + let error = match kind { + AssemblyTimeoutKind::Idle => { + miette!("websocket text message assembly idle timeout") + } + AssemblyTimeoutKind::Total => { + miette!("websocket text message assembly total timeout") + } + }; + Self { + kind: FrameErrorKind::AssemblyTimeout(kind), + error, + } + } +} + +impl From for WebSocketTermination { + fn from(frame_error: FrameError) -> Self { + let (cause, failure_class) = match frame_error.kind { + FrameErrorKind::PeerDisconnect => (WebSocketTerminationCause::PeerDisconnect, None), + FrameErrorKind::Protocol(failure_class) => ( + WebSocketTerminationCause::ProtocolError, + Some(failure_class), + ), + FrameErrorKind::InvalidUtf8 => ( + WebSocketTerminationCause::InvalidUtf8, + Some(FrameFailureClass::InvalidUtf8), + ), + FrameErrorKind::MessageTooBig => ( + WebSocketTerminationCause::MessageTooBig, + Some(FrameFailureClass::InvalidLength), + ), + FrameErrorKind::AssemblyTimeout(kind) => ( + WebSocketTerminationCause::ProtocolError, + Some(match kind { + AssemblyTimeoutKind::Idle => FrameFailureClass::MessageAssemblyIdleTimeout, + AssemblyTimeoutKind::Total => FrameFailureClass::MessageAssemblyTotalTimeout, + }), + ), + }; + Self { + cause, + failure_class, + error: frame_error.error, + } + } +} + +#[derive(Debug)] +enum WebSocketDecompressionError { + MessageTooBig(miette::Report), + Protocol(miette::Report), +} + +type WebSocketRelayResult = std::result::Result; +type FrameResult = std::result::Result; + +fn terminate(cause: WebSocketTerminationCause, error: miette::Report) -> WebSocketTermination { + WebSocketTermination { + cause, + failure_class: None, + error, + } +} #[derive(Debug)] struct FrameHeader { @@ -43,8 +323,146 @@ struct FrameHeader { #[derive(Debug)] enum FragmentState { None, - Text { payload: Vec, compressed: bool }, - Binary, + Text(TextMessageAssembly), + Binary { + fragment_count: usize, + coverage: Vec, + }, +} + +/// One admitted client text message while its complete logical payload is +/// being assembled. +/// +/// Keeping the admission permit with the buffer and deadlines makes every +/// timeout and terminal parser error release shared middleware capacity through +/// ordinary ownership. The total deadline includes interleaved control frames; +/// the idle deadline resets only when a client read makes progress. +#[derive(Debug)] +struct TextMessageAssembly { + payload: Vec, + compressed: bool, + fragment_count: usize, + assembly_admission: WebSocketAssemblyAdmission, + admission: Option, + total_deadline: tokio::time::Instant, +} + +impl TextMessageAssembly { + fn new( + compressed: bool, + assembly_admission: WebSocketAssemblyAdmission, + admission: Option, + ) -> Self { + Self { + payload: Vec::new(), + compressed, + fragment_count: 1, + assembly_admission, + admission, + total_deadline: tokio::time::Instant::now() + TEXT_MESSAGE_ASSEMBLY_TOTAL_TIMEOUT, + } + } + + async fn read_some( + &self, + reader: &mut R, + buffer: &mut [u8], + ) -> FrameResult { + if buffer.is_empty() { + return Ok(0); + } + let idle_deadline = tokio::time::Instant::now() + TEXT_MESSAGE_ASSEMBLY_IDLE_TIMEOUT; + tokio::select! { + biased; + () = tokio::time::sleep_until(self.total_deadline) => { + Err(FrameError::assembly_timeout(AssemblyTimeoutKind::Total)) + } + () = tokio::time::sleep_until(idle_deadline) => { + Err(FrameError::assembly_timeout(AssemblyTimeoutKind::Idle)) + } + result = reader.read(buffer) => { + result.map_err(|error| FrameError::peer_io("websocket client read failed", error)) + }, + } + } + + async fn read_exact( + &self, + reader: &mut R, + buffer: &mut [u8], + ) -> FrameResult<()> { + let mut filled = 0; + while filled < buffer.len() { + let read = self.read_some(reader, &mut buffer[filled..]).await?; + if read == 0 { + return Err(FrameError::peer_disconnect(miette!( + "websocket payload ended before declared length" + ))); + } + filled += read; + } + Ok(()) + } + + async fn read_payload( + &mut self, + reader: &mut R, + frame: &FrameHeader, + ) -> FrameResult<()> { + let next = read_masked_payload(reader, frame, Some(self)).await?; + append_text_fragment(&mut self.payload, next) + } + + fn ensure_payload_fits(&self, frame: &FrameHeader) -> FrameResult<()> { + let frame_len = usize::try_from(frame.payload_len).map_err(|_| { + FrameError::message_too_big(miette!("websocket text frame is too large to buffer")) + })?; + let complete_len = self.payload.len().checked_add(frame_len).ok_or_else(|| { + FrameError::message_too_big(miette!("websocket text message length overflow")) + })?; + if complete_len > MAX_TEXT_MESSAGE_BYTES { + return Err(FrameError::message_too_big(miette!( + "websocket text message exceeds {MAX_TEXT_MESSAGE_BYTES} byte limit" + ))); + } + Ok(()) + } + + fn add_fragment(&mut self) -> FrameResult<()> { + self.fragment_count = self.fragment_count.saturating_add(1); + if self.fragment_count > MAX_MESSAGE_FRAGMENTS { + return Err(FrameError::protocol( + FrameFailureClass::InvalidFragmentation, + miette!("websocket message exceeds {MAX_MESSAGE_FRAGMENTS} fragment limit"), + )); + } + Ok(()) + } + + async fn within_total( + &self, + future: impl Future>, + ) -> FrameResult { + tokio::time::timeout_at(self.total_deadline, future) + .await + .map_err(|_| FrameError::assembly_timeout(AssemblyTimeoutKind::Total))? + } + + fn into_parts( + self, + ) -> ( + Vec, + bool, + WebSocketAssemblyAdmission, + Option, + ) { + ( + self.payload, + self.compressed, + self.assembly_admission, + self.admission, + ) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -64,9 +482,16 @@ pub(super) struct InspectionOptions<'a> { pub(super) struct RelayOptions<'a> { pub(super) policy_name: &'a str, + pub(super) assembly_budget: WebSocketAssemblyBudget, pub(super) resolver: Option<&'a SecretResolver>, + pub(super) generation_guard: Option<&'a PolicyGenerationGuard>, + pub(super) provider_credentials: Option<&'a ProviderCredentialState>, + pub(super) target: &'a str, pub(super) inspector: Option>, pub(super) compression: WebSocketCompression, + pub(super) middleware_session: Option, + pub(super) middleware_context: Option<&'a L7EvalContext>, + pub(super) deny_uninspected_credentials: bool, } /// Relay an upgraded WebSocket connection with optional client text inspection, @@ -77,7 +502,7 @@ pub(super) async fn relay_with_options( overflow: Vec, host: &str, port: u16, - options: RelayOptions<'_>, + mut options: RelayOptions<'_>, ) -> Result<()> where C: AsyncRead + AsyncWrite + Unpin + Send, @@ -91,23 +516,101 @@ where client_write.flush().await.into_diagnostic()?; } - let client_to_server = - relay_client_to_server(&mut client_read, &mut upstream_write, host, port, &options); + let client_to_server = relay_client_to_server( + &mut client_read, + &mut upstream_write, + host, + port, + &mut options, + ); let server_to_client = async { tokio::io::copy(&mut upstream_read, &mut client_write) .await - .into_diagnostic()?; - client_write.flush().await.into_diagnostic()?; - Ok::<(), miette::Report>(()) + .map_err(|error| { + terminate( + WebSocketTerminationCause::PeerDisconnect, + miette!("websocket upstream relay ended: {error}"), + ) + })?; + client_write.flush().await.map_err(|error| { + terminate( + WebSocketTerminationCause::PeerDisconnect, + miette!("websocket client relay ended: {error}"), + ) + })?; + Ok::<_, WebSocketTermination>( + openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect, + ) }; let result = tokio::select! { result = client_to_server => result, result = server_to_client => result, }; + if let Err(termination) = &result { + observe_termination(host, port, options.policy_name, termination); + } + if let Err(termination) = &result { + let error = termination.error.to_string(); + if error.contains(CREDENTIAL_ENDPOINT_MISMATCH) { + emit_credential_endpoint_mismatch(host, port, options.policy_name); + let _ = write_policy_violation_close(&mut client_write).await; + } else if error.contains("credential") { + if let Some(code) = termination.cause.close_code() { + let payload = code.to_be_bytes(); + let _ = write_unmasked_close(&mut client_write, &payload).await; + } + } else if let Some(code) = termination.cause.close_code() { + let payload = code.to_be_bytes(); + let _ = write_masked_close(&mut upstream_write, &payload).await; + let _ = write_unmasked_close(&mut client_write, &payload).await; + } + } + if let Some(session) = options.middleware_session.take() { + let reason = match &result { + Ok(reason) => *reason, + Err(termination) => termination.cause.session_end_reason(), + }; + session.end(reason).await; + } let _ = upstream_write.shutdown().await; let _ = client_write.shutdown().await; - result + result.map(|_| ()).map_err(|termination| termination.error) +} + +async fn write_policy_violation_close(writer: &mut W) -> Result<()> { + let reason = b"credential endpoint mismatch"; + let mut frame = Vec::with_capacity(reason.len() + 4); + frame.push(0x80 | OPCODE_CLOSE); + frame.push(u8::try_from(reason.len() + 2).expect("close reason fits one-byte length")); + frame.extend_from_slice(&1008u16.to_be_bytes()); + frame.extend_from_slice(reason); + writer.write_all(&frame).await.into_diagnostic()?; + writer.flush().await.into_diagnostic() +} + +fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .firewall_rule(policy_name, "credential-binding") + .message(format!( + "WebSocket credential use denied: credential is not authorized for {host}:{port}" + )) + .status_detail("credential_endpoint_mismatch") + .build() + ); + ocsf_emit!(crate::l7::build_credential_endpoint_mismatch_finding( + policy_name, + host, + Some("websocket"), + "Provider credential endpoint binding mismatch; WebSocket closed", + )); } async fn relay_client_to_server( @@ -115,166 +618,254 @@ async fn relay_client_to_server( writer: &mut W, host: &str, port: u16, - options: &RelayOptions<'_>, -) -> Result<()> + options: &mut RelayOptions<'_>, +) -> WebSocketRelayResult where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { let mut fragments = FragmentState::None; let mut close_seen = false; - loop { - let Some(frame) = read_frame_header(reader).await.inspect_err(|e| { - emit_protocol_failure(host, port, options.policy_name, protocol_failure_class(e)); - })? + let assembly = match &fragments { + FragmentState::Text(assembly) => Some(assembly), + FragmentState::None | FragmentState::Binary { .. } => None, + }; + let Some(frame) = read_frame_header(reader, assembly) + .await + .map_err(WebSocketTermination::from)? else { - writer.shutdown().await.into_diagnostic()?; - return Ok(()); + let _ = writer.shutdown().await; + return Ok(if close_seen { + openshell_core::proto::WebSocketSessionEndReason::NormalClose + } else { + openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect + }); }; if close_seen { - let e = miette!("websocket frame received after close frame"); - emit_protocol_failure(host, port, options.policy_name, protocol_failure_class(&e)); - return Err(e); + return Err(FrameError::protocol( + FrameFailureClass::InvalidCloseFrame, + miette!("websocket frame received after close frame"), + ) + .into()); } - if let Err(e) = validate_frame_header(&frame, &fragments, options.compression) { - emit_protocol_failure(host, port, options.policy_name, protocol_failure_class(&e)); - return Err(e); + validate_frame_header(&frame, &fragments, options.compression) + .map_err(WebSocketTermination::from)?; + + if matches!( + frame.opcode, + OPCODE_TEXT | OPCODE_BINARY | OPCODE_CONTINUATION + ) { + ensure_generation_current(host, port, options)?; } match frame.opcode { OPCODE_TEXT => { - let payload = read_masked_payload(reader, &frame).await.inspect_err(|e| { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(e), - ); - })?; + if frame.payload_len > MAX_TEXT_MESSAGE_BYTES as u64 { + return Err(FrameError::message_too_big(miette!( + "websocket text message exceeds {MAX_TEXT_MESSAGE_BYTES} byte limit" + )) + .into()); + } + let assembly_outcome = + options.assembly_budget.reserve().await.map_err(|error| { + terminate(WebSocketTerminationCause::CapacityExhausted, error) + })?; + let assembly_admission = match assembly_outcome { + WebSocketAssemblyAdmissionOutcome::Admitted(admission) => admission, + WebSocketAssemblyAdmissionOutcome::QueueExhausted => { + return Err(terminate( + WebSocketTerminationCause::CapacityExhausted, + miette!( + "websocket assembly admission queue is full; refusing additional buffered work" + ), + )); + } + }; + ensure_generation_current(host, port, options)?; + let admission = if let Some(session) = options.middleware_session.as_mut() { + let message_admission = session.admit_message().await.map_err(|error| { + terminate(WebSocketTerminationCause::MiddlewareFailure, error) + })?; + match message_admission { + openshell_supervisor_middleware::WebSocketMessageAdmission::Bypass => { + options.middleware_session.take(); + None + } + openshell_supervisor_middleware::WebSocketMessageAdmission::Inspect( + admission, + ) => Some(admission), + } + } else { + None + }; + ensure_generation_current(host, port, options)?; let compressed = frame.rsv == 0x40; + let mut assembly = + TextMessageAssembly::new(compressed, assembly_admission, admission); + assembly + .read_payload(reader, &frame) + .await + .map_err(WebSocketTermination::from)?; + ensure_generation_current(host, port, options)?; if frame.fin { + let (payload, compressed, assembly_admission, admission) = + assembly.into_parts(); relay_text_payload( - writer, &frame, payload, false, compressed, host, port, options, - ) - .await - .inspect_err(|e| { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(e), - ); - })?; - } else { - fragments = FragmentState::Text { + writer, + &frame, payload, + assembly_admission, + admission, + false, compressed, - }; + host, + port, + options, + ) + .await?; + } else { + fragments = FragmentState::Text(assembly); } } - OPCODE_CONTINUATION => match &mut fragments { - FragmentState::Text { - payload, - compressed, - } => { - let next = read_masked_payload(reader, &frame).await.inspect_err(|e| { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(e), - ); - })?; - if let Err(e) = append_text_fragment(payload, next) { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(&e), - ); - return Err(e); + OPCODE_CONTINUATION => { + if let FragmentState::Text(assembly) = &mut fragments { + assembly + .add_fragment() + .map_err(WebSocketTermination::from)?; + if frame.payload_len > MAX_TEXT_MESSAGE_BYTES as u64 { + return Err(FrameError::message_too_big(miette!( + "websocket text message exceeds {MAX_TEXT_MESSAGE_BYTES} byte limit" + )) + .into()); } + assembly + .ensure_payload_fits(&frame) + .map_err(WebSocketTermination::from)?; + assembly + .read_payload(reader, &frame) + .await + .map_err(WebSocketTermination::from)?; + ensure_generation_current(host, port, options)?; if frame.fin { - let complete = std::mem::take(payload); - let was_compressed = *compressed; - fragments = FragmentState::None; + let FragmentState::Text(assembly) = + std::mem::replace(&mut fragments, FragmentState::None) + else { + unreachable!("validated text continuation state") + }; + let (complete, was_compressed, assembly_admission, admission) = + assembly.into_parts(); relay_text_payload( writer, &frame, complete, + assembly_admission, + admission, true, was_compressed, host, port, options, ) - .await - .inspect_err(|e| { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(e), - ); - })?; + .await?; + } + } else if let FragmentState::Binary { + fragment_count, + coverage, + } = &mut fragments + { + *fragment_count = fragment_count.saturating_add(1); + if *fragment_count > MAX_MESSAGE_FRAGMENTS { + return Err(FrameError::protocol( + FrameFailureClass::InvalidFragmentation, + miette!( + "websocket message exceeds {MAX_MESSAGE_FRAGMENTS} fragment limit" + ), + ) + .into()); } - } - FragmentState::Binary => { copy_raw_frame_payload(reader, writer, &frame) .await - .inspect_err(|e| { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(e), - ); - })?; + .map_err(WebSocketTermination::from)?; + let fragment_size = usize::try_from(frame.payload_len).unwrap_or(usize::MAX); + for record in coverage.iter_mut() { + record.original_size = record.original_size.saturating_add(fragment_size); + } if frame.fin { - fragments = FragmentState::None; + let FragmentState::Binary { coverage, .. } = + std::mem::replace(&mut fragments, FragmentState::None) + else { + unreachable!("validated binary continuation state") + }; + if let Some(ctx) = options.middleware_context { + crate::l7::middleware::emit_websocket_coverage(ctx, &coverage); + } } + } else { + return Err(FrameError::protocol( + FrameFailureClass::InvalidFragmentation, + miette!("websocket continuation frame without active fragmented message"), + ) + .into()); } - FragmentState::None => { - let e = - miette!("websocket continuation frame without active fragmented message"); - emit_protocol_failure( + } + OPCODE_BINARY => { + if options.deny_uninspected_credentials { + emit_uninspected_credential_denial( host, port, options.policy_name, - protocol_failure_class(&e), + "websocket-binary", ); - return Err(e); - } - }, - OPCODE_BINARY => { - if !frame.fin { - fragments = FragmentState::Binary; + return Err(terminate( + WebSocketTerminationCause::PolicyDenial, + miette!("websocket binary frame denied for credentialed endpoint"), + )); } + let initial_size = usize::try_from(frame.payload_len).unwrap_or(usize::MAX); + let coverage = options + .middleware_session + .as_mut() + .map(|session| { + session.observe_unsupported_message( + openshell_supervisor_middleware::WebSocketMessageType::Binary, + initial_size, + ) + }) + .unwrap_or_default(); copy_raw_frame_payload(reader, writer, &frame) .await - .inspect_err(|e| { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(e), - ); - })?; + .map_err(WebSocketTermination::from)?; + if frame.fin + && let Some(ctx) = options.middleware_context + { + crate::l7::middleware::emit_websocket_coverage(ctx, &coverage); + } else if !frame.fin { + fragments = FragmentState::Binary { + fragment_count: 1, + coverage, + }; + } } OPCODE_CLOSE | OPCODE_PING | OPCODE_PONG => { - relay_control_frame(reader, writer, &frame) - .await - .inspect_err(|e| { - emit_protocol_failure( - host, - port, - options.policy_name, - protocol_failure_class(e), - ); - })?; + let control_result = match &fragments { + FragmentState::Text(assembly) => { + assembly + .within_total(relay_control_frame( + reader, + writer, + &frame, + Some(assembly), + )) + .await + } + FragmentState::None | FragmentState::Binary { .. } => { + relay_control_frame(reader, writer, &frame, None).await + } + }; + control_result.map_err(WebSocketTermination::from)?; if frame.opcode == OPCODE_CLOSE { close_seen = true; } @@ -284,25 +875,41 @@ where } } -async fn read_frame_header(reader: &mut R) -> Result> { - let first = match reader.read_u8().await { - Ok(byte) => byte, - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::BrokenPipe - ) => - { - return Ok(None); - } - Err(e) => return Err(miette!("{e}")), +async fn read_exact_for_assembly( + reader: &mut R, + buffer: &mut [u8], + assembly: Option<&TextMessageAssembly>, +) -> FrameResult<()> { + match assembly { + Some(assembly) => assembly.read_exact(reader, buffer).await, + None => reader + .read_exact(buffer) + .await + .map(|_| ()) + .map_err(|error| FrameError::peer_io("websocket client read failed", error)), + } +} + +async fn read_frame_header( + reader: &mut R, + assembly: Option<&TextMessageAssembly>, +) -> FrameResult> { + let mut first = [0u8; 1]; + let first_read = match assembly { + Some(assembly) => assembly.read_some(reader, &mut first).await, + None => reader + .read(&mut first) + .await + .map_err(|error| FrameError::peer_io("websocket client read failed", error)), }; - let second = reader - .read_u8() - .await - .map_err(|e| miette!("malformed websocket frame header: {e}"))?; + let first = match first_read { + Ok(0) => return Ok(None), + Ok(_) => first[0], + Err(error) => return Err(error), + }; + let mut second = [0u8; 1]; + read_exact_for_assembly(reader, &mut second, assembly).await?; + let second = second[0]; let mut raw_header = vec![first, second]; let len_code = second & 0x7F; @@ -310,33 +917,32 @@ async fn read_frame_header(reader: &mut R) -> Result u64::from(len_code), 126 => { let mut bytes = [0u8; 2]; - reader - .read_exact(&mut bytes) - .await - .map_err(|e| miette!("malformed websocket extended length: {e}"))?; + read_exact_for_assembly(reader, &mut bytes, assembly).await?; raw_header.extend_from_slice(&bytes); let len = u64::from(u16::from_be_bytes(bytes)); if len < 126 { - return Err(miette!( - "websocket frame uses non-minimal 16-bit extended length" + return Err(FrameError::protocol( + FrameFailureClass::InvalidLength, + miette!("websocket frame uses non-minimal 16-bit extended length"), )); } len } 127 => { let mut bytes = [0u8; 8]; - reader - .read_exact(&mut bytes) - .await - .map_err(|e| miette!("malformed websocket extended length: {e}"))?; + read_exact_for_assembly(reader, &mut bytes, assembly).await?; if bytes[0] & 0x80 != 0 { - return Err(miette!("websocket frame uses non-canonical 64-bit length")); + return Err(FrameError::protocol( + FrameFailureClass::InvalidLength, + miette!("websocket frame uses non-canonical 64-bit length"), + )); } raw_header.extend_from_slice(&bytes); let len = u64::from_be_bytes(bytes); if u16::try_from(len).is_ok() { - return Err(miette!( - "websocket frame uses non-minimal 64-bit extended length" + return Err(FrameError::protocol( + FrameFailureClass::InvalidLength, + miette!("websocket frame uses non-minimal 64-bit extended length"), )); } len @@ -347,10 +953,7 @@ async fn read_frame_header(reader: &mut R) -> Result Result<()> { +) -> FrameResult<()> { if !valid_rsv_bits(frame, fragments, compression) { - return Err(miette!( - "websocket frame has unsupported RSV bits or extension state" + return Err(FrameError::protocol( + FrameFailureClass::RsvBits, + miette!("websocket frame has unsupported RSV bits or extension state"), )); } if !frame.masked { - return Err(miette!("websocket client frame is not masked")); + return Err(FrameError::protocol( + FrameFailureClass::UnmaskedClientFrame, + miette!("websocket client frame is not masked"), + )); } if !matches!( frame.opcode, @@ -390,34 +997,49 @@ fn validate_frame_header( | OPCODE_PING | OPCODE_PONG ) { - return Err(miette!("websocket frame uses reserved opcode")); + return Err(FrameError::protocol( + FrameFailureClass::ReservedOpcode, + miette!("websocket frame uses reserved opcode"), + )); } if matches!(frame.opcode, OPCODE_CLOSE | OPCODE_PING | OPCODE_PONG) { if !frame.fin { - return Err(miette!("websocket control frame is fragmented")); + return Err(FrameError::protocol( + FrameFailureClass::InvalidControlFrame, + miette!("websocket control frame is fragmented"), + )); } if frame.payload_len > 125 { - return Err(miette!("websocket control frame exceeds 125 bytes")); + return Err(FrameError::protocol( + FrameFailureClass::InvalidControlFrame, + miette!("websocket control frame exceeds 125 bytes"), + )); } } if matches!(frame.opcode, OPCODE_TEXT | OPCODE_BINARY) && !matches!(fragments, FragmentState::None) { - return Err(miette!( - "websocket data frame started before previous fragmented message completed" + return Err(FrameError::protocol( + FrameFailureClass::InvalidFragmentation, + miette!("websocket data frame started before previous fragmented message completed"), )); } if matches!(frame.opcode, OPCODE_CONTINUATION) && matches!(fragments, FragmentState::None) { - return Err(miette!( - "websocket continuation frame without active fragmented message" + return Err(FrameError::protocol( + FrameFailureClass::InvalidFragmentation, + miette!("websocket continuation frame without active fragmented message"), )); } if (frame.opcode == OPCODE_BINARY - || (frame.opcode == OPCODE_CONTINUATION && matches!(fragments, FragmentState::Binary))) + || (frame.opcode == OPCODE_CONTINUATION + && matches!(fragments, FragmentState::Binary { .. }))) && frame.payload_len > MAX_RAW_FRAME_PAYLOAD_BYTES { - return Err(miette!( - "websocket binary frame exceeds {MAX_RAW_FRAME_PAYLOAD_BYTES} byte relay limit" + return Err(FrameError::protocol( + FrameFailureClass::InvalidLength, + miette!( + "websocket binary frame exceeds {MAX_RAW_FRAME_PAYLOAD_BYTES} byte relay limit" + ), )); } Ok(()) @@ -440,93 +1062,305 @@ fn valid_rsv_bits( async fn read_masked_payload( reader: &mut R, frame: &FrameHeader, -) -> Result> { - let payload_len = usize::try_from(frame.payload_len) - .map_err(|_| miette!("websocket text frame is too large to buffer"))?; + assembly: Option<&TextMessageAssembly>, +) -> FrameResult> { + let payload_len = usize::try_from(frame.payload_len).map_err(|_| { + FrameError::message_too_big(miette!("websocket text frame is too large to buffer")) + })?; if payload_len > MAX_TEXT_MESSAGE_BYTES { - return Err(miette!( + return Err(FrameError::message_too_big(miette!( "websocket text message exceeds {MAX_TEXT_MESSAGE_BYTES} byte limit" - )); + ))); } let mut payload = vec![0u8; payload_len]; - reader - .read_exact(&mut payload) - .await - .map_err(|e| miette!("malformed websocket payload: {e}"))?; - let mask_key = frame - .mask_key - .ok_or_else(|| miette!("websocket client frame is not masked"))?; + read_exact_for_assembly(reader, &mut payload, assembly).await?; + let mask_key = frame.mask_key.ok_or_else(|| { + FrameError::protocol( + FrameFailureClass::UnmaskedClientFrame, + miette!("websocket client frame is not masked"), + ) + })?; apply_mask(&mut payload, mask_key); Ok(payload) } -fn append_text_fragment(buffer: &mut Vec, next: Vec) -> Result<()> { - let new_len = buffer - .len() - .checked_add(next.len()) - .ok_or_else(|| miette!("websocket text message length overflow"))?; +fn append_text_fragment(buffer: &mut Vec, next: Vec) -> FrameResult<()> { + let new_len = buffer.len().checked_add(next.len()).ok_or_else(|| { + FrameError::message_too_big(miette!("websocket text message length overflow")) + })?; if new_len > MAX_TEXT_MESSAGE_BYTES { - return Err(miette!( + return Err(FrameError::message_too_big(miette!( "websocket text message exceeds {MAX_TEXT_MESSAGE_BYTES} byte limit" - )); + ))); } buffer.extend_from_slice(&next); Ok(()) } -#[allow(clippy::too_many_arguments)] -async fn relay_text_payload( - writer: &mut W, +fn ensure_generation_current( + host: &str, + port: u16, + options: &RelayOptions<'_>, +) -> WebSocketRelayResult<()> { + ensure_generation_guard_current(host, port, options.policy_name, options.generation_guard) +} + +fn ensure_generation_guard_current( + host: &str, + port: u16, + policy_name: &str, + generation_guard: Option<&PolicyGenerationGuard>, +) -> WebSocketRelayResult<()> { + let Some(guard) = generation_guard else { + return Ok(()); + }; + guard.ensure_current().map_err(|error| { + crate::l7::relay::emit_policy_reload(guard, host, port, policy_name); + terminate(WebSocketTerminationCause::PolicyReload, error) + }) +} + +#[allow(clippy::too_many_arguments)] +async fn relay_text_payload( + writer: &mut W, frame: &FrameHeader, payload: Vec, + assembly_admission: WebSocketAssemblyAdmission, + admission: Option, force_reframe: bool, compressed: bool, host: &str, port: u16, - options: &RelayOptions<'_>, -) -> Result<()> { + options: &mut RelayOptions<'_>, +) -> WebSocketRelayResult<()> { + relay_text_payload_with_before_credential_write( + writer, + frame, + payload, + assembly_admission, + admission, + force_reframe, + compressed, + host, + port, + options, + std::future::ready(()), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn relay_text_payload_with_before_credential_write( + writer: &mut W, + frame: &FrameHeader, + payload: Vec, + _assembly_admission: WebSocketAssemblyAdmission, + admission: Option, + force_reframe: bool, + compressed: bool, + host: &str, + port: u16, + options: &mut RelayOptions<'_>, + before_credential_write: F, +) -> WebSocketRelayResult<()> +where + W: AsyncWrite + Unpin, + F: Future, +{ + ensure_generation_current(host, port, options)?; let message_payload = if compressed { - decompress_permessage_deflate(&payload)? + decompress_permessage_deflate(&payload).map_err(|error| match error { + WebSocketDecompressionError::MessageTooBig(error) => { + WebSocketTermination::from(FrameError::message_too_big(error)) + } + WebSocketDecompressionError::Protocol(error) => WebSocketTermination::from( + FrameError::protocol(FrameFailureClass::ProtocolError, error), + ), + })? } else { payload }; - let mut text = String::from_utf8(message_payload) - .map_err(|_| miette!("websocket text message is not valid UTF-8"))?; - let replacements = if let Some(resolver) = options.resolver { + let mut text = String::from_utf8(message_payload).map_err(|_| { + WebSocketTermination::from(FrameError::invalid_utf8(miette!( + "websocket text message is not valid UTF-8" + ))) + })?; + let live_resolver = options.provider_credentials.map(|credentials| { + let (resolver, revision) = + credentials.resolver_for_endpoint_with_revision(host, port, options.target); + ( + resolver, + crate::l7::rest::CredentialGenerationGuard::new(credentials, revision), + ) + }); + let resolver = live_resolver + .as_ref() + .map_or(options.resolver, |(resolver, _)| resolver.as_deref()); + if options.deny_uninspected_credentials + && resolver.is_none() + && contains_reserved_credential_marker(&text) + { + emit_uninspected_credential_denial(host, port, options.policy_name, "websocket-text"); + return Err(terminate( + WebSocketTerminationCause::PolicyDenial, + miette!("websocket credential placeholder denied because rewrite is disabled"), + )); + } + + // Built-in transport/GraphQL inspection sees the original unresolved + // message. External transformations run next, then policy is re-evaluated + // before credential material is introduced. + if let Some(inspector) = options.inspector.as_ref() { + inspect_websocket_text_message(host, port, options.policy_name, inspector, &text)?; + } + ensure_generation_current(host, port, options)?; + + let mut middleware_transformed = false; + if let Some(session) = options.middleware_session.as_mut() { + let admission = admission.ok_or_else(|| { + terminate( + WebSocketTerminationCause::MiddlewareFailure, + miette!("websocket middleware message missing work admission"), + ) + })?; + let outcome = session.evaluate_text_admitted(text, admission).await; + ensure_generation_current(host, port, options)?; + if let Some(ctx) = options.middleware_context { + crate::l7::middleware::emit_websocket_message_events(ctx, &outcome); + } + if !outcome.allowed { + if outcome.platform_oversize { + return Err(FrameError::message_too_big(miette!( + "websocket message over middleware platform capacity" + )) + .into()); + } + let cause = if outcome.denial.is_some() { + WebSocketTerminationCause::MiddlewareDenial + } else { + WebSocketTerminationCause::MiddlewareFailure + }; + return Err(terminate( + cause, + miette!("websocket middleware denied message: {}", outcome.reason), + )); + } + middleware_transformed = outcome + .invocations + .iter() + .any(|invocation| invocation.transformed); + text = outcome.payload; + } + + if middleware_transformed && let Some(inspector) = options.inspector.as_ref() { + inspect_websocket_text_message(host, port, options.policy_name, inspector, &text)?; + } + ensure_generation_current(host, port, options)?; + + let replacements = if let Some(resolver) = resolver { resolver .rewrite_websocket_text_placeholders(&mut text) - .map_err(|_| miette!("websocket credential placeholder resolution failed"))? + .map_err(|error| { + if error.is_endpoint_mismatch() { + terminate( + WebSocketTerminationCause::PolicyDenial, + miette!(CREDENTIAL_ENDPOINT_MISMATCH), + ) + } else { + terminate( + WebSocketTerminationCause::MiddlewareFailure, + miette!("websocket credential placeholder resolution failed"), + ) + } + })? + } else if contains_reserved_credential_marker(&text) { + return Err(terminate( + WebSocketTerminationCause::MiddlewareFailure, + miette!("websocket credential placeholder resolution failed"), + )); } else { 0 }; + ensure_generation_current(host, port, options)?; - if let Some(inspector) = options.inspector.as_ref() { - inspect_websocket_text_message(host, port, options.policy_name, inspector, &text)?; - } - - if replacements == 0 && !force_reframe && !compressed { - writer - .write_all(&frame.raw_header) - .await - .into_diagnostic()?; + if replacements == 0 && !middleware_transformed && !force_reframe && !compressed { let mut payload = text.into_bytes(); - let mask_key = frame - .mask_key - .ok_or_else(|| miette!("websocket client frame is not masked"))?; + let mask_key = frame.mask_key.ok_or_else(|| { + WebSocketTermination::from(FrameError::protocol( + FrameFailureClass::UnmaskedClientFrame, + miette!("websocket client frame is not masked"), + )) + })?; apply_mask(&mut payload, mask_key); - writer.write_all(&payload).await.into_diagnostic()?; - writer.flush().await.into_diagnostic()?; - return Ok(()); + return write_text_frame_guarded( + writer, + &frame.raw_header, + &payload, + host, + port, + options.policy_name, + options.generation_guard, + ) + .await; } if replacements > 0 { emit_rewrite_event(host, port, options.policy_name, replacements); } + if replacements > 0 { + before_credential_write.await; + if let Some((_, guard)) = live_resolver { + guard + .ensure_current() + .map_err(|error| terminate(WebSocketTerminationCause::PolicyDenial, error))?; + } + ensure_generation_current(host, port, options)?; + } if compressed { - let compressed_payload = compress_permessage_deflate(text.as_bytes())?; - return write_masked_frame_with_rsv(writer, OPCODE_TEXT, 0x40, &compressed_payload).await; + let compressed_payload = compress_permessage_deflate(text.as_bytes()).map_err(|error| { + WebSocketTermination::from(FrameError::protocol( + FrameFailureClass::ProtocolError, + error, + )) + })?; + return write_masked_text_frame_guarded( + writer, + 0x40, + &compressed_payload, + host, + port, + options.policy_name, + options.generation_guard, + ) + .await; } - write_masked_frame(writer, OPCODE_TEXT, text.as_bytes()).await + write_masked_text_frame_guarded( + writer, + 0, + text.as_bytes(), + host, + port, + options.policy_name, + options.generation_guard, + ) + .await +} + +fn emit_uninspected_credential_denial(host: &str, port: u16, policy_name: &str, surface: &str) { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Traffic) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .firewall_rule(policy_name, "l7-websocket") + .message(format!( + "WebSocket credential traffic denied for {host}:{port}" + )) + .build(); + ocsf_emit!(event); + crate::l7::emit_uninspected_credential_finding(host, policy_name, surface); } fn inspect_websocket_text_message( @@ -535,7 +1369,7 @@ fn inspect_websocket_text_message( policy_name: &str, inspector: &InspectionOptions<'_>, text: &str, -) -> Result<()> { +) -> WebSocketRelayResult<()> { if inspector.graphql_policy { return inspect_graphql_websocket_message(host, port, policy_name, inspector, text); } @@ -547,7 +1381,8 @@ fn inspect_websocket_text_message( graphql: None, jsonrpc: None, }; - let (allowed, reason) = evaluate_l7_request(inspector.engine, inspector.ctx, &request_info)?; + let (allowed, reason) = evaluate_l7_request(inspector.engine, inspector.ctx, &request_info) + .map_err(|error| terminate(WebSocketTerminationCause::PolicyReload, error))?; let decision = match (allowed, inspector.enforcement) { (true, _) => "allow", (false, EnforcementMode::Audit) => "audit", @@ -563,7 +1398,10 @@ fn inspect_websocket_text_message( None, ); if !allowed && inspector.enforcement == EnforcementMode::Enforce { - return Err(miette!("websocket text message denied by policy")); + return Err(terminate( + WebSocketTerminationCause::PolicyDenial, + miette!("websocket text message denied by policy"), + )); } Ok(()) } @@ -574,7 +1412,7 @@ fn inspect_graphql_websocket_message( policy_name: &str, inspector: &InspectionOptions<'_>, text: &str, -) -> Result<()> { +) -> WebSocketRelayResult<()> { match classify_graphql_websocket_message(text) { GraphqlWebSocketMessage::Control { message_type } => { let request_info = L7RequestInfo { @@ -614,7 +1452,8 @@ fn inspect_graphql_websocket_message( let (allowed, reason) = if let Some(reason) = parse_error_reason { (false, reason) } else { - evaluate_l7_request(inspector.engine, inspector.ctx, &request_info)? + evaluate_l7_request(inspector.engine, inspector.ctx, &request_info) + .map_err(|error| terminate(WebSocketTerminationCause::PolicyReload, error))? }; let decision = match (allowed, inspector.enforcement) { (_, _) if force_deny => "deny", @@ -633,7 +1472,10 @@ fn inspect_graphql_websocket_message( Some(&graphql), ); if (!allowed && inspector.enforcement == EnforcementMode::Enforce) || force_deny { - return Err(miette!("websocket GraphQL message denied by policy")); + return Err(terminate( + WebSocketTerminationCause::PolicyDenial, + miette!("websocket GraphQL message denied by policy"), + )); } Ok(()) } @@ -728,24 +1570,29 @@ async fn relay_control_frame( reader: &mut R, writer: &mut W, frame: &FrameHeader, -) -> Result<()> + assembly: Option<&TextMessageAssembly>, +) -> FrameResult<()> where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { - let raw_payload_len = usize::try_from(frame.payload_len) - .map_err(|_| miette!("websocket control frame payload length overflow"))?; + let raw_payload_len = usize::try_from(frame.payload_len).map_err(|_| { + FrameError::protocol( + FrameFailureClass::InvalidControlFrame, + miette!("websocket control frame payload length overflow"), + ) + })?; let mut raw_payload = vec![0u8; raw_payload_len]; - reader - .read_exact(&mut raw_payload) - .await - .map_err(|e| miette!("malformed websocket control payload: {e}"))?; + read_exact_for_assembly(reader, &mut raw_payload, assembly).await?; if frame.opcode == OPCODE_CLOSE { let mut payload = raw_payload.clone(); - let mask_key = frame - .mask_key - .ok_or_else(|| miette!("websocket client frame is not masked"))?; + let mask_key = frame.mask_key.ok_or_else(|| { + FrameError::protocol( + FrameFailureClass::UnmaskedClientFrame, + miette!("websocket client frame is not masked"), + ) + })?; apply_mask(&mut payload, mask_key); validate_close_payload(&payload)?; } @@ -753,16 +1600,23 @@ where writer .write_all(&frame.raw_header) .await - .into_diagnostic()?; - writer.write_all(&raw_payload).await.into_diagnostic()?; - writer.flush().await.into_diagnostic()?; + .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; + writer + .write_all(&raw_payload) + .await + .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; + writer + .flush() + .await + .map_err(|error| FrameError::peer_io("websocket upstream flush failed", error))?; Ok(()) } -fn validate_close_payload(payload: &[u8]) -> Result<()> { +fn validate_close_payload(payload: &[u8]) -> FrameResult<()> { if payload.len() == 1 { - return Err(miette!( - "websocket close frame payload cannot be exactly one byte" + return Err(FrameError::protocol( + FrameFailureClass::InvalidCloseFrame, + miette!("websocket close frame payload cannot be exactly one byte"), )); } if payload.len() < 2 { @@ -771,10 +1625,15 @@ fn validate_close_payload(payload: &[u8]) -> Result<()> { let code = u16::from_be_bytes([payload[0], payload[1]]); if !valid_close_code(code) { - return Err(miette!("websocket close frame uses invalid close code")); + return Err(FrameError::protocol( + FrameFailureClass::InvalidCloseFrame, + miette!("websocket close frame uses invalid close code"), + )); } if std::str::from_utf8(&payload[2..]).is_err() { - return Err(miette!("websocket close frame reason is not valid UTF-8")); + return Err(FrameError::invalid_utf8(miette!( + "websocket close frame reason is not valid UTF-8" + ))); } Ok(()) } @@ -787,7 +1646,7 @@ async fn copy_raw_frame_payload( reader: &mut R, writer: &mut W, frame: &FrameHeader, -) -> Result<()> +) -> FrameResult<()> where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, @@ -795,21 +1654,32 @@ where writer .write_all(&frame.raw_header) .await - .into_diagnostic()?; + .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; let mut remaining = frame.payload_len; let mut buf = [0u8; COPY_BUF_SIZE]; while remaining > 0 { let to_read = usize::try_from(remaining) .unwrap_or(buf.len()) .min(buf.len()); - let n = reader.read(&mut buf[..to_read]).await.into_diagnostic()?; + let n = reader + .read(&mut buf[..to_read]) + .await + .map_err(|error| FrameError::peer_io("websocket client read failed", error))?; if n == 0 { - return Err(miette!("websocket payload ended before declared length")); + return Err(FrameError::peer_disconnect(miette!( + "websocket payload ended before declared length" + ))); } - writer.write_all(&buf[..n]).await.into_diagnostic()?; + writer + .write_all(&buf[..n]) + .await + .map_err(|error| FrameError::peer_io("websocket upstream write failed", error))?; remaining -= n as u64; } - writer.flush().await.into_diagnostic()?; + writer + .flush() + .await + .map_err(|error| FrameError::peer_io("websocket upstream flush failed", error))?; Ok(()) } @@ -821,12 +1691,105 @@ async fn write_masked_frame( write_masked_frame_with_rsv(writer, opcode, 0, payload).await } +pub(super) async fn write_masked_close( + writer: &mut W, + payload: &[u8], +) -> Result<()> { + write_masked_frame(writer, OPCODE_CLOSE, payload).await +} + +pub(super) async fn write_unmasked_close( + writer: &mut W, + payload: &[u8], +) -> Result<()> { + let payload_len = u8::try_from(payload.len()) + .map_err(|_| miette!("websocket close payload exceeds 125 bytes"))?; + writer + .write_all(&[0x80 | OPCODE_CLOSE, payload_len]) + .await + .into_diagnostic()?; + writer.write_all(payload).await.into_diagnostic()?; + writer.flush().await.into_diagnostic()?; + Ok(()) +} + async fn write_masked_frame_with_rsv( writer: &mut W, opcode: u8, rsv: u8, payload: &[u8], ) -> Result<()> { + let (header, masked) = masked_frame_parts(opcode, rsv, payload); + writer.write_all(&header).await.into_diagnostic()?; + writer.write_all(&masked).await.into_diagnostic()?; + writer.flush().await.into_diagnostic()?; + Ok(()) +} + +async fn write_masked_text_frame_guarded( + writer: &mut W, + rsv: u8, + payload: &[u8], + host: &str, + port: u16, + policy_name: &str, + generation_guard: Option<&PolicyGenerationGuard>, +) -> WebSocketRelayResult<()> { + let (header, masked) = masked_frame_parts(OPCODE_TEXT, rsv, payload); + write_text_frame_guarded( + writer, + &header, + &masked, + host, + port, + policy_name, + generation_guard, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn write_text_frame_guarded( + writer: &mut W, + header: &[u8], + payload: &[u8], + host: &str, + port: u16, + policy_name: &str, + generation_guard: Option<&PolicyGenerationGuard>, +) -> WebSocketRelayResult<()> { + ensure_generation_guard_current(host, port, policy_name, generation_guard)?; + tokio::time::timeout(TEXT_MESSAGE_FORWARD_TOTAL_TIMEOUT, async { + writer.write_all(header).await.map_err(|error| { + terminate( + WebSocketTerminationCause::PeerDisconnect, + miette!("websocket upstream write failed: {error}"), + ) + })?; + writer.write_all(payload).await.map_err(|error| { + terminate( + WebSocketTerminationCause::PeerDisconnect, + miette!("websocket upstream write failed: {error}"), + ) + })?; + writer.flush().await.map_err(|error| { + terminate( + WebSocketTerminationCause::PeerDisconnect, + miette!("websocket upstream flush failed: {error}"), + ) + }) + }) + .await + .map_err(|_| { + terminate( + WebSocketTerminationCause::PeerDisconnect, + miette!("websocket upstream forwarding total timeout"), + ) + })??; + ensure_generation_guard_current(host, port, policy_name, generation_guard) +} + +fn masked_frame_parts(opcode: u8, rsv: u8, payload: &[u8]) -> (Vec, Vec) { let mut header = Vec::with_capacity(14); header.push(0x80 | rsv | opcode); match payload.len() { @@ -849,13 +1812,12 @@ async fn write_masked_frame_with_rsv( let mut masked = payload.to_vec(); apply_mask(&mut masked, mask_key); - writer.write_all(&header).await.into_diagnostic()?; - writer.write_all(&masked).await.into_diagnostic()?; - writer.flush().await.into_diagnostic()?; - Ok(()) + (header, masked) } -fn decompress_permessage_deflate(payload: &[u8]) -> Result> { +fn decompress_permessage_deflate( + payload: &[u8], +) -> std::result::Result, WebSocketDecompressionError> { let mut decoder = Decompress::new(false); let mut input = Vec::with_capacity(payload.len() + 4); input.extend_from_slice(payload); @@ -868,18 +1830,30 @@ fn decompress_permessage_deflate(payload: &[u8]) -> Result> { let before_out = decoder.total_out(); let status = decoder .decompress(&input[input_pos..], &mut scratch, FlushDecompress::Sync) - .map_err(|e| miette!("websocket permessage-deflate decompression failed: {e}"))?; - let read = usize::try_from(decoder.total_in() - before_in) - .map_err(|_| miette!("websocket permessage-deflate input length overflow"))?; - let written = usize::try_from(decoder.total_out() - before_out) - .map_err(|_| miette!("websocket permessage-deflate output length overflow"))?; - input_pos = input_pos - .checked_add(read) - .ok_or_else(|| miette!("websocket permessage-deflate input length overflow"))?; + .map_err(|e| { + WebSocketDecompressionError::Protocol(miette!( + "websocket permessage-deflate decompression failed: {e}" + )) + })?; + let read = usize::try_from(decoder.total_in() - before_in).map_err(|_| { + WebSocketDecompressionError::Protocol(miette!( + "websocket permessage-deflate input length overflow" + )) + })?; + let written = usize::try_from(decoder.total_out() - before_out).map_err(|_| { + WebSocketDecompressionError::Protocol(miette!( + "websocket permessage-deflate output length overflow" + )) + })?; + input_pos = input_pos.checked_add(read).ok_or_else(|| { + WebSocketDecompressionError::Protocol(miette!( + "websocket permessage-deflate input length overflow" + )) + })?; if out.len().saturating_add(written) > MAX_TEXT_MESSAGE_BYTES { - return Err(miette!( + return Err(WebSocketDecompressionError::MessageTooBig(miette!( "websocket text message exceeds {MAX_TEXT_MESSAGE_BYTES} byte limit" - )); + ))); } out.extend_from_slice(&scratch[..written]); if matches!(status, Status::StreamEnd) { @@ -889,9 +1863,9 @@ fn decompress_permessage_deflate(payload: &[u8]) -> Result> { break; } if read == 0 && written == 0 { - return Err(miette!( + return Err(WebSocketDecompressionError::Protocol(miette!( "websocket permessage-deflate decompression did not make progress" - )); + ))); } } Ok(out) @@ -940,6 +1914,99 @@ fn compress_permessage_deflate(payload: &[u8]) -> Result> { Ok(out) } +#[cfg(test)] +pub fn compressed_masked_text_frame_for_test(payload: &[u8]) -> Vec { + let compressed = compress_permessage_deflate(payload).expect("compress test WebSocket frame"); + let (mut frame, masked) = masked_frame_parts(OPCODE_TEXT, 0x40, &compressed); + frame.extend_from_slice(&masked); + frame +} + +#[cfg(test)] +pub fn decode_compressed_masked_text_frame_for_test(frame: &[u8]) -> String { + assert_eq!(frame[0] & 0x0f, OPCODE_TEXT); + assert_eq!(frame[0] & 0x40, 0x40); + let header = parse_test_frame_layout(frame); + assert!(header.masked, "client-to-upstream frame must be masked"); + let mut payload = + frame[header.payload_offset..header.payload_offset + header.payload_len].to_vec(); + apply_mask(&mut payload, header.mask_key.expect("masked test frame")); + String::from_utf8( + decompress_permessage_deflate(&payload).expect("decompress test WebSocket frame"), + ) + .expect("UTF-8 test WebSocket text") +} + +#[cfg(test)] +pub async fn read_frame_for_test(reader: &mut R) -> Vec { + let mut frame = vec![0u8; 2]; + reader + .read_exact(&mut frame) + .await + .expect("read test WebSocket frame header"); + let extended_len_bytes = match frame[1] & 0x7f { + 0..=125 => 0, + 126 => 2, + 127 => 8, + _ => unreachable!(), + }; + let header_len = extended_len_bytes + if frame[1] & 0x80 != 0 { 4 } else { 0 }; + frame.resize(2 + header_len, 0); + reader + .read_exact(&mut frame[2..]) + .await + .expect("read test WebSocket frame metadata"); + let payload_len = match frame[1] & 0x7f { + 0..=125 => usize::from(frame[1] & 0x7f), + 126 => usize::from(u16::from_be_bytes([frame[2], frame[3]])), + 127 => usize::try_from(u64::from_be_bytes(frame[2..10].try_into().unwrap())).unwrap(), + _ => unreachable!(), + }; + let frame_len = frame.len(); + frame.resize(frame_len + payload_len, 0); + reader + .read_exact(&mut frame[frame_len..]) + .await + .expect("read test WebSocket frame payload"); + frame +} + +#[cfg(test)] +struct TestFrameLayout { + masked: bool, + mask_key: Option<[u8; 4]>, + payload_offset: usize, + payload_len: usize, +} + +#[cfg(test)] +fn parse_test_frame_layout(frame: &[u8]) -> TestFrameLayout { + let masked = frame[1] & 0x80 != 0; + let len_code = frame[1] & 0x7f; + let (payload_len, mut payload_offset) = match len_code { + 0..=125 => (usize::from(len_code), 2), + 126 => (usize::from(u16::from_be_bytes([frame[2], frame[3]])), 4), + 127 => ( + usize::try_from(u64::from_be_bytes(frame[2..10].try_into().unwrap())).unwrap(), + 10, + ), + _ => unreachable!(), + }; + let mask_key = masked.then(|| { + let key = frame[payload_offset..payload_offset + 4] + .try_into() + .expect("test frame mask"); + payload_offset += 4; + key + }); + TestFrameLayout { + masked, + mask_key, + payload_offset, + payload_len, + } +} + fn new_mask_key() -> [u8; 4] { let bytes = uuid::Uuid::new_v4().into_bytes(); [bytes[0], bytes[1], bytes[2], bytes[3]] @@ -1003,7 +2070,10 @@ fn emit_websocket_l7_event( SeverityId::Informational, ), }; - let summary = graphql.map(graphql_log_summary).unwrap_or_default(); + let summary = graphql + .map(crate::l7::graphql::log_summary) + .map(|summary| format!(" {summary}")) + .unwrap_or_default(); let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(action_id) @@ -1020,72 +2090,60 @@ fn emit_websocket_l7_event( ocsf_emit!(event); } -fn graphql_log_summary(info: &crate::l7::graphql::GraphqlRequestInfo) -> String { - if let Some(error) = info.error.as_deref() { - return format!(" graphql_error={error:?}"); +fn observe_termination( + host: &str, + port: u16, + policy_name: &str, + termination: &WebSocketTermination, +) { + if let Some(failure_class) = termination.failure_class { + emit_protocol_failure(host, port, policy_name, failure_class); } - let ops: Vec = info - .operations - .iter() - .map(|op| { - let name = op.operation_name.as_deref().unwrap_or("-"); - let fields = if op.fields.is_empty() { - "-".to_string() - } else { - op.fields.join(",") - }; - let persisted = op - .persisted_query_hash - .as_deref() - .or(op.persisted_query_id.as_deref()) - .unwrap_or("-"); - format!( - "type={} name={} fields={} persisted={}", - op.operation_type, name, fields, persisted - ) - }) - .collect(); - format!(" graphql_ops={}", ops.join(";")) -} - -fn protocol_failure_class(error: &miette::Report) -> &'static str { - let msg = error.to_string().to_ascii_lowercase(); - if msg.contains("credential") { - "credential_resolution_failed" - } else if msg.contains("utf-8") { - "invalid_utf8" - } else if msg.contains("close frame") || msg.contains("after close") { - "invalid_close_frame" - } else if msg.contains("control frame") { - "invalid_control_frame" - } else if msg.contains("length") - || msg.contains("too large") - || msg.contains("exceeds") - || msg.contains("overflow") - { - "invalid_length" - } else if msg.contains("continuation") || msg.contains("fragmented") { - "invalid_fragmentation" - } else if msg.contains("reserved opcode") { - "reserved_opcode" - } else if msg.contains("not masked") { - "unmasked_client_frame" - } else if msg.contains("rsv") { - "rsv_bits" - } else if msg.contains("malformed") { - "malformed_frame" - } else { - "protocol_error" + if termination.cause == WebSocketTerminationCause::CapacityExhausted { + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .firewall_rule(policy_name, "l7-websocket") + .status_detail("assembly_capacity_exhausted") + .message(format!( + "WebSocket text assembly capacity exhausted [host:{host} port:{port}]" + )) + .build() + ); } } -fn emit_protocol_failure(host: &str, port: u16, policy_name: &str, failure_class: &str) { +fn emit_protocol_failure( + host: &str, + port: u16, + policy_name: &str, + failure_class: FrameFailureClass, +) { + ocsf_emit!(protocol_failure_event( + host, + port, + policy_name, + failure_class + )); +} + +fn protocol_failure_event( + host: &str, + port: u16, + policy_name: &str, + failure_class: FrameFailureClass, +) -> openshell_ocsf::OcsfEvent { let policy_name = if policy_name.is_empty() { "-" } else { policy_name }; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1094,9 +2152,8 @@ fn emit_protocol_failure(host: &str, port: u16, policy_name: &str, failure_class .dst_endpoint(Endpoint::from_domain(host, port)) .firewall_rule(policy_name, "l7-websocket") .message(protocol_failure_message(host, port)) - .status_detail(failure_class) - .build(); - ocsf_emit!(event); + .status_detail(failure_class.as_str()) + .build() } fn protocol_failure_message(host: &str, port: u16) -> String { @@ -1108,9 +2165,29 @@ mod tests { use super::*; use crate::l7::relay::L7EvalContext; use crate::opa::{NetworkInput, OpaEngine}; + use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ + SupervisorMiddleware, SupervisorMiddlewareServer, + }; + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + use openshell_core::provider_credentials::ProviderCredentialState; use openshell_core::secrets::SecretResolver; use std::path::PathBuf; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream}; + use tonic::{Request, Response, Status}; + + async fn test_assembly_admission() -> WebSocketAssemblyAdmission { + match WebSocketAssemblyBudget::new(1, 0) + .reserve() + .await + .expect("reserve test assembly") + { + WebSocketAssemblyAdmissionOutcome::Admitted(admission) => admission, + WebSocketAssemblyAdmissionOutcome::QueueExhausted => { + panic!("fresh test assembly budget must admit") + } + } + } const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); const GRAPHQL_WS_POLICY: &str = r#" @@ -1137,21 +2214,69 @@ network_policies: - { path: /usr/bin/node } "#; - fn resolver() -> (HashMap, SecretResolver) { - let (child_env, resolver) = SecretResolver::from_provider_env( - std::iter::once(("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())).collect(), - ); - (child_env, resolver.expect("resolver")) - } - - fn masked_frame(fin: bool, opcode: u8, payload: &[u8]) -> Vec { - masked_frame_with_rsv(fin, opcode, 0, payload) - } + #[test] + fn termination_causes_map_to_protocol_close_codes_and_session_reasons() { + use openshell_core::proto::WebSocketSessionEndReason as EndReason; - fn masked_frame_with_rsv(fin: bool, opcode: u8, rsv: u8, payload: &[u8]) -> Vec { - let mask_key = [0x37, 0xfa, 0x21, 0x3d]; - let mut frame = Vec::new(); - frame.push((if fin { 0x80 } else { 0 }) | rsv | opcode); + assert_eq!( + WebSocketTerminationCause::InvalidUtf8.close_code(), + Some(1007) + ); + assert_eq!( + WebSocketTerminationCause::ProtocolError.close_code(), + Some(1002) + ); + assert_eq!( + WebSocketTerminationCause::MessageTooBig.close_code(), + Some(1009) + ); + assert_eq!( + WebSocketTerminationCause::MiddlewareDenial.close_code(), + Some(1008) + ); + assert_eq!( + WebSocketTerminationCause::PolicyReload.close_code(), + Some(1012) + ); + assert_eq!(WebSocketTerminationCause::PeerDisconnect.close_code(), None); + + assert_eq!( + WebSocketTerminationCause::InvalidUtf8.session_end_reason(), + EndReason::ProtocolError + ); + assert_eq!( + WebSocketTerminationCause::MiddlewareDenial.session_end_reason(), + EndReason::MiddlewareDenial + ); + assert_eq!( + WebSocketTerminationCause::PolicyDenial.session_end_reason(), + EndReason::PolicyDenial + ); + assert_eq!( + WebSocketTerminationCause::MiddlewareFailure.session_end_reason(), + EndReason::MiddlewareFailure + ); + assert_eq!( + WebSocketTerminationCause::PolicyReload.session_end_reason(), + EndReason::PolicyReload + ); + } + + fn resolver() -> (HashMap, SecretResolver) { + let (child_env, resolver) = SecretResolver::from_provider_env( + std::iter::once(("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())).collect(), + ); + (child_env, resolver.expect("resolver")) + } + + fn masked_frame(fin: bool, opcode: u8, payload: &[u8]) -> Vec { + masked_frame_with_rsv(fin, opcode, 0, payload) + } + + fn masked_frame_with_rsv(fin: bool, opcode: u8, rsv: u8, payload: &[u8]) -> Vec { + let mask_key = [0x37, 0xfa, 0x21, 0x3d]; + let mut frame = Vec::new(); + frame.push((if fin { 0x80 } else { 0 }) | rsv | opcode); match payload.len() { 0..=125 => frame.push(0x80 | u8::try_from(payload.len()).expect("payload <= 125")), 126..=65_535 => { @@ -1208,6 +2333,79 @@ network_policies: frame } + fn test_frame_header(fin: bool, opcode: u8, payload_len: u64) -> FrameHeader { + FrameHeader { + fin, + rsv: 0, + opcode, + masked: true, + payload_len, + mask_key: Some([0x37, 0xfa, 0x21, 0x3d]), + raw_header: Vec::new(), + } + } + + async fn wait_for_stalled_task(task: &tokio::task::JoinHandle) { + tokio::task::yield_now().await; + assert!(!task.is_finished(), "fixture task must be pending"); + } + + struct ReloadAfterFirstWrite { + engine: Arc, + output: Vec, + writes: usize, + } + + impl AsyncWrite for ReloadAfterFirstWrite { + fn poll_write( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buffer: &[u8], + ) -> std::task::Poll> { + let this = self.get_mut(); + this.output.extend_from_slice(buffer); + this.writes += 1; + if this.writes == 1 { + this.engine + .replace_middleware_registry( + openshell_supervisor_middleware::MiddlewareRegistry::default(), + ) + .expect("invalidate generation after frame header"); + } + std::task::Poll::Ready(Ok(buffer.len())) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + } + + fn relay_options_with_budget(budget: WebSocketAssemblyBudget) -> RelayOptions<'static> { + RelayOptions { + policy_name: "test-policy", + assembly_budget: budget, + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: false, + } + } + fn close_payload(code: u16, reason: &[u8]) -> Vec { let mut payload = Vec::with_capacity(2 + reason.len()); payload.extend_from_slice(&code.to_be_bytes()); @@ -1223,25 +2421,505 @@ network_policies: client_write.write_all(&input).await.unwrap(); drop(client_write); - let options = RelayOptions { + let mut options = RelayOptions { policy_name: "test-policy", + assembly_budget: WebSocketAssemblyBudget::default(), resolver: Some(&resolver), + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: false, + }; + let result = relay_client_to_server( + &mut relay_read, + &mut relay_write, + "gateway.example.test", + 443, + &mut options, + ) + .await; + drop(relay_write); + + let mut output = Vec::new(); + upstream_read.read_to_end(&mut output).await.unwrap(); + result + .map(|_| output) + .map_err(|termination| termination.error) + } + + #[tokio::test] + async fn parsed_relays_share_assembly_budget_without_middleware() { + let budget = WebSocketAssemblyBudget::new(1, 0); + let held_frame = masked_frame(true, OPCODE_TEXT, b"held"); + let (mut first_client, mut first_reader) = tokio::io::duplex(64); + let (mut first_writer, _first_upstream) = tokio::io::duplex(64); + first_client + .write_all(&held_frame[..6]) + .await + .expect("send frame header without payload"); + let first_budget = budget.clone(); + let first = tokio::spawn(async move { + let mut options = relay_options_with_budget(first_budget); + relay_client_to_server( + &mut first_reader, + &mut first_writer, + "gateway.example.test", + 443, + &mut options, + ) + .await + }); + wait_for_stalled_task(&first).await; + + let rejected_frame = masked_frame(true, OPCODE_TEXT, b"rejected"); + let (mut second_client, mut second_reader) = tokio::io::duplex(64); + let (mut second_writer, mut second_upstream) = tokio::io::duplex(64); + second_client + .write_all(&rejected_frame) + .await + .expect("send rejected frame"); + drop(second_client); + let mut second_options = relay_options_with_budget(budget.clone()); + let rejected = relay_client_to_server( + &mut second_reader, + &mut second_writer, + "gateway.example.test", + 443, + &mut second_options, + ) + .await + .expect_err("full assembly budget must shed"); + assert_eq!(rejected.cause, WebSocketTerminationCause::CapacityExhausted); + drop(second_writer); + let mut rejected_output = Vec::new(); + second_upstream + .read_to_end(&mut rejected_output) + .await + .expect("read rejected upstream"); + assert!(rejected_output.is_empty()); + + drop(first_client); + first + .await + .expect("join held relay") + .expect_err("incomplete message must end on disconnect"); + + let admitted_frame = masked_frame(true, OPCODE_TEXT, b"admitted"); + let (mut third_client, mut third_reader) = tokio::io::duplex(64); + let (mut third_writer, mut third_upstream) = tokio::io::duplex(64); + third_client + .write_all(&admitted_frame) + .await + .expect("send admitted frame"); + drop(third_client); + let mut third_options = relay_options_with_budget(budget); + relay_client_to_server( + &mut third_reader, + &mut third_writer, + "gateway.example.test", + 443, + &mut third_options, + ) + .await + .expect("released assembly budget must admit"); + drop(third_writer); + let mut admitted_output = Vec::new(); + third_upstream + .read_to_end(&mut admitted_output) + .await + .expect("read admitted upstream"); + assert_eq!(admitted_output, admitted_frame); + } + + #[tokio::test] + async fn assembly_budget_bounds_waiters_and_recovers_capacity() { + let budget = WebSocketAssemblyBudget::new(1, 1); + let first = match budget.reserve().await.expect("reserve active assembly") { + WebSocketAssemblyAdmissionOutcome::Admitted(admission) => admission, + WebSocketAssemblyAdmissionOutcome::QueueExhausted => { + panic!("fresh budget must admit") + } + }; + let waiting_budget = budget.clone(); + let waiting = tokio::spawn(async move { waiting_budget.reserve().await }); + wait_for_stalled_task(&waiting).await; + assert!(matches!( + budget.reserve().await.expect("attempt shed admission"), + WebSocketAssemblyAdmissionOutcome::QueueExhausted + )); + drop(first); + assert!(matches!( + waiting + .await + .expect("join waiting admission") + .expect("waiting admission result"), + WebSocketAssemblyAdmissionOutcome::Admitted(_) + )); + } + + #[tokio::test(start_paused = true)] + async fn upstream_forwarding_timeout_releases_assembly_capacity() { + let budget = WebSocketAssemblyBudget::new(1, 0); + let assembly_admission = match budget.reserve().await.expect("reserve assembly") { + WebSocketAssemblyAdmissionOutcome::Admitted(admission) => admission, + WebSocketAssemblyAdmissionOutcome::QueueExhausted => { + panic!("fresh assembly budget must admit") + } + }; + let payload = vec![b'x'; 64]; + let frame_bytes = masked_frame(true, OPCODE_TEXT, &payload); + let mut frame = test_frame_header(true, OPCODE_TEXT, payload.len() as u64); + frame.raw_header = frame_bytes[..6].to_vec(); + let (mut writer, _non_reading_upstream) = tokio::io::duplex(1); + let forwarding = tokio::spawn(async move { + let mut options = relay_options_with_budget(WebSocketAssemblyBudget::new(1, 0)); + relay_text_payload( + &mut writer, + &frame, + payload, + assembly_admission, + None, + false, + false, + "gateway.example.test", + 443, + &mut options, + ) + .await + }); + wait_for_stalled_task(&forwarding).await; + assert!(matches!( + budget.reserve().await.expect("attempt shed admission"), + WebSocketAssemblyAdmissionOutcome::QueueExhausted + )); + + tokio::time::advance(TEXT_MESSAGE_FORWARD_TOTAL_TIMEOUT + StdDuration::from_millis(1)) + .await; + let error = forwarding + .await + .expect("join forwarding") + .expect_err("non-reading upstream must time out"); + assert_eq!(error.cause, WebSocketTerminationCause::PeerDisconnect); + assert!( + error + .error + .to_string() + .contains("upstream forwarding total timeout") + ); + assert!(matches!( + budget + .reserve() + .await + .expect("reserve after forwarding timeout"), + WebSocketAssemblyAdmissionOutcome::Admitted(_) + )); + } + + #[tokio::test] + async fn reload_after_frame_header_preserves_frame_boundary_before_close() { + let engine = Arc::new( + OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").expect("test policy"), + ); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .expect("generation guard"); + let payload = b"complete-frame"; + let (header, masked) = masked_frame_parts(OPCODE_TEXT, 0, payload); + let mut writer = ReloadAfterFirstWrite { + engine, + output: Vec::new(), + writes: 0, + }; + + let error = write_text_frame_guarded( + &mut writer, + &header, + &masked, + "gateway.example.test", + 443, + "test-policy", + Some(&generation_guard), + ) + .await + .expect_err("reload after header must close after completing the frame"); + assert_eq!(error.cause, WebSocketTerminationCause::PolicyReload); + + write_masked_close(&mut writer, &1012u16.to_be_bytes()) + .await + .expect("write typed close"); + let frame_len = header.len() + masked.len(); + assert_eq!(&writer.output[..header.len()], header); + assert_eq!(&writer.output[header.len()..frame_len], masked); + let close = &writer.output[frame_len..]; + assert_eq!(close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(close)[..2] + .try_into() + .expect("close code"), + ), + 1012 + ); + } + + fn bound_websocket_provider_state() -> ProviderCredentialState { + ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "DISCORD_BOT_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "gateway.example.test".to_string(), + port: 443, + path: "/socket".to_string(), + }], + credential_identity: "provider-a:DISCORD_BOT_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound websocket provider state") + } + + async fn relay_frame_after_live_state_change( + state: &ProviderCredentialState, + fallback: &SecretResolver, + ) -> (Result<()>, Vec) { + let placeholder = b"openshell:resolve:env:v1_DISCORD_BOT_TOKEN"; + let input = masked_frame(true, 0x1, placeholder); + let (mut client_write, mut relay_read) = tokio::io::duplex(4096); + let (mut relay_write, mut upstream_read) = tokio::io::duplex(4096); + client_write.write_all(&input).await.unwrap(); + drop(client_write); + + let mut options = RelayOptions { + policy_name: "test-policy", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: Some(fallback), + generation_guard: None, + provider_credentials: Some(state), + target: "/socket", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: false, + }; + let result = relay_client_to_server( + &mut relay_read, + &mut relay_write, + "gateway.example.test", + 443, + &mut options, + ) + .await; + drop(relay_write); + let mut output = Vec::new(); + upstream_read.read_to_end(&mut output).await.unwrap(); + ( + result.map(|_| ()).map_err(|termination| termination.error), + output, + ) + } + + #[tokio::test] + async fn established_websocket_does_not_restore_resolver_after_detach() { + let state = bound_websocket_provider_state(); + let fallback = state.resolver().expect("upgrade-time resolver"); + state.revoke_static_provider_environment(2); + + let (result, output) = relay_frame_after_live_state_change(&state, fallback.as_ref()).await; + assert!(result.is_err(), "revoked placeholder must close the relay"); + assert!( + output.is_empty(), + "revoked WebSocket credential must not reach upstream" + ); + } + + #[tokio::test] + async fn established_websocket_does_not_restore_resolver_after_invalid_refresh() { + let state = bound_websocket_provider_state(); + let fallback = state.resolver().expect("upgrade-time resolver"); + let refresh = state.install_bound_environment( + 2, + HashMap::from([("DISCORD_BOT_TOKEN".to_string(), "rotated".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + ); + assert!( + refresh.is_err(), + "incomplete bindings must revoke static state" + ); + + let (result, output) = relay_frame_after_live_state_change(&state, fallback.as_ref()).await; + assert!( + result.is_err(), + "invalid-refresh placeholder must close the relay" + ); + assert!( + output.is_empty(), + "invalid-refresh credential must not reach upstream" + ); + } + + #[tokio::test] + async fn guarded_websocket_uses_path_scoped_live_resolver() { + let state = bound_websocket_provider_state(); + let placeholder = b"openshell:resolve:env:v1_DISCORD_BOT_TOKEN"; + let input = masked_frame(true, OPCODE_TEXT, placeholder); + let (mut client_write, mut relay_read) = tokio::io::duplex(4096); + let (mut relay_write, mut upstream_read) = tokio::io::duplex(4096); + client_write.write_all(&input).await.unwrap(); + drop(client_write); + + let mut options = RelayOptions { + policy_name: "test-policy", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: Some(&state), + target: "/socket", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: true, + }; + let result = relay_client_to_server( + &mut relay_read, + &mut relay_write, + "gateway.example.test", + 443, + &mut options, + ) + .await; + assert!( + result.is_ok(), + "path-scoped live resolver must satisfy the credential guard: {result:?}" + ); + + drop(relay_write); + let mut output = Vec::new(); + upstream_read.read_to_end(&mut output).await.unwrap(); + assert_eq!(decode_masked_text_frame(&output), "real-token"); + } + + #[tokio::test] + async fn websocket_rewrite_rejects_revocation_before_frame_write() { + let state = bound_websocket_provider_state(); + let fallback = state.resolver().expect("upgrade-time resolver"); + let placeholder = b"openshell:resolve:env:v1_DISCORD_BOT_TOKEN"; + let compressed = compress_permessage_deflate(placeholder).expect("compress placeholder"); + let frame = FrameHeader { + fin: true, + rsv: 0x40, + opcode: OPCODE_TEXT, + masked: true, + payload_len: compressed.len() as u64, + mask_key: Some([0x37, 0xfa, 0x21, 0x3d]), + raw_header: Vec::new(), + }; + let mut options = RelayOptions { + policy_name: "test-policy", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: Some(fallback.as_ref()), + generation_guard: None, + provider_credentials: Some(&state), + target: "/socket", + inspector: None, + compression: WebSocketCompression::PermessageDeflate, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: false, + }; + let reached_write = tokio::sync::Barrier::new(2); + let release_write = tokio::sync::Barrier::new(2); + let (mut relay_write, mut upstream_read) = tokio::io::duplex(4096); + + let relay = relay_text_payload_with_before_credential_write( + &mut relay_write, + &frame, + compressed, + test_assembly_admission().await, + None, + false, + true, + "gateway.example.test", + 443, + &mut options, + async { + reached_write.wait().await; + release_write.wait().await; + }, + ); + let revoke = async { + reached_write.wait().await; + state.revoke_static_provider_environment(2); + release_write.wait().await; + }; + let (result, ()) = tokio::join!(relay, revoke); + assert!( + result + .as_ref() + .is_err_and(|error| error.error.to_string().contains("generation changed")), + "revoked credential generation must fail before the frame write: {result:?}" + ); + + drop(relay_write); + let mut output = Vec::new(); + upstream_read.read_to_end(&mut output).await.unwrap(); + assert!( + output.is_empty(), + "credential revoked before the write guard must not reach upstream" + ); + } + + async fn run_client_to_server_guarded(input: Vec) -> (Result<()>, Vec) { + let (mut client_write, mut relay_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024); + let (mut relay_write, mut upstream_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024); + + client_write.write_all(&input).await.unwrap(); + drop(client_write); + + let mut options = RelayOptions { + policy_name: "test-policy", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", inspector: None, compression: WebSocketCompression::None, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: true, }; let result = relay_client_to_server( &mut relay_read, &mut relay_write, "gateway.example.test", 443, - &options, + &mut options, ) .await; drop(relay_write); let mut output = Vec::new(); upstream_read.read_to_end(&mut output).await.unwrap(); - result.map(|()| output) + ( + result.map(|_| ()).map_err(|termination| termination.error), + output, + ) } async fn run_client_to_server_with_graphql_policy( @@ -1281,9 +2959,13 @@ network_policies: client_write.write_all(&input).await.unwrap(); drop(client_write); - let options = RelayOptions { + let mut options = RelayOptions { policy_name: "graphql_ws", + assembly_budget: WebSocketAssemblyBudget::default(), resolver, + generation_guard: Some(tunnel_engine.generation_guard()), + provider_credentials: None, + target: "/graphql", inspector: Some(InspectionOptions { engine: &tunnel_engine, ctx: &ctx, @@ -1293,20 +2975,25 @@ network_policies: graphql_policy: true, }), compression: WebSocketCompression::None, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: false, }; let result = relay_client_to_server( &mut relay_read, &mut relay_write, "realtime.graphql.test", 443, - &options, + &mut options, ) .await; drop(relay_write); let mut output = Vec::new(); upstream_read.read_to_end(&mut output).await.unwrap(); - result.map(|()| output) + result + .map(|_| output) + .map_err(|termination| termination.error) } async fn run_client_to_server_compressed(input: Vec) -> Result> { @@ -1317,25 +3004,34 @@ network_policies: client_write.write_all(&input).await.unwrap(); drop(client_write); - let options = RelayOptions { + let mut options = RelayOptions { policy_name: "test-policy", + assembly_budget: WebSocketAssemblyBudget::default(), resolver: Some(&resolver), + generation_guard: None, + provider_credentials: None, + target: "/", inspector: None, compression: WebSocketCompression::PermessageDeflate, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: false, }; let result = relay_client_to_server( &mut relay_read, &mut relay_write, "gateway.example.test", 443, - &options, + &mut options, ) .await; drop(relay_write); let mut output = Vec::new(); upstream_read.read_to_end(&mut output).await.unwrap(); - result.map(|()| output) + result + .map(|_| output) + .map_err(|termination| termination.error) } fn decode_masked_text_frame(frame: &[u8]) -> String { @@ -1404,147 +3100,1895 @@ network_policies: reader .read_exact(&mut rest[extended_len.len()..]) .await - .unwrap(); - - let mut frame = header.to_vec(); - frame.extend_from_slice(&rest); - frame - } + .unwrap(); + + let mut frame = header.to_vec(); + frame.extend_from_slice(&rest); + frame + } + + #[tokio::test(start_paused = true)] + async fn stalled_initial_text_payload_releases_middleware_admission() { + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let admission = runner + .reserve_middleware_work_admission() + .await + .expect("reserve middleware work"); + let (client, mut reader) = tokio::io::duplex(8); + let frame = test_frame_header(true, OPCODE_TEXT, 4); + let task = tokio::spawn(async move { + let mut assembly = + TextMessageAssembly::new(false, test_assembly_admission().await, Some(admission)); + assembly.read_payload(&mut reader, &frame).await + }); + + wait_for_stalled_task(&task).await; + tokio::time::advance(TEXT_MESSAGE_ASSEMBLY_IDLE_TIMEOUT + StdDuration::from_millis(1)) + .await; + let error = task + .await + .expect("join stalled assembly") + .expect_err("initial payload must time out"); + assert!(error.to_string().contains("assembly idle timeout")); + + runner + .reserve_middleware_work_admission() + .await + .expect("timed-out initial payload releases admission"); + drop(client); + } + + #[tokio::test(start_paused = true)] + async fn stalled_continuation_payload_releases_middleware_admission() { + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let admission = runner + .reserve_middleware_work_admission() + .await + .expect("reserve middleware work"); + let (mut client, mut reader) = tokio::io::duplex(8); + client + .write_all(&[0x37]) + .await + .expect("send partial continuation payload"); + let frame = test_frame_header(true, OPCODE_CONTINUATION, 2); + let task = tokio::spawn(async move { + let mut assembly = + TextMessageAssembly::new(false, test_assembly_admission().await, Some(admission)); + assembly.payload.extend_from_slice(b"first"); + assembly.add_fragment().expect("continuation within limit"); + assembly.read_payload(&mut reader, &frame).await + }); + + wait_for_stalled_task(&task).await; + tokio::time::advance(TEXT_MESSAGE_ASSEMBLY_IDLE_TIMEOUT + StdDuration::from_millis(1)) + .await; + let error = task + .await + .expect("join stalled assembly") + .expect_err("continuation payload must time out"); + assert!(error.to_string().contains("assembly idle timeout")); + + runner + .reserve_middleware_work_admission() + .await + .expect("timed-out continuation releases admission"); + drop(client); + } + + #[tokio::test(start_paused = true)] + async fn missing_continuation_header_releases_middleware_admission() { + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let admission = runner + .reserve_middleware_work_admission() + .await + .expect("reserve middleware work"); + let (client, mut reader) = tokio::io::duplex(8); + let task = tokio::spawn(async move { + let mut assembly = + TextMessageAssembly::new(false, test_assembly_admission().await, Some(admission)); + assembly.payload.extend_from_slice(b"first"); + let result = read_frame_header(&mut reader, Some(&assembly)).await; + drop(assembly); + result + }); + + wait_for_stalled_task(&task).await; + tokio::time::advance(TEXT_MESSAGE_ASSEMBLY_IDLE_TIMEOUT + StdDuration::from_millis(1)) + .await; + let error = task + .await + .expect("join stalled assembly") + .expect_err("missing continuation header must time out"); + assert!(error.to_string().contains("assembly idle timeout")); + + runner + .reserve_middleware_work_admission() + .await + .expect("missing continuation releases admission"); + drop(client); + } + + #[tokio::test(start_paused = true)] + async fn text_assembly_total_deadline_does_not_reset_on_input_progress() { + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let admission = runner + .reserve_middleware_work_admission() + .await + .expect("reserve middleware work"); + let (mut client, mut reader) = tokio::io::duplex(8); + let frame = test_frame_header(true, OPCODE_TEXT, 1_024); + let task = tokio::spawn(async move { + let mut assembly = + TextMessageAssembly::new(false, test_assembly_admission().await, Some(admission)); + assembly.read_payload(&mut reader, &frame).await + }); + wait_for_stalled_task(&task).await; + + let progress_interval = TEXT_MESSAGE_ASSEMBLY_IDLE_TIMEOUT / 2; + let mut elapsed = StdDuration::ZERO; + while elapsed + progress_interval < TEXT_MESSAGE_ASSEMBLY_TOTAL_TIMEOUT { + tokio::time::advance(progress_interval).await; + elapsed += progress_interval; + client + .write_all(&[0x37]) + .await + .expect("make progress before idle timeout"); + tokio::task::yield_now().await; + assert!(!task.is_finished(), "idle deadline must reset on progress"); + } + let until_total_timeout = TEXT_MESSAGE_ASSEMBLY_TOTAL_TIMEOUT + .checked_sub(elapsed) + .expect("progress schedule stays within total timeout"); + tokio::time::advance(until_total_timeout + StdDuration::from_millis(1)).await; + let error = task + .await + .expect("join total assembly timeout") + .expect_err("total assembly deadline must not reset"); + assert!(error.to_string().contains("assembly total timeout")); + + runner + .reserve_middleware_work_admission() + .await + .expect("total timeout releases admission"); + } + + #[tokio::test(start_paused = true)] + async fn stalled_assemblies_release_the_saturated_shared_work_budget() { + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let mut clients = Vec::new(); + let mut stalled = Vec::new(); + for _ in 0..openshell_supervisor_middleware::MAX_CONCURRENT_MIDDLEWARE_WORK { + let admission = runner + .reserve_middleware_work_admission() + .await + .expect("fill shared middleware work budget"); + let (client, mut reader) = tokio::io::duplex(8); + clients.push(client); + let frame = test_frame_header(true, OPCODE_TEXT, 4); + stalled.push(tokio::spawn(async move { + let mut assembly = TextMessageAssembly::new( + false, + test_assembly_admission().await, + Some(admission), + ); + assembly.read_payload(&mut reader, &frame).await + })); + } + tokio::task::yield_now().await; + assert!(stalled.iter().all(|task| !task.is_finished())); + + let later_runner = runner.clone(); + let later_work = + tokio::spawn(async move { later_runner.reserve_middleware_work_admission().await }); + wait_for_stalled_task(&later_work).await; + + tokio::time::advance(TEXT_MESSAGE_ASSEMBLY_IDLE_TIMEOUT + StdDuration::from_millis(1)) + .await; + for task in stalled { + let error = task + .await + .expect("join stalled assembly") + .expect_err("stalled assembly must time out"); + assert!(error.to_string().contains("assembly idle timeout")); + } + + let admission = later_work + .await + .expect("join later middleware work") + .expect("later middleware work acquires released capacity"); + assert!( + admission.saturated(), + "later work must have waited behind the saturated budget" + ); + drop(clients); + } + + #[test] + fn classifies_graphql_transport_ws_subscribe_operation() { + let message = r#"{"type":"subscribe","id":"1","payload":{"query":"subscription NewMessages { messageAdded }"}}"#; + + match classify_graphql_websocket_message(message) { + GraphqlWebSocketMessage::Operation { + message_type, + graphql, + } => { + assert_eq!(message_type, "subscribe"); + assert!( + graphql.error.is_none(), + "unexpected error: {:?}", + graphql.error + ); + assert_eq!(graphql.operations.len(), 1); + assert_eq!(graphql.operations[0].operation_type, "subscription"); + assert_eq!( + graphql.operations[0].operation_name.as_deref(), + Some("NewMessages") + ); + assert_eq!(graphql.operations[0].fields, vec!["messageAdded"]); + } + other @ GraphqlWebSocketMessage::Control { .. } => { + panic!("expected operation, got {other:?}") + } + } + } + + #[test] + fn classifies_legacy_graphql_ws_start_operation() { + let message = r#"{"type":"start","id":"1","payload":{"query":"query Viewer { viewer }"}}"#; + + match classify_graphql_websocket_message(message) { + GraphqlWebSocketMessage::Operation { + message_type, + graphql, + } => { + assert_eq!(message_type, "start"); + assert!( + graphql.error.is_none(), + "unexpected error: {:?}", + graphql.error + ); + assert_eq!(graphql.operations[0].operation_type, "query"); + assert_eq!(graphql.operations[0].fields, vec!["viewer"]); + } + other @ GraphqlWebSocketMessage::Control { .. } => { + panic!("expected operation, got {other:?}") + } + } + } + + #[test] + fn classifies_graphql_websocket_control_message_without_payload_logging() { + match classify_graphql_websocket_message( + r#"{"type":"connection_init","payload":{"authorization":"secret"}}"#, + ) { + GraphqlWebSocketMessage::Control { message_type } => { + assert_eq!(message_type, "connection_init"); + } + other @ GraphqlWebSocketMessage::Operation { .. } => { + panic!("expected control message, got {other:?}") + } + } + } + + #[test] + fn unsupported_graphql_websocket_message_type_fails_closed() { + match classify_graphql_websocket_message(r#"{"type":"next","id":"1"}"#) { + GraphqlWebSocketMessage::Operation { graphql, .. } => { + assert!( + graphql + .error + .as_deref() + .is_some_and(|error| error.contains("unsupported")) + ); + } + other @ GraphqlWebSocketMessage::Control { .. } => { + panic!("expected operation error, got {other:?}") + } + } + } + + #[test] + fn graphql_websocket_log_summary_excludes_payload_variables_and_secrets() { + let placeholder = "openshell:resolve:env:T"; + let message = format!( + r#"{{"type":"subscribe","id":"1","payload":{{"query":"query Viewer {{ viewer }}","variables":{{"token":"{placeholder}"}}}}}}"# + ); + let graphql = match classify_graphql_websocket_message(&message) { + GraphqlWebSocketMessage::Operation { graphql, .. } => graphql, + other @ GraphqlWebSocketMessage::Control { .. } => { + panic!("expected operation, got {other:?}") + } + }; + let summary = crate::l7::graphql::log_summary(&graphql); + + assert!(summary.contains("type=query")); + assert!(summary.contains("fields=viewer")); + assert!(!summary.contains(placeholder)); + assert!(!summary.contains("real-token")); + assert!(!summary.contains("variables")); + assert!(!summary.contains("token")); + assert!(!summary.contains("secret_len")); + } + + #[tokio::test] + async fn rewrites_discord_like_identify_text_payload() { + let (child_env, _) = resolver(); + let placeholder = child_env.get("DISCORD_BOT_TOKEN").unwrap(); + let payload = format!(r#"{{"op":2,"d":{{"token":"{placeholder}"}}}}"#); + + let output = run_client_to_server(masked_frame(true, OPCODE_TEXT, payload.as_bytes())) + .await + .expect("relay should succeed"); + + assert_eq!( + decode_masked_text_frame(&output), + r#"{"op":2,"d":{"token":"real-token"}}"# + ); + } + + #[tokio::test] + async fn upgraded_relay_rewrites_client_text_before_upstream_receives_it() { + let (child_env, resolver) = resolver(); + let placeholder = child_env.get("DISCORD_BOT_TOKEN").unwrap(); + let payload = format!(r#"{{"op":2,"d":{{"token":"{placeholder}"}}}}"#); + let client_frame = masked_frame(true, OPCODE_TEXT, payload.as_bytes()); + assert!( + !String::from_utf8_lossy(&client_frame).contains("real-token"), + "client-side fixture must not contain the real token" + ); + + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + relay_with_options( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "gateway.example.test", + 443, + RelayOptions { + policy_name: "test-policy", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: Some(&resolver), + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: None, + middleware_context: None, + deny_uninspected_credentials: false, + }, + ) + .await + }); + + client_app.write_all(&client_frame).await.unwrap(); + client_app.flush().await.unwrap(); + + let upstream_frame = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_one_frame(&mut upstream_app), + ) + .await + .expect("upstream should receive rewritten frame"); + assert_eq!( + decode_masked_text_frame(&upstream_frame), + r#"{"op":2,"d":{"token":"real-token"}}"# + ); + + drop(client_app); + drop(upstream_app); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), relay).await; + } + + #[derive(Debug)] + enum ObservedWebSocketRequest { + SessionStart, + Message { sequence: u64, payload: String }, + SessionEnd(openshell_core::proto::WebSocketSessionEndReason), + } + + #[derive(Clone, Default)] + struct OpenAiWebSocketRedactor { + observed: Option>, + close_on_first_message: bool, + message_received: Option>, + release_message: Option>, + // Opt-in capture of the preflight request context's workspace. Kept + // separate from `observed` so it does not perturb the session-event + // ordering the other tests assert on. + preflight_observed: Option>, + } + + #[tonic::async_trait] + impl SupervisorMiddleware for OpenAiWebSocketRedactor { + type EvaluateWebSocketSessionStream = + openshell_supervisor_middleware::WebSocketResponseStream; + + async fn describe( + &self, + _request: Request<()>, + ) -> std::result::Result, Status> + { + use openshell_core::proto::{ + MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, + }; + Ok(Response::new(MiddlewareManifest { + name: "test/openai-websocket-redactor".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "1s".into(), + }], + expected_audience: String::new(), + })) + } + + async fn validate_config( + &self, + _request: Request, + ) -> std::result::Result, Status> + { + Ok(Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: Request, + ) -> std::result::Result, Status> + { + Err(Status::unimplemented("WebSocket-only test middleware")) + } + + async fn evaluate_web_socket_session( + &self, + request: Request>, + ) -> std::result::Result, Status> { + use openshell_core::proto::{ + Decision, WebSocketMessageResult, WebSocketPreflightAction, + WebSocketPreflightDecision, WebSocketSessionEventResult, web_socket_message, + web_socket_message_result, web_socket_session_event, + web_socket_session_event_result, + }; + let mut requests = request.into_inner(); + let observed = self.observed.clone(); + let close_on_first_message = self.close_on_first_message; + let message_received = self.message_received.clone(); + let release_message = self.release_message.clone(); + let preflight_observed = self.preflight_observed.clone(); + let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Ok(Some(request)) = requests.message().await { + let response = match request.event { + Some(web_socket_session_event::Event::Preflight(preflight)) => { + if let Some(preflight_observed) = &preflight_observed { + let workspace = preflight + .context + .as_ref() + .map(|context| context.workspace.clone()) + .unwrap_or_default(); + let _ = preflight_observed.send(workspace); + } + Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::PreflightDecision( + WebSocketPreflightDecision { + action: WebSocketPreflightAction::Inspect as i32, + ..Default::default() + }, + ), + ), + }) + } + Some(web_socket_session_event::Event::Message(message)) => { + let web_socket_message::Payload::Text(text) = + message.payload.expect("OpenAI event payload") + else { + panic!("OpenAI event must be text"); + }; + if let Some(observed) = &observed { + let _ = observed.send(ObservedWebSocketRequest::Message { + sequence: message.sequence, + payload: text.clone(), + }); + } + if close_on_first_message { + break; + } + if let Some(received) = &message_received { + received.notify_one(); + } + if let Some(release) = &release_message { + release.notified().await; + } + let deny = text.contains("deny-me"); + let replacement = text.replace("customer-secret", "[REDACTED]"); + Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::MessageResult( + WebSocketMessageResult { + sequence: message.sequence, + decision: if deny { + Decision::Deny as i32 + } else { + Decision::Allow as i32 + }, + replacement: (!deny).then_some( + web_socket_message_result::Replacement::Text( + replacement, + ), + ), + reason_code: if deny { + "blocked".into() + } else { + "redacted".into() + }, + ..Default::default() + }, + ), + ), + }) + } + Some(web_socket_session_event::Event::SessionStart(_)) => { + if let Some(observed) = &observed { + let _ = observed.send(ObservedWebSocketRequest::SessionStart); + } + None + } + Some(web_socket_session_event::Event::SessionEnd(end)) => { + if let Some(observed) = &observed + && let Ok(reason) = + openshell_core::proto::WebSocketSessionEndReason::try_from( + end.reason, + ) + { + let _ = observed.send(ObservedWebSocketRequest::SessionEnd(reason)); + } + None + } + _ => None, + }; + if let Some(response) = response + && responses_tx.send(Ok(response)).await.is_err() + { + break; + } + } + }); + Ok(Response::new(Box::pin(ReceiverStream::new(responses_rx)))) + } + } + + async fn recording_middleware_session( + scheme: &str, + ) -> ( + openshell_supervisor_middleware::WebSocketSession, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle>, + ) { + recording_middleware_session_with_controls(scheme, None, None).await + } + + async fn recording_middleware_session_with_controls( + scheme: &str, + message_received: Option>, + release_message: Option>, + ) -> ( + openshell_supervisor_middleware::WebSocketSession, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle>, + ) { + use openshell_core::proto::SupervisorMiddlewareService; + use openshell_supervisor_middleware::{ChainEntry, MiddlewareRegistry, OnError}; + + let (observed_tx, observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(OpenAiWebSocketRedactor { + observed: Some(observed_tx), + message_received, + release_message, + ..Default::default() + })) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = MiddlewareRegistry::connect_services( + Vec::new(), + vec![SupervisorMiddlewareService { + name: "openai-redactor".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "2s".into(), + tls_ca_cert_pem: Vec::new(), + audience: String::new(), + allow_insecure_transport: false, + }], + ) + .await + .expect("connect middleware"); + let runner = openshell_supervisor_middleware::ChainRunner::from_registry(registry); + let preflight = runner + .preflight_websocket( + &[ChainEntry { + name: "redact-openai".into(), + implementation: "openai-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: "session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), + scheme: scheme.into(), + host: "api.openai.com".into(), + port: if scheme == "wss" { 443 } else { 80 }, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + + ( + preflight.session.expect("middleware inspects session"), + observed_rx, + shutdown_tx, + server_task, + ) + } + + // Companion to the HTTP-path assertion in openshell-supervisor-middleware: + // proves the WebSocket preflight forwards the request context's workspace to + // the middleware service. + #[tokio::test] + async fn websocket_preflight_forwards_workspace_to_middleware() { + use openshell_core::proto::SupervisorMiddlewareService; + use openshell_supervisor_middleware::{ChainEntry, MiddlewareRegistry, OnError}; + + let (preflight_tx, mut preflight_rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(OpenAiWebSocketRedactor { + preflight_observed: Some(preflight_tx), + ..Default::default() + })) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = MiddlewareRegistry::connect_services( + Vec::new(), + vec![SupervisorMiddlewareService { + name: "openai-redactor".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "2s".into(), + tls_ca_cert_pem: Vec::new(), + audience: String::new(), + allow_insecure_transport: false, + }], + ) + .await + .expect("connect middleware"); + let runner = openshell_supervisor_middleware::ChainRunner::from_registry(registry); + runner + .preflight_websocket( + &[ChainEntry { + name: "redact-openai".into(), + implementation: "openai-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: "session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "team-a".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + + assert_eq!( + preflight_rx.recv().await, + Some("team-a".to_string()), + "WebSocket preflight must forward the workspace to the middleware" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; + } + + async fn assert_invalid_close_termination(payload: Vec, expected_close_code: u16) { + let (mut session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + assert!(session.start("").await.allowed); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionStart) + )); + + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + relay_with_options( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + RelayOptions { + policy_name: "rest-api", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: Some(session), + middleware_context: None, + deny_uninspected_credentials: false, + }, + ) + .await + }); + + client_app + .write_all(&masked_frame(true, OPCODE_CLOSE, &payload)) + .await + .expect("send invalid close frame"); + client_app.flush().await.expect("flush invalid close frame"); + + let upstream_close = read_one_frame(&mut upstream_app).await; + assert_eq!(upstream_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(&upstream_close)[..2] + .try_into() + .expect("upstream close code"), + ), + expected_close_code + ); + let client_close = read_one_frame(&mut client_app).await; + assert_eq!(client_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes(client_close[2..4].try_into().expect("client close code")), + expected_close_code + ); + + relay + .await + .expect("join relay") + .expect_err("invalid close frame must terminate relay"); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::WebSocketSessionEndReason::ProtocolError, + )) + )); + assert!( + observed.try_recv().is_err(), + "control-frame failure must not invoke middleware" + ); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn invalid_close_frames_close_both_peers_and_report_protocol_session_end() { + assert_invalid_close_termination(vec![0x03], 1002).await; + assert_invalid_close_termination(close_payload(1005, b""), 1002).await; + assert_invalid_close_termination(close_payload(1000, &[0xff]), 1007).await; + } + + #[test] + fn invalid_close_frames_have_stable_telemetry_classes() { + let cases = [ + ( + vec![0x03], + WebSocketTerminationCause::ProtocolError, + FrameFailureClass::InvalidCloseFrame, + 1002, + ), + ( + close_payload(1005, b""), + WebSocketTerminationCause::ProtocolError, + FrameFailureClass::InvalidCloseFrame, + 1002, + ), + ( + close_payload(1000, &[0xff]), + WebSocketTerminationCause::InvalidUtf8, + FrameFailureClass::InvalidUtf8, + 1007, + ), + ]; + + for (payload, expected_cause, expected_class, expected_close_code) in cases { + let frame_error = + validate_close_payload(&payload).expect_err("close payload must be invalid"); + let termination = WebSocketTermination::from(frame_error); + assert_eq!(termination.cause, expected_cause); + assert_eq!(termination.failure_class, Some(expected_class)); + assert_eq!(termination.cause.close_code(), Some(expected_close_code)); + + let event = + protocol_failure_event("gateway.example.test", 443, "test-policy", expected_class); + assert_eq!( + event.base().status_detail.as_deref(), + Some(expected_class.as_str()) + ); + } + } + + #[tokio::test] + async fn partial_control_payload_eof_is_peer_disconnect_without_generated_close() { + let (mut session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + assert!(session.start("").await.allowed); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionStart) + )); + + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + relay_with_options( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + RelayOptions { + policy_name: "rest-api", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: Some(session), + middleware_context: None, + deny_uninspected_credentials: false, + }, + ) + .await + }); + + client_app + .write_all(&[0x80 | OPCODE_CLOSE, 0x80 | 2, 0x37, 0xfa, 0x21, 0x3d]) + .await + .expect("send truncated close frame"); + drop(client_app); + + let mut upstream_output = Vec::new(); + upstream_app + .read_to_end(&mut upstream_output) + .await + .expect("read upstream shutdown"); + assert!( + upstream_output.is_empty(), + "peer EOF must not generate an upstream close frame" + ); + relay + .await + .expect("join relay") + .expect_err("truncated payload must end relay"); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::WebSocketSessionEndReason::PeerDisconnect, + )) + )); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn denied_websocket_session_start_reports_middleware_failure_before_close() { + let (session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + + crate::l7::relay::handle_upgrade( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + crate::l7::relay::UpgradeRelayOptions { + websocket_request: true, + assembly_budget: Some(WebSocketAssemblyBudget::default()), + middleware_session: Some(session), + selected_subprotocol: Some("x".repeat(257)), + ..Default::default() + }, + ) + .await + .expect("denied session start closes cleanly"); + + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::WebSocketSessionEndReason::MiddlewareFailure, + )) + )); + assert!( + observed.try_recv().is_err(), + "session start failure must send exactly one terminal event" + ); + + let client_close = read_one_frame(&mut client_app).await; + assert_eq!(client_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes(client_close[2..4].try_into().expect("client close code")), + 1008 + ); + let upstream_close = read_one_frame(&mut upstream_app).await; + assert_eq!(upstream_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(&upstream_close)[..2] + .try_into() + .expect("upstream close code"), + ), + 1008 + ); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn selected_middleware_reports_binary_gap_and_inspects_later_text_at_next_sequence() { + let (session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + crate::l7::relay::handle_upgrade( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + crate::l7::relay::UpgradeRelayOptions { + websocket_request: true, + assembly_budget: Some(WebSocketAssemblyBudget::default()), + ctx: Some(&L7EvalContext { + host: "api.openai.com".into(), + port: 443, + policy_name: "rest-api".into(), + ..Default::default() + }), + policy_name: "rest-api".into(), + middleware_session: Some(session), + ..Default::default() + }, + ) + .await + }); + + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionStart) + )); + + let binary = masked_frame(true, OPCODE_BINARY, &[0, 1, 2, 3, 255]); + let text = masked_frame(true, OPCODE_TEXT, br#"{"type":"response.create"}"#); + client_app + .write_all(&binary) + .await + .expect("send binary message"); + client_app + .write_all(&text) + .await + .expect("send text message"); + + assert_eq!(read_one_frame(&mut upstream_app).await, binary); + assert_eq!( + decode_masked_text_frame(&read_one_frame(&mut upstream_app).await), + r#"{"type":"response.create"}"# + ); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::Message { + sequence: 2, + payload, + }) if payload == r#"{"type":"response.create"}"# + )); + + drop(client_app); + drop(upstream_app); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("relay should stop") + .expect("join relay") + .expect("relay result"); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + async fn assert_middleware_only_relay_observes_reload(scheme: &str, fragmented: bool) { + use openshell_supervisor_middleware::MiddlewareRegistry; + + let (session, mut observed, shutdown_tx, server_task) = + recording_middleware_session(scheme).await; + let engine = Arc::new( + OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").expect("test policy"), + ); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .expect("generation guard"); + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let port = if scheme == "wss" { 443 } else { 80 }; + let relay = tokio::spawn(async move { + crate::l7::relay::handle_upgrade( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + port, + crate::l7::relay::UpgradeRelayOptions { + websocket_request: true, + assembly_budget: Some(WebSocketAssemblyBudget::default()), + generation_guard: Some(&generation_guard), + ctx: Some(&L7EvalContext { + host: "api.openai.com".into(), + port, + policy_name: "rest-api".into(), + ..Default::default() + }), + policy_name: "rest-api".into(), + middleware_session: Some(session), + ..Default::default() + }, + ) + .await + }); + + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionStart) + )); + let stale_frame = if fragmented { + masked_frame(true, OPCODE_CONTINUATION, b"message") + } else { + masked_frame(true, OPCODE_TEXT, b"stale-message") + }; + if fragmented { + client_app + .write_all(&masked_frame(false, OPCODE_TEXT, b"stale-")) + .await + .expect("send initial fragment"); + } else { + client_app + .write_all(&stale_frame[..7]) + .await + .expect("send frame header and partial payload"); + } + + engine + .replace_middleware_registry(MiddlewareRegistry::default()) + .expect("invalidate generation"); + let stale_input = if fragmented { + &stale_frame[..6] + } else { + &stale_frame[7..] + }; + client_app + .write_all(stale_input) + .await + .expect("send stale data frame"); + + let upstream_close = read_one_frame(&mut upstream_app).await; + assert_eq!(upstream_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(&upstream_close)[..2] + .try_into() + .expect("upstream close code"), + ), + 1012 + ); + let client_close = read_one_frame(&mut client_app).await; + assert_eq!(client_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes(client_close[2..4].try_into().expect("client close code")), + 1012 + ); + let error = relay + .await + .expect("join relay") + .expect_err("stale generation must terminate relay"); + assert!(error.to_string().contains("policy generation is stale")); + match observed.recv().await { + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::WebSocketSessionEndReason::PolicyReload, + )) => {} + Some(ObservedWebSocketRequest::Message { payload, .. }) => { + panic!("stale message leaked {} bytes to middleware", payload.len()); + } + other => panic!("unexpected middleware lifecycle event: {other:?}"), + } + assert!( + observed.try_recv().is_err(), + "stale data must not reach middleware" + ); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn middleware_only_rest_wss_relay_observes_policy_reload() { + assert_middleware_only_relay_observes_reload("wss", false).await; + } + + #[tokio::test] + async fn middleware_only_rest_ws_relay_stops_fragment_after_policy_reload() { + assert_middleware_only_relay_observes_reload("ws", true).await; + } + + #[tokio::test] + async fn reload_during_middleware_evaluation_blocks_credentials_and_payload() { + use openshell_supervisor_middleware::MiddlewareRegistry; + + let message_received = Arc::new(tokio::sync::Notify::new()); + let release_message = Arc::new(tokio::sync::Notify::new()); + let (session, mut observed, shutdown_tx, server_task) = + recording_middleware_session_with_controls( + "wss", + Some(Arc::clone(&message_received)), + Some(Arc::clone(&release_message)), + ) + .await; + let engine = Arc::new( + OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").expect("test policy"), + ); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .expect("generation guard"); + let assembly_budget = engine.websocket_assembly_budget(); + let (child_env, resolver) = resolver(); + let placeholder = child_env + .get("DISCORD_BOT_TOKEN") + .expect("credential placeholder") + .clone(); + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + crate::l7::relay::handle_upgrade( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + crate::l7::relay::UpgradeRelayOptions { + websocket_request: true, + websocket: crate::l7::relay::WebSocketUpgradeBehavior { + credential_rewrite: true, + ..Default::default() + }, + assembly_budget: Some(assembly_budget), + secret_resolver: Some(Arc::new(resolver)), + generation_guard: Some(&generation_guard), + policy_name: "rest-api".into(), + middleware_session: Some(session), + ..Default::default() + }, + ) + .await + }); + + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionStart) + )); + let payload = format!(r#"{{"authorization":"{placeholder}"}}"#); + client_app + .write_all(&masked_frame(true, OPCODE_TEXT, payload.as_bytes())) + .await + .expect("send credential-bearing message"); + message_received.notified().await; + + engine + .replace_middleware_registry(MiddlewareRegistry::default()) + .expect("invalidate generation"); + release_message.notify_one(); + + let upstream_close = read_one_frame(&mut upstream_app).await; + assert_eq!(upstream_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(&upstream_close)[..2] + .try_into() + .expect("upstream close code"), + ), + 1012 + ); + assert!( + !String::from_utf8_lossy(&upstream_close).contains("real-token"), + "resolved credential must not reach upstream" + ); + let error = relay + .await + .expect("join relay") + .expect_err("stale generation must terminate relay"); + assert!(error.to_string().contains("policy generation is stale")); + + let mut end_reason = None; + while let Some(event) = observed.recv().await { + if let ObservedWebSocketRequest::SessionEnd(reason) = event { + end_reason = Some(reason); + break; + } + } + assert_eq!( + end_reason, + Some(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + ); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn pre_upgrade_reload_finalizes_session_as_policy_reload() { + use openshell_supervisor_middleware::MiddlewareRegistry; + + let (session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + let engine = + OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").expect("test policy"); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .expect("generation guard"); + engine + .replace_middleware_registry(MiddlewareRegistry::default()) + .expect("invalidate generation"); + let mut session = Some(session); + + crate::l7::relay::finalize_websocket_pre_upgrade( + &mut session, + &generation_guard, + "api.openai.com", + 443, + "rest-api", + Ok(crate::l7::provider::RelayOutcome::Reusable), + ) + .await + .expect_err("reload before upgrade must terminate"); + assert!(session.is_none()); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::WebSocketSessionEndReason::PolicyReload + )) + )); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn reload_after_forwarded_upgrade_uses_typed_close_path() { + use openshell_supervisor_middleware::MiddlewareRegistry; + + let (session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + let engine = + OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").expect("test policy"); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .expect("generation guard"); + engine + .replace_middleware_registry(MiddlewareRegistry::default()) + .expect("invalidate generation"); + let mut session = Some(session); + let upgrade_response = b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"; + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + relay_client + .write_all(upgrade_response) + .await + .expect("forward upgrade response"); + + let outcome = crate::l7::relay::finalize_websocket_pre_upgrade( + &mut session, + &generation_guard, + "api.openai.com", + 443, + "rest-api", + Ok(crate::l7::provider::RelayOutcome::Upgraded { + overflow: Vec::new(), + websocket_permessage_deflate: false, + websocket_subprotocol: None, + }), + ) + .await + .expect("upgraded outcome must reach typed close path"); + let crate::l7::provider::RelayOutcome::Upgraded { + overflow, + websocket_permessage_deflate, + websocket_subprotocol, + } = outcome + else { + panic!("expected upgraded outcome") + }; + + let error = crate::l7::relay::handle_upgrade( + &mut relay_client, + &mut relay_upstream, + overflow, + "api.openai.com", + 443, + crate::l7::relay::UpgradeRelayOptions { + websocket_request: true, + websocket: crate::l7::relay::WebSocketUpgradeBehavior { + permessage_deflate: websocket_permessage_deflate, + ..Default::default() + }, + assembly_budget: Some(WebSocketAssemblyBudget::default()), + generation_guard: Some(&generation_guard), + policy_name: "rest-api".into(), + middleware_session: session.take(), + selected_subprotocol: websocket_subprotocol, + ..Default::default() + }, + ) + .await + .expect_err("stale upgraded connection must close"); + assert!(error.to_string().contains("policy generation is stale")); + + let mut client_output = Vec::new(); + client_app + .read_to_end(&mut client_output) + .await + .expect("read upgrade and close"); + assert_eq!(&client_output[..upgrade_response.len()], upgrade_response); + assert_eq!( + &client_output[upgrade_response.len()..], + &[0x88, 0x02, 0x03, 0xf4] + ); + let upstream_close = read_one_frame(&mut upstream_app).await; + assert_eq!(upstream_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(&upstream_close)[..2] + .try_into() + .expect("upstream close code"), + ), + 1012 + ); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::WebSocketSessionEndReason::PolicyReload + )) + )); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); + } + + #[tokio::test] + async fn graphql_policy_denial_reports_policy_denial_to_middleware() { + let (mut session, mut observed, shutdown_tx, server_task) = + recording_middleware_session("wss").await; + assert!(session.start("").await.allowed); + assert!(matches!( + observed.recv().await, + Some(ObservedWebSocketRequest::SessionStart) + )); + + let engine = OpaEngine::from_strings(TEST_POLICY, GRAPHQL_WS_POLICY) + .expect("GraphQL WebSocket policy should load"); + let network_input = NetworkInput { + host: "realtime.graphql.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let generation = engine + .evaluate_network_action_with_generation(&network_input) + .expect("network action should evaluate") + .1; + let tunnel_engine = engine + .clone_engine_for_tunnel(generation) + .expect("tunnel engine"); + let ctx = L7EvalContext { + host: "realtime.graphql.test".into(), + port: 443, + policy_name: "graphql_ws".into(), + binary_path: "/usr/bin/node".into(), + ..Default::default() + }; + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + relay_with_options( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "realtime.graphql.test", + 443, + RelayOptions { + policy_name: "graphql_ws", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: Some(tunnel_engine.generation_guard()), + provider_credentials: None, + target: "/graphql", + inspector: Some(InspectionOptions { + engine: &tunnel_engine, + ctx: &ctx, + enforcement: EnforcementMode::Enforce, + target: "/graphql".into(), + query_params: HashMap::new(), + graphql_policy: true, + }), + compression: WebSocketCompression::None, + middleware_session: Some(session), + middleware_context: Some(&ctx), + deny_uninspected_credentials: false, + }, + ) + .await + }); - #[test] - fn classifies_graphql_transport_ws_subscribe_operation() { - let message = r#"{"type":"subscribe","id":"1","payload":{"query":"subscription NewMessages { messageAdded }"}}"#; + let payload = + br#"{"type":"subscribe","id":"1","payload":{"query":"query Admin { adminAuditLog }"}}"#; + client_app + .write_all(&masked_frame(true, OPCODE_TEXT, payload)) + .await + .expect("send policy-denied message"); - match classify_graphql_websocket_message(message) { - GraphqlWebSocketMessage::Operation { - message_type, - graphql, - } => { - assert_eq!(message_type, "subscribe"); - assert!( - graphql.error.is_none(), - "unexpected error: {:?}", - graphql.error - ); - assert_eq!(graphql.operations.len(), 1); - assert_eq!(graphql.operations[0].operation_type, "subscription"); - assert_eq!( - graphql.operations[0].operation_name.as_deref(), - Some("NewMessages") + let upstream_close = read_one_frame(&mut upstream_app).await; + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(&upstream_close)[..2] + .try_into() + .expect("upstream close code"), + ), + 1008 + ); + let client_close = read_one_frame(&mut client_app).await; + assert_eq!( + u16::from_be_bytes(client_close[2..4].try_into().expect("client close code")), + 1008 + ); + let error = relay + .await + .expect("join relay") + .expect_err("policy denial must terminate relay"); + assert!( + error + .to_string() + .contains("websocket GraphQL message denied") + ); + match observed.recv().await { + Some(ObservedWebSocketRequest::SessionEnd( + openshell_core::proto::WebSocketSessionEndReason::PolicyDenial, + )) => {} + Some(ObservedWebSocketRequest::Message { payload, .. }) => { + panic!( + "built-in policy denial leaked {} bytes to middleware", + payload.len() ); - assert_eq!(graphql.operations[0].fields, vec!["messageAdded"]); - } - other @ GraphqlWebSocketMessage::Control { .. } => { - panic!("expected operation, got {other:?}") } + other => panic!("unexpected middleware lifecycle event: {other:?}"), } - } - - #[test] - fn classifies_legacy_graphql_ws_start_operation() { - let message = r#"{"type":"start","id":"1","payload":{"query":"query Viewer { viewer }"}}"#; - match classify_graphql_websocket_message(message) { - GraphqlWebSocketMessage::Operation { - message_type, - graphql, - } => { - assert_eq!(message_type, "start"); - assert!( - graphql.error.is_none(), - "unexpected error: {:?}", - graphql.error - ); - assert_eq!(graphql.operations[0].operation_type, "query"); - assert_eq!(graphql.operations[0].fields, vec!["viewer"]); - } - other @ GraphqlWebSocketMessage::Control { .. } => { - panic!("expected operation, got {other:?}") - } - } + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware server") + .expect("middleware server"); } - #[test] - fn classifies_graphql_websocket_control_message_without_payload_logging() { - match classify_graphql_websocket_message( - r#"{"type":"connection_init","payload":{"authorization":"secret"}}"#, - ) { - GraphqlWebSocketMessage::Control { message_type } => { - assert_eq!(message_type, "connection_init"); - } - other @ GraphqlWebSocketMessage::Operation { .. } => { - panic!("expected control message, got {other:?}") - } - } - } + #[tokio::test] + async fn parsed_relay_sends_redacted_openai_event_to_upstream() { + use openshell_core::proto::SupervisorMiddlewareService; + use openshell_supervisor_middleware::{ChainEntry, MiddlewareRegistry, OnError}; - #[test] - fn unsupported_graphql_websocket_message_type_fails_closed() { - match classify_graphql_websocket_message(r#"{"type":"next","id":"1"}"#) { - GraphqlWebSocketMessage::Operation { graphql, .. } => { - assert!( - graphql - .error - .as_deref() - .is_some_and(|error| error.contains("unsupported")) - ); - } - other @ GraphqlWebSocketMessage::Control { .. } => { - panic!("expected operation error, got {other:?}") - } - } - } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new( + OpenAiWebSocketRedactor::default(), + )) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = MiddlewareRegistry::connect_services( + Vec::new(), + vec![SupervisorMiddlewareService { + name: "openai-redactor".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "2s".into(), + tls_ca_cert_pem: Vec::new(), + audience: String::new(), + allow_insecure_transport: false, + }], + ) + .await + .expect("connect middleware"); + let runner = openshell_supervisor_middleware::ChainRunner::from_registry(registry); + let preflight = runner + .preflight_websocket( + &[ChainEntry { + name: "redact-openai".into(), + implementation: "openai-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: "session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + let mut session = preflight.session.expect("middleware inspects session"); + assert!(session.start("").await.allowed); - #[test] - fn graphql_websocket_log_summary_excludes_payload_variables_and_secrets() { - let placeholder = "openshell:resolve:env:T"; - let message = format!( - r#"{{"type":"subscribe","id":"1","payload":{{"query":"query Viewer {{ viewer }}","variables":{{"token":"{placeholder}"}}}}}}"# + let original = br#"{"type":"response.create","response":{"input":"customer-secret"}}"#; + let client_frame = masked_frame(true, OPCODE_TEXT, original); + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + relay_with_options( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + RelayOptions { + policy_name: "openai", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: Some(session), + middleware_context: None, + deny_uninspected_credentials: false, + }, + ) + .await + }); + + client_app + .write_all(&client_frame) + .await + .expect("send event"); + client_app.flush().await.expect("flush event"); + let upstream_frame = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_one_frame(&mut upstream_app), + ) + .await + .expect("upstream receives event"); + let upstream_text = decode_masked_text_frame(&upstream_frame); + assert!(upstream_text.contains("[REDACTED]")); + assert!(!upstream_text.contains("customer-secret")); + + let denied = br#"{"type":"response.create","response":{"input":"deny-me"}}"#; + client_app + .write_all(&masked_frame(true, OPCODE_TEXT, denied)) + .await + .expect("send denied event"); + client_app.flush().await.expect("flush denied event"); + let upstream_close = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_one_frame(&mut upstream_app), + ) + .await + .expect("upstream receives close"); + assert_eq!(upstream_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes( + decode_masked_payload(&upstream_close)[..2] + .try_into() + .expect("close code"), + ), + 1008 + ); + let client_close = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_one_frame(&mut client_app), + ) + .await + .expect("client receives close"); + assert_eq!(client_close[0] & 0x0f, OPCODE_CLOSE); + assert_eq!( + u16::from_be_bytes(client_close[2..4].try_into().expect("close code")), + 1008 ); - let graphql = match classify_graphql_websocket_message(&message) { - GraphqlWebSocketMessage::Operation { graphql, .. } => graphql, - other @ GraphqlWebSocketMessage::Control { .. } => { - panic!("expected operation, got {other:?}") - } - }; - let summary = graphql_log_summary(&graphql); - assert!(summary.contains("type=query")); - assert!(summary.contains("fields=viewer")); - assert!(!summary.contains(placeholder)); - assert!(!summary.contains("real-token")); - assert!(!summary.contains("variables")); - assert!(!summary.contains("token")); - assert!(!summary.contains("secret_len")); + drop(client_app); + drop(upstream_app); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), relay).await; + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware") + .expect("serve middleware"); } #[tokio::test] - async fn rewrites_discord_like_identify_text_payload() { - let (child_env, _) = resolver(); - let placeholder = child_env.get("DISCORD_BOT_TOKEN").unwrap(); - let payload = format!(r#"{{"op":2,"d":{{"token":"{placeholder}"}}}}"#); + async fn fully_disabled_session_bypasses_saturated_work_budget_without_rpc() { + use openshell_core::proto::SupervisorMiddlewareService; + use openshell_supervisor_middleware::{ChainEntry, MiddlewareRegistry, OnError}; - let output = run_client_to_server(masked_frame(true, OPCODE_TEXT, payload.as_bytes())) + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await - .expect("relay should succeed"); + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(OpenAiWebSocketRedactor { + observed: Some(observed_tx), + close_on_first_message: true, + ..Default::default() + })) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = MiddlewareRegistry::connect_services( + Vec::new(), + vec![SupervisorMiddlewareService { + name: "openai-redactor".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "2s".into(), + tls_ca_cert_pem: Vec::new(), + audience: String::new(), + allow_insecure_transport: false, + }], + ) + .await + .expect("connect middleware"); + let runner = openshell_supervisor_middleware::ChainRunner::from_registry(registry); + let preflight = runner + .preflight_websocket( + &[ChainEntry { + name: "redact-openai".into(), + implementation: "openai-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }], + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: "disabled-session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + let mut session = preflight.session.expect("middleware inspects session"); + assert!(session.start("").await.allowed); + assert!(matches!( + observed_rx.recv().await, + Some(ObservedWebSocketRequest::SessionStart) + )); - assert_eq!( - decode_masked_text_frame(&output), - r#"{"op":2,"d":{"token":"real-token"}}"# + let failed = session + .evaluate_text(r#"{"type":"response.create"}"#.into()) + .await; + assert!(failed.allowed); + assert!(failed.invocations[0].stage_disabled); + assert!(matches!( + observed_rx.recv().await, + Some(ObservedWebSocketRequest::Message { .. }) + )); + + let mut occupied_work = Vec::new(); + for _ in 0..openshell_supervisor_middleware::MAX_CONCURRENT_MIDDLEWARE_WORK { + occupied_work.push( + runner + .reserve_middleware_work_admission() + .await + .expect("fill middleware work budget"), + ); + } + + let original = r#"{"type":"response.cancel","reason":"keep-original"}"#; + let client_frame = masked_frame(true, OPCODE_TEXT, original.as_bytes()); + let (mut client_app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + relay_with_options( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "api.openai.com", + 443, + RelayOptions { + policy_name: "openai", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", + inspector: None, + compression: WebSocketCompression::None, + middleware_session: Some(session), + middleware_context: None, + deny_uninspected_credentials: false, + }, + ) + .await + }); + + client_app + .write_all(&client_frame) + .await + .expect("send bypassed event"); + client_app.flush().await.expect("flush bypassed event"); + let upstream_frame = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_one_frame(&mut upstream_app), + ) + .await + .expect("fully disabled session bypasses saturated work"); + assert_eq!(decode_masked_text_frame(&upstream_frame), original); + assert!( + observed_rx.try_recv().is_err(), + "fully disabled session must not make another middleware RPC" ); + + drop(occupied_work); + drop(client_app); + drop(upstream_app); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("relay finishes after disconnect") + .expect("join relay") + .expect("relay"); + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join middleware") + .expect("serve middleware"); } #[tokio::test] - async fn upgraded_relay_rewrites_client_text_before_upstream_receives_it() { - let (child_env, resolver) = resolver(); - let placeholder = child_env.get("DISCORD_BOT_TOKEN").unwrap(); - let payload = format!(r#"{{"op":2,"d":{{"token":"{placeholder}"}}}}"#); - let client_frame = masked_frame(true, OPCODE_TEXT, payload.as_bytes()); - assert!( - !String::from_utf8_lossy(&client_frame).contains("real-token"), - "client-side fixture must not contain the real token" - ); + async fn parsed_relay_uses_builtin_regex_for_complete_client_text_messages() { + use openshell_supervisor_middleware::{ChainEntry, MiddlewareRegistry, OnError}; + + let registry = MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("connect built-in middleware"); + let runner = openshell_supervisor_middleware::ChainRunner::from_registry(registry); + let preflight = runner + .preflight_websocket( + &[ChainEntry { + name: "regex-redactor".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_REGEX.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: "builtin-regex-session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + let mut session = preflight.session.expect("built-in inspects session"); + assert!(session.start("").await.allowed); + let original = br#"{"type":"response.create","response":{"input":"sk-ABCDEFGHIJKLMNOP"}}"#; let (mut client_app, mut relay_client) = tokio::io::duplex(4096); let (mut relay_upstream, mut upstream_app) = tokio::io::duplex(4096); let relay = tokio::spawn(async move { @@ -1552,35 +4996,75 @@ network_policies: &mut relay_client, &mut relay_upstream, Vec::new(), - "gateway.example.test", + "api.openai.com", 443, RelayOptions { - policy_name: "test-policy", - resolver: Some(&resolver), + policy_name: "openai", + assembly_budget: WebSocketAssemblyBudget::default(), + resolver: None, + generation_guard: None, + provider_credentials: None, + target: "/", inspector: None, compression: WebSocketCompression::None, + middleware_session: Some(session), + middleware_context: None, + deny_uninspected_credentials: false, }, ) .await }); - client_app.write_all(&client_frame).await.unwrap(); - client_app.flush().await.unwrap(); - + let split = original + .windows(b"sk-ABC".len()) + .position(|window| window == b"sk-ABC") + .expect("token prefix") + + b"sk-ABC".len(); + client_app + .write_all(&masked_frame(false, OPCODE_TEXT, &original[..split])) + .await + .expect("send initial event fragment"); + client_app + .write_all(&masked_frame(true, OPCODE_CONTINUATION, &original[split..])) + .await + .expect("send final event fragment"); let upstream_frame = tokio::time::timeout( std::time::Duration::from_secs(2), read_one_frame(&mut upstream_app), ) .await - .expect("upstream should receive rewritten frame"); + .expect("upstream receives event"); + let upstream_text = decode_masked_text_frame(&upstream_frame); assert_eq!( - decode_masked_text_frame(&upstream_frame), - r#"{"op":2,"d":{"token":"real-token"}}"# + upstream_text, + r#"{"type":"response.create","response":{"input":"[REDACTED]"}}"# ); drop(client_app); drop(upstream_app); - let _ = tokio::time::timeout(std::time::Duration::from_secs(2), relay).await; + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("relay finishes after disconnect") + .expect("join relay") + .expect("relay"); + } + + #[tokio::test] + #[ignore = "PR 2 adds return-path inspection and completes this full-duplex fixture"] + async fn pr2_full_duplex_external_middleware_vertical_slice() { + // PR 2 should extend the real relay fixture above with controllable + // server-to-client transforms plus slow, hanging, closed, duplicate, + // missing, out-of-order, and oversized middleware responses. + let deferred_faults = [ + "slow", + "hanging", + "closed", + "duplicate", + "missing", + "out-of-order", + "oversized", + ]; + assert_eq!(deferred_faults.len(), 7); } #[tokio::test] @@ -1793,6 +5277,40 @@ network_policies: assert_eq!(output, frame); } + #[tokio::test] + async fn credentialed_endpoint_denies_binary_frame_without_opt_in() { + let frame = masked_frame(true, OPCODE_BINARY, &[0, 1, 2, 3, 255]); + + let (result, output) = run_client_to_server_guarded(frame).await; + + assert!( + result + .unwrap_err() + .to_string() + .contains("binary frame denied") + ); + assert!(output.is_empty()); + } + + #[tokio::test] + async fn credentialed_endpoint_denies_text_placeholder_without_rewrite() { + let frame = masked_frame( + true, + OPCODE_TEXT, + br#"{"token":"openshell:resolve:env:API_TOKEN"}"#, + ); + + let (result, output) = run_client_to_server_guarded(frame).await; + + assert!( + result + .unwrap_err() + .to_string() + .contains("rewrite is disabled") + ); + assert!(output.is_empty()); + } + #[tokio::test] async fn rejects_reserved_opcode() { let err = run_client_to_server(masked_frame(true, 0x3, b"reserved")) diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index f5d0205e3a..4fec48b300 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -12,12 +12,12 @@ pub mod identity; pub mod inference_routes; pub mod l7; pub mod opa; +pub(crate) mod policy_dns; pub mod policy_local; pub mod procfs; pub mod proxy; pub mod run; pub mod sigv4; -mod spiffe_endpoint; mod token_grant; pub mod upstream_proxy; diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index d6af02a9f0..63aa2c2c70 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -50,6 +50,34 @@ pub enum NetworkAction { Deny { reason: String }, } +/// Endpoint identity and metadata captured with one policy generation. +#[derive(Debug, Clone)] +pub struct MatchedEndpoint { + pub policy_name: String, + pub endpoint_index: usize, + pub endpoint: regorus::Value, +} + +/// Policy-DNS eligible endpoint metadata captured from one policy generation. +/// +/// This is policy data only. It deliberately contains no process identity or +/// network authorization decision. +#[derive(Debug, Clone)] +pub struct PolicyDnsEligibilitySnapshot { + pub endpoints: Vec, + pub generation: u64, +} + +/// Atomic policy result used to authorize and materialize one egress request. +#[derive(Debug, Clone)] +pub struct EgressAuthorization { + pub action: NetworkAction, + pub endpoint_configs: Vec, + pub matched_endpoints: Vec, + pub exact_declared_endpoint_host: bool, + pub generation: u64, +} + /// Input for a network access policy evaluation. pub struct NetworkInput { pub host: String, @@ -124,6 +152,7 @@ pub struct OpaEngine { engine: Mutex, generation: Arc, middleware_runner: RwLock, + websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, generation_tx: watch::Sender, fail_closed_reason: RwLock>, } @@ -198,6 +227,7 @@ pub struct TunnelPolicyEngine { engine: Mutex, generation_guard: PolicyGenerationGuard, middleware_runner: ChainRunner, + websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, } impl TunnelPolicyEngine { @@ -225,6 +255,12 @@ impl TunnelPolicyEngine { &self.middleware_runner } + pub(crate) fn websocket_assembly_budget( + &self, + ) -> crate::l7::websocket::WebSocketAssemblyBudget { + self.websocket_assembly_budget.clone() + } + /// Query the ordered middleware chain for a destination within this tunnel. pub fn query_middleware_chain(&self, input: &NetworkInput) -> Result> { let mut engine = self @@ -236,6 +272,12 @@ impl TunnelPolicyEngine { } impl OpaEngine { + pub(crate) fn websocket_assembly_budget( + &self, + ) -> crate::l7::websocket::WebSocketAssemblyBudget { + self.websocket_assembly_budget.clone() + } + fn with_engine(engine: regorus::Engine) -> Self { let generation = Arc::new(AtomicU64::new(0)); let (generation_tx, _) = watch::channel(0); @@ -243,6 +285,7 @@ impl OpaEngine { engine: Mutex::new(engine), generation, middleware_runner: RwLock::new(ChainRunner::default()), + websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget::default(), generation_tx, fail_closed_reason: RwLock::new(None), } @@ -254,15 +297,6 @@ impl OpaEngine { generation } - #[cfg(test)] - pub(crate) fn poison_lock_for_test(&self) { - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _guard = self.engine.lock().expect("test engine lock"); - panic!("poison OPA engine lock for compatibility fallback test"); - })); - assert!(self.engine.is_poisoned()); - } - /// Load policy from a `.rego` rules file and data from a YAML file. /// /// Preprocesses the YAML data to expand access presets and validate L7 config. @@ -461,9 +495,7 @@ impl OpaEngine { }); } - engine - .set_input_json(&input_json.to_string()) - .map_err(|e| miette::miette!("{e}"))?; + set_regorus_input(&mut engine, input_json)?; let allowed = engine .eval_rule("data.openshell.sandbox.allow_network".into()) @@ -504,6 +536,13 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(NetworkAction, u64)> { + let authorization = self.authorize_egress(input)?; + Ok((authorization.action, authorization.generation)) + } + + /// Authorize egress and return all connection metadata from one Rego result + /// evaluated against one policy generation. + pub fn authorize_egress(&self, input: &NetworkInput) -> Result { #[cfg(test)] record_test_opa_query(); @@ -521,36 +560,94 @@ impl OpaEngine { .map_err(|_| miette::miette!("OPA fail-closed state lock poisoned"))? .clone(); if let Some(reason) = fail_closed_reason { - return Ok((NetworkAction::Deny { reason }, generation)); + return Ok(EgressAuthorization { + action: NetworkAction::Deny { reason }, + endpoint_configs: Vec::new(), + matched_endpoints: Vec::new(), + exact_declared_endpoint_host: false, + generation, + }); } - engine - .set_input_json(&input_json.to_string()) - .map_err(|e| miette::miette!("{e}"))?; + set_regorus_input(&mut engine, input_json)?; - let action_val = engine - .eval_rule("data.openshell.sandbox.network_action".into()) + let result = engine + .eval_rule("data.openshell.sandbox.egress_authorization".into()) .map_err(|e| miette::miette!("{e}"))?; - let action_str = value_to_string(&action_val); + let action_str = get_str(&result, "action").unwrap_or_default(); + let matched_policy = get_str(&result, "matched_policy").filter(|name| !name.is_empty()); + let endpoint_configs = match get_field(&result, "endpoint_configs") { + Some(regorus::Value::Array(values)) => values.to_vec(), + _ => Vec::new(), + }; + let matched_endpoints = match get_field(&result, "matched_endpoints") { + Some(regorus::Value::Array(values)) => { + values.iter().filter_map(parse_matched_endpoint).collect() + } + _ => Vec::new(), + }; + let exact_declared_endpoint_host = + get_bool(&result, "exact_declared_endpoint_host").unwrap_or(false); - let matched = engine - .eval_rule("data.openshell.sandbox.matched_network_policy".into()) - .map_err(|e| miette::miette!("{e}"))?; - let matched_policy = if matched == regorus::Value::Undefined { - None + let action = if action_str == "allow" { + NetworkAction::Allow { matched_policy } } else { - Some(value_to_string(&matched)) + NetworkAction::Deny { + reason: get_str(&result, "deny_reason") + .filter(|reason| !reason.is_empty()) + .unwrap_or_else(|| "network connections not allowed by policy".to_string()), + } }; - if action_str == "allow" { - Ok((NetworkAction::Allow { matched_policy }, generation)) - } else { - let reason_val = engine - .eval_rule("data.openshell.sandbox.deny_reason".into()) - .map_err(|e| miette::miette!("{e}"))?; - let reason = value_to_string(&reason_val); - Ok((NetworkAction::Deny { reason }, generation)) + Ok(EgressAuthorization { + action, + endpoint_configs, + matched_endpoints, + exact_declared_endpoint_host, + generation, + }) + } + + /// Return all explicit TCP endpoints eligible for policy DNS. + /// + /// The owned endpoint records and generation are captured while holding + /// the engine lock, so reloads cannot mix data from one generation with + /// the generation number of another. Fail-closed quarantine produces an + /// empty snapshot for its quarantine generation. + pub fn policy_dns_eligibility_snapshot(&self) -> Result { + let mut engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + let generation = self.current_generation(); + + if self + .fail_closed_reason + .read() + .map_err(|_| miette::miette!("OPA fail-closed state lock poisoned"))? + .is_some() + { + return Ok(PolicyDnsEligibilitySnapshot { + endpoints: Vec::new(), + generation, + }); } + + let value = engine + .eval_rule("data.openshell.sandbox.policy_dns_eligible_endpoint_records".into()) + .map_err(|error| miette::miette!("{error}"))?; + let endpoints = match value { + regorus::Value::Array(values) => { + values.iter().filter_map(parse_matched_endpoint).collect() + } + regorus::Value::Undefined => Vec::new(), + other => parse_matched_endpoint(&other).into_iter().collect(), + }; + + Ok(PolicyDnsEligibilitySnapshot { + endpoints, + generation, + }) } /// Reload policy and data from strings (data is YAML). @@ -634,8 +731,6 @@ impl OpaEngine { .engine .into_inner() .map_err(|_| miette::miette!("lock poisoned on new engine"))?; - let new_runner = ChainRunner::from_registry(registry); - // Match clone_engine_for_tunnel's lock order (engine, then runner) so // readers can observe only the old pair or the new pair. let mut engine = self @@ -646,6 +741,7 @@ impl OpaEngine { .middleware_runner .write() .map_err(|_| miette::miette!("middleware runner lock poisoned"))?; + let new_runner = runner.with_replacement_registry(registry); *engine = new_engine; *runner = new_runner; *self @@ -708,14 +804,42 @@ impl OpaEngine { self.generation.load(Ordering::Acquire) } + /// Run a short operation only while `expected_generation` is current. + /// + /// The engine mutex is also the policy reload mutex. Holding it across the + /// generation comparison and callback linearizes state derived from an OPA + /// snapshot with every policy reload and fail-closed transition. Callers + /// must not perform I/O or other long-running work in `operation`. + pub(crate) fn with_current_generation( + &self, + expected_generation: u64, + operation: impl FnOnce(u64) -> T, + ) -> Result> { + let _engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + let current_generation = self.current_generation(); + if current_generation != expected_generation { + return Ok(None); + } + Ok(Some(operation(current_generation))) + } + /// Replace the complete middleware service registry and invalidate /// existing tunnels so subsequent requests use the new service set. pub fn replace_middleware_registry(&self, registry: MiddlewareRegistry) -> Result<()> { + // Generation changes serialize through the engine lock so guarded + // publication cannot overlap any runtime generation transition. + let _engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; let mut runner = self .middleware_runner .write() .map_err(|_| miette::miette!("middleware runner lock poisoned"))?; - *runner = ChainRunner::from_registry(registry); + *runner = runner.with_replacement_registry(registry); self.advance_generation(); Ok(()) } @@ -823,9 +947,7 @@ impl OpaEngine { .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; let generation = self.current_generation(); - engine - .set_input_json(&input_json.to_string()) - .map_err(|e| miette::miette!("{e}"))?; + set_regorus_input(&mut engine, input_json)?; let val = engine .eval_rule("data.openshell.sandbox._matching_endpoint_configs".into()) @@ -838,6 +960,38 @@ impl OpaEngine { } } + /// Query every matching endpoint for credential-provenance gating. + /// + /// Unlike [`Self::query_endpoint_configs_with_generation`], this includes + /// L4-only endpoints, which carry no extended L7 config. It is answered by + /// a dedicated Rego rule so credential gating cannot alter which endpoint + /// the TLS mode and SSRF allowlist are read from. + pub fn query_endpoint_credential_guards( + &self, + input: &NetworkInput, + ) -> Result> { + let input_json = network_input_json(input); + + let mut engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + + engine + .set_input_json(&input_json.to_string()) + .map_err(|e| miette::miette!("{e}"))?; + + let val = engine + .eval_rule("data.openshell.sandbox.endpoint_credential_guards".into()) + .map_err(|e| miette::miette!("{e}"))?; + + match val { + regorus::Value::Undefined => Ok(Vec::new()), + regorus::Value::Array(values) => Ok(values.to_vec()), + other => Ok(vec![other]), + } + } + /// Query the ordered middleware chain for an admitted destination. pub fn query_middleware_chain_with_generation( &self, @@ -883,9 +1037,7 @@ impl OpaEngine { .lock() .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; - engine - .set_input_json(&input_json.to_string()) - .map_err(|e| miette::miette!("{e}"))?; + set_regorus_input(&mut engine, input_json)?; let val = engine .eval_rule("data.openshell.sandbox.exact_declared_endpoint_host".into()) @@ -918,6 +1070,7 @@ impl OpaEngine { generation_rx: self.generation_tx.subscribe(), }, middleware_runner: self.middleware_runner()?, + websocket_assembly_budget: self.websocket_assembly_budget(), }) } } @@ -1000,6 +1153,20 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { }) } +/// Sets an already-built JSON value as Regorus input without encoding and reparsing JSON text. +/// +/// The explicit fallible conversion preserves evaluator errors because Regorus's infallible +/// `From` conversion maps failures to [`regorus::Value::Undefined`]. +pub(crate) fn set_regorus_input( + engine: &mut regorus::Engine, + input: serde_json::Value, +) -> Result<()> { + let input = + serde_json::from_value::(input).map_err(|e| miette::miette!("{e}"))?; + engine.set_input(input); + Ok(()) +} + fn query_middleware_chain_locked( engine: &mut regorus::Engine, input: &NetworkInput, @@ -1102,6 +1269,21 @@ fn get_field<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Valu } } +fn parse_matched_endpoint(value: ®orus::Value) -> Option { + let policy_name = get_str(value, "policy_name")?; + let endpoint_index = match get_field(value, "endpoint_index")? { + regorus::Value::Number(number) => usize::try_from(number.as_i64()?).ok()?, + _ => return None, + }; + let endpoint = get_field(value, "endpoint")?.clone(); + + Some(MatchedEndpoint { + policy_name, + endpoint_index, + endpoint, + }) +} + fn regorus_value_to_struct(value: ®orus::Value) -> prost_types::Struct { let regorus::Value::Object(map) = value else { return prost_types::Struct::default(); @@ -1442,19 +1624,6 @@ fn normalize_l7_rule_aliases( } } -/// Resolve a policy binary path through the container's root filesystem. -/// -/// On Linux, `/proc//root/` provides access to the container's mount -/// namespace. If the policy path is a symlink inside the container -/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), returns the -/// canonical target path. Returns `None` if: -/// - Not on Linux -/// - `entrypoint_pid` is 0 (container not yet started) -/// - Path contains glob characters -/// - Path is not a symlink -/// - Resolution fails (binary doesn't exist in container) -/// - Resolved path equals the original -/// /// Normalize a path by resolving `.` and `..` components without touching /// the filesystem. Only works correctly for absolute paths. #[cfg(any(target_os = "linux", test))] @@ -1472,10 +1641,72 @@ fn normalize_path(path: &Path) -> PathBuf { result } +// Only the Linux resolver constructs the non-`Literal` variants; on other +// platforms the stub returns `Literal`, so the rest look dead there. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Debug, PartialEq, Eq)] +enum BinaryResolution { + /// Symlink resolved to a different canonical target — add it to policy. + Resolved(String), + /// Nothing to add: glob, pid 0, not a symlink, or already canonical. + Literal, + /// Candidate absent under an accessible process root — expected, quiet. + Absent, + /// The process root `/proc//root` itself is unreachable (pid gone or + /// denied) — actionable, and `CAP_SYS_PTRACE` guidance is relevant. + Inaccessible(std::io::ErrorKind), + /// The process root is reachable but the candidate path itself failed for a + /// non-NotFound reason (a parent component is permission-denied, or the path + /// forms a symlink loop) — actionable, but a target-path problem rather than + /// a process-root one, so `CAP_SYS_PTRACE` guidance does not apply. + CandidateInaccessible(std::io::ErrorKind), + /// A component mid symlink-chain failed to resolve. + ChainBroken(std::io::ErrorKind), +} + +impl BinaryResolution { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + const fn classify_first_probe_error(kind: std::io::ErrorKind) -> Self { + // Callers reach this only after confirming the process root is + // reachable, so a non-NotFound failure here is specific to the + // candidate path, not the process root. + match kind { + std::io::ErrorKind::NotFound => Self::Absent, + _ => Self::CandidateInaccessible(kind), + } + } +} + +/// Resolve a policy binary path through the container's root filesystem. +/// +/// On Linux, `/proc//root/` provides access to the container's mount +/// namespace. If the policy path is a symlink inside the container +/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), the canonical target is +/// returned as [`BinaryResolution::Resolved`]. The outcome is classified as: +/// - [`BinaryResolution::Literal`] — not on Linux, `entrypoint_pid` is 0 +/// (container not yet started), the path contains glob characters, it is not +/// a symlink, or the resolved path equals the original. +/// - [`BinaryResolution::Absent`] — the candidate does not exist under an +/// otherwise reachable process root (expected; the caller logs it quietly). +/// - [`BinaryResolution::Inaccessible`] — `/proc//root` itself is +/// unreachable (pid gone or access denied). +/// - [`BinaryResolution::CandidateInaccessible`] — the process root is +/// reachable but the candidate path failed for a non-NotFound reason. +/// - [`BinaryResolution::ChainBroken`] — a component mid symlink-chain failed, +/// or the chain forms a cycle / exceeds the kernel symlink limit. #[cfg(target_os = "linux")] -fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option { +fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> BinaryResolution { if policy_path.contains('*') || entrypoint_pid == 0 { - return None; + return BinaryResolution::Literal; + } + + // Confirm the process root itself is reachable before probing candidates. + // A leaf `ENOENT` (absent candidate) and an unreachable process root + // (pid gone -> `ENOENT`, or denied -> `EACCES`) are indistinguishable at + // the leaf path, so check the root first. Any failure here is an access + // problem, not an absent candidate, and must stay actionable. + if let Err(e) = std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) { + return BinaryResolution::Inaccessible(e.kind()); } // Walk the symlink chain inside the container filesystem using @@ -1485,6 +1716,11 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option // the specified symlink, keeping us in the container's namespace. let mut resolved = PathBuf::from(policy_path); + // Set once the walk reaches a real (non-symlink) target or a terminal + // dead end. If the iteration cap is hit while every component is still a + // symlink, this stays false and the chain is treated as broken. + let mut reached_target = false; + // Linux SYMLOOP_MAX is 40; stop before infinite loops for _ in 0..40 { let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); @@ -1496,76 +1732,93 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option let meta = match std::fs::symlink_metadata(&container_path) { Ok(m) => m, Err(e) => { - // Only warn on the first iteration (the original policy path). - // On subsequent iterations, the intermediate target may - // legitimately not exist (broken symlink chain). + // First iteration is the original policy path: an absent + // candidate (NotFound) is expected, any other error means the + // process root itself is unreachable. Later iterations are + // chain components, so a failure there is a broken symlink + // chain. Classify without logging; the caller emits the log at + // the appropriate level. if resolved.as_os_str() == policy_path { - tracing::warn!( - "Cannot access container filesystem for symlink resolution: \ - path={policy_path} container_path={container_path} pid={entrypoint_pid} \ - error={e}. Binary paths in policy will be matched literally. \ - If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ - use the canonical path instead, or run with CAP_SYS_PTRACE." - ); - } else { - tracing::warn!( - "Symlink chain broken during resolution: \ - original={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); + // The up-front root check and this probe are separate + // syscalls; if the process exited in between, the whole + // `/proc/` tree is gone and the leaf probe also + // reports NotFound. Re-check the root so a vanished process + // stays classified as Inaccessible (actionable) rather than + // being masked as an absent candidate (quiet). + if e.kind() == std::io::ErrorKind::NotFound + && let Err(root_error) = + std::fs::metadata(format!("/proc/{entrypoint_pid}/root")) + { + return BinaryResolution::Inaccessible(root_error.kind()); + } + return BinaryResolution::classify_first_probe_error(e.kind()); } - return None; + return BinaryResolution::ChainBroken(e.kind()); } }; if !meta.file_type().is_symlink() { // Reached a non-symlink — this is the final resolved target + reached_target = true; break; } let target = match std::fs::read_link(&container_path) { Ok(t) => t, - Err(e) => { - tracing::warn!( - "Symlink detected but read_link failed: \ - path={policy_path} current={} pid={entrypoint_pid} error={e}. \ - Binary will be matched by original path only.", - resolved.display() - ); - return None; - } + // A symlink whose target can't be read is a broken chain; the + // caller logs it. + Err(e) => return BinaryResolution::ChainBroken(e.kind()), }; if target.is_absolute() { resolved = target; - } else { + } else if let Some(parent) = resolved.parent() { // Relative symlink: resolve against the containing directory // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11 - if let Some(parent) = resolved.parent() { - resolved = normalize_path(&parent.join(&target)); - } else { - break; + resolved = normalize_path(&parent.join(&target)); + } else { + // No parent to resolve a relative target against — terminal. + reached_target = true; + break; + } + } + + if !reached_target { + // The cap was exhausted while following symlinks. Linux SYMLOOP_MAX is + // 40, so a chain of exactly 40 symlinks ending at a real file is still + // valid — after the 40th hop `resolved` may already point at that file. + // Inspect it once more without following another link: a non-symlink is + // the valid final target (fall through to the Literal/Resolved logic + // below), while another symlink (or an error) is a genuine cycle + // (a -> b -> a) or a chain deeper than the kernel allows. read_link + // resolves one hop at a time, so the kernel never surfaces ELOOP; treat + // those as a broken chain so the caller logs it and matches literally. + let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); + + match std::fs::symlink_metadata(&container_path) { + Ok(meta) if !meta.file_type().is_symlink() => {} + Ok(_) => { + return BinaryResolution::ChainBroken( + std::io::Error::from_raw_os_error(libc::ELOOP).kind(), + ); } + Err(e) => return BinaryResolution::ChainBroken(e.kind()), } } let resolved_str = resolved.to_string_lossy().into_owned(); if resolved_str == policy_path { - None + BinaryResolution::Literal } else { - tracing::info!( - "Resolved policy binary symlink via container filesystem: \ - original={policy_path} resolved={resolved_str} pid={entrypoint_pid}" - ); - Some(resolved_str) + // The caller logs the resolution at info level. + BinaryResolution::Resolved(resolved_str) } } #[cfg(not(target_os = "linux"))] -fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> Option { - None +fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> BinaryResolution { + BinaryResolution::Literal } fn l7_matchers_to_json( @@ -1759,6 +2012,12 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if e.request_body_credential_rewrite { ep["request_body_credential_rewrite"] = true.into(); } + if e.allow_uninspected_credentials { + ep["allow_uninspected_credentials"] = true.into(); + } + if e.provider_credentialed { + ep["provider_credentialed"] = true.into(); + } if !e.credential_signing.is_empty() { ep["credential_signing"] = e.credential_signing.clone().into(); } @@ -1768,6 +2027,11 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if !e.signing_region.is_empty() { ep["signing_region"] = e.signing_region.clone().into(); } + if let Some(binding) = &e.credential_binding { + ep["credential_binding"] = serde_json::json!({ + "provider": binding.provider.clone(), + }); + } if !e.persisted_queries.is_empty() { ep["persisted_queries"] = e.persisted_queries.clone().into(); } @@ -1822,8 +2086,46 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St entry }; let mut entries = vec![binary_entry(&b.path)]; - if let Some(resolved) = resolve_binary_in_container(&b.path, entrypoint_pid) { - entries.push(binary_entry(&resolved)); + match resolve_binary_in_container(&b.path, entrypoint_pid) { + BinaryResolution::Resolved(resolved) => { + tracing::info!( + "Resolved policy binary symlink: original={} resolved={resolved} pid={entrypoint_pid}", + b.path + ); + entries.push(binary_entry(&resolved)); + } + BinaryResolution::Absent => { + tracing::debug!( + "Policy binary candidate not present in container: path={} pid={entrypoint_pid}. Matched literally.", + b.path + ); + } + BinaryResolution::Inaccessible(kind) => { + tracing::warn!( + "Cannot access container filesystem for symlink resolution: path={} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary paths in policy will be matched literally. \ + If this binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ + use the canonical path instead, or run with CAP_SYS_PTRACE.", + b.path + ); + } + BinaryResolution::CandidateInaccessible(kind) => { + tracing::warn!( + "Cannot access policy binary candidate path: path={} pid={entrypoint_pid} \ + error_kind={kind:?}. Binary path will be matched literally. \ + A parent component is permission-denied or the path forms a symlink loop; \ + the process root itself is reachable.", + b.path + ); + } + BinaryResolution::ChainBroken(kind) => { + tracing::warn!( + "Symlink chain broken during resolution: path={} pid={entrypoint_pid} error_kind={kind:?}. \ + Matched by original path only.", + b.path + ); + } + BinaryResolution::Literal => {}, } entries }) @@ -1961,6 +2263,78 @@ mod tests { } } + const POLICY_DNS_SNAPSHOT_DATA: &str = r#" +network_policies: + dns_transport: + name: dns_transport + endpoints: + - { host: resolver.example, ports: [53, 853], protocol: tcp } + - { host: web.example, port: 443, protocol: rest, access: full } + - { host: implicit.example, port: 443 } + - { host: "", port: 53, protocol: tcp, allowed_ips: [8.8.8.8] } + - { host: secondary.example, port: 5353, protocol: tcp } + binaries: + - { path: /usr/bin/one-process } +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#; + + #[test] + fn policy_dns_snapshot_is_tcp_only_stable_and_generation_consistent() { + let engine = OpaEngine::from_strings(TEST_POLICY, POLICY_DNS_SNAPSHOT_DATA).unwrap(); + + let snapshot = engine.policy_dns_eligibility_snapshot().unwrap(); + + assert_eq!(snapshot.generation, engine.current_generation()); + assert_eq!(snapshot.endpoints.len(), 2); + assert_eq!(snapshot.endpoints[0].policy_name, "dns_transport"); + assert_eq!(snapshot.endpoints[0].endpoint_index, 0); + assert_eq!( + get_str(&snapshot.endpoints[0].endpoint, "host").as_deref(), + Some("resolver.example") + ); + let Some(regorus::Value::Array(ports)) = + get_field(&snapshot.endpoints[0].endpoint, "ports") + else { + panic!("eligible endpoint must retain concrete ports"); + }; + assert_eq!(ports.as_ref(), &[53.into(), 853.into()]); + assert_eq!(snapshot.endpoints[1].endpoint_index, 4); + + engine + .reload(TEST_POLICY, POLICY_DNS_SNAPSHOT_DATA) + .unwrap(); + let reloaded = engine.policy_dns_eligibility_snapshot().unwrap(); + assert_eq!(reloaded.generation, snapshot.generation + 1); + assert_eq!(reloaded.endpoints.len(), 2); + assert_eq!(reloaded.endpoints[1].endpoint_index, 4); + } + + #[test] + fn policy_dns_snapshot_is_empty_during_fail_closed_quarantine() { + let engine = OpaEngine::from_strings(TEST_POLICY, POLICY_DNS_SNAPSHOT_DATA).unwrap(); + let generation = engine.enter_fail_closed("invalid candidate").unwrap(); + + let snapshot = engine.policy_dns_eligibility_snapshot().unwrap(); + + assert_eq!(snapshot.generation, generation); + assert!(snapshot.endpoints.is_empty()); + } + + #[test] + fn policy_dns_snapshot_accepts_the_default_multi_policy_shape() { + let engine = OpaEngine::from_strings(TEST_POLICY, TEST_DATA_YAML).unwrap(); + let snapshot = engine.policy_dns_eligibility_snapshot().unwrap(); + assert!(snapshot.endpoints.is_empty()); + } + #[test] fn allowed_binary_and_endpoint() { let engine = test_engine(); @@ -2633,6 +3007,7 @@ network_policies: name: l4_only endpoints: - { host: l4only.example.com, port: 443 } + - { host: explicit-tcp.example.com, port: 443, protocol: tcp } binaries: - { path: /usr/bin/curl } filesystem_policy: @@ -2788,7 +3163,7 @@ process: fn eval_l7(engine: &OpaEngine, input: &serde_json::Value) -> bool { let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input.clone()).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -2803,7 +3178,7 @@ process: engine .add_data_json(&data.to_string()) .expect("add raw data json"); - engine.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut engine, input).unwrap(); let val = engine .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -3080,7 +3455,7 @@ process: }]), ); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.request_deny_reason".into()) .unwrap(); @@ -4383,7 +4758,7 @@ network_policies: let engine = l7_engine(); let input = l7_input("api.example.com", 8080, "DELETE", "/repos/myorg/foo"); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.request_deny_reason".into()) .unwrap(); @@ -4416,6 +4791,26 @@ network_policies: assert_eq!(l7.enforcement, crate::l7::EnforcementMode::Enforce); } + #[test] + fn explicit_tcp_authorizes_as_l4_without_endpoint_config() { + let engine = l7_engine(); + let input = NetworkInput { + host: "explicit-tcp.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + assert!(matches!( + engine.evaluate_network_action(&input).unwrap(), + NetworkAction::Allow { .. } + )); + assert!(engine.query_endpoint_config(&input).unwrap().is_none()); + assert!(engine.query_exact_declared_endpoint_host(&input).unwrap()); + } + #[test] fn l7_endpoint_config_preserves_mcp_strict_tool_names_opt_out() { let data = r#" @@ -4777,7 +5172,7 @@ network_policies: // Verify the cloned engine can evaluate let input_json = l7_input("api.example.com", 8080, "GET", "/repos/myorg/foo"); let mut eng = cloned.engine().lock().unwrap(); - eng.set_input_json(&input_json.to_string()).unwrap(); + set_regorus_input(&mut eng, input_json).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -4819,7 +5214,7 @@ network_policies: .clone_engine_for_tunnel(engine.current_generation()) .unwrap(); let mut eng = cloned.engine().lock().unwrap(); - eng.set_input_json(&input_json.to_string()).unwrap(); + set_regorus_input(&mut eng, input_json).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5019,7 +5414,7 @@ process: "/repos/myorg/pulls/123/reviews", ); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5036,7 +5431,7 @@ process: // GET repos/issues is allowed and not denied let input = l7_input("api.github.com", 443, "GET", "/repos/myorg/issues"); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5053,7 +5448,7 @@ process: // POST to issues is allowed (deny only targets reviews) let input = l7_input("api.github.com", 443, "POST", "/repos/myorg/issues"); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5070,7 +5465,7 @@ process: // GET /repos/myorg/rulesets should be denied (method: "*") let input = l7_input("api.github.com", 443, "GET", "/repos/myorg/rulesets"); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5091,7 +5486,7 @@ process: "/repos/myorg/branches/main/protection", ); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5112,7 +5507,7 @@ process: "/repos/myorg/pulls/123/reviews", ); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.request_deny_reason".into()) .unwrap(); @@ -5138,7 +5533,7 @@ process: serde_json::json!({"force": ["true"]}), ); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5161,7 +5556,7 @@ process: serde_json::json!({"force": ["false"]}), ); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5186,7 +5581,7 @@ process: serde_json::json!({"force": ["true", "false"]}), ); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5204,7 +5599,7 @@ process: // so no match (key not present) and request should be allowed let input = l7_input("api.restricted.com", 443, "POST", "/admin/settings"); let mut eng = engine.engine.lock().unwrap(); - eng.set_input_json(&input.to_string()).unwrap(); + set_regorus_input(&mut eng, input).unwrap(); let val = eng .eval_rule("data.openshell.sandbox.allow_request".into()) .unwrap(); @@ -5301,6 +5696,18 @@ process: } ); + let authorization = engine.authorize_egress(&input).unwrap(); + let mut endpoint_identities = authorization + .matched_endpoints + .iter() + .map(|matched| (matched.policy_name.as_str(), matched.endpoint_index)) + .collect::>(); + endpoint_identities.sort_unstable(); + assert_eq!( + endpoint_identities, + [("allow_192_168_1_100_8567", 0), ("test_server", 0),] + ); + let (configs, generation) = engine .query_endpoint_configs_with_generation(&input) .unwrap(); @@ -5642,6 +6049,87 @@ process: .expect("Failed to load allowed_ips test data") } + /// An L4-only credentialed endpoint must not join the shared endpoint-config + /// list: `query_endpoint_config` returns only its first element, so joining + /// it would let the L4 endpoint shadow the inspected endpoint's TLS mode and + /// SSRF allowlist on the same host:port. + #[test] + fn credential_guard_does_not_shadow_inspected_endpoint_config() { + const OVERLAPPING_DATA: &str = r#" +network_policies: + telemetry_l4: + name: telemetry_l4 + endpoints: + - host: api.example.com + port: 443 + provider_credentialed: true + allow_uninspected_credentials: true + binaries: + - { path: /usr/bin/curl } + inspected_api: + name: inspected_api + endpoints: + - host: api.example.com + port: 443 + protocol: rest + access: full + tls: skip + allowed_ips: ["10.0.5.0/24"] + binaries: + - { path: /usr/bin/curl } +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, OVERLAPPING_DATA) + .expect("overlapping endpoint policy should load"); + let input = NetworkInput { + host: "api.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let configs = engine + .query_endpoint_configs_with_generation(&input) + .unwrap() + .0; + assert_eq!( + configs.len(), + 1, + "only the inspected endpoint carries extended config" + ); + let config = crate::l7::parse_l7_config(&configs[0]).expect("inspected endpoint config"); + assert_eq!(config.protocol, crate::l7::L7Protocol::Rest); + assert_eq!(config.tls, crate::l7::TlsMode::Skip); + assert_eq!( + engine.query_allowed_ips(&input).unwrap(), + vec!["10.0.5.0/24"], + "SSRF allowlist must still come from the inspected endpoint" + ); + + let guards = engine.query_endpoint_credential_guards(&input).unwrap(); + assert_eq!( + guards.len(), + 2, + "credential gating must still see the L4-only endpoint" + ); + assert!( + guards + .iter() + .map(crate::l7::parse_endpoint_credential_guard) + .any(|guard| guard.provider_credentialed) + ); + } + #[test] fn allowed_ips_mode2_host_plus_ips_allows() { let engine = allowed_ips_engine(); @@ -5662,6 +6150,121 @@ process: assert_eq!(decision.matched_policy.as_deref(), Some("internal_api")); } + #[test] + fn egress_authorization_returns_one_generation_consistent_snapshot() { + let engine = allowed_ips_engine(); + let input = NetworkInput { + host: "my-service.corp.net".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let authorization = engine.authorize_egress(&input).unwrap(); + + assert_eq!(authorization.generation, engine.current_generation()); + assert_eq!( + authorization.action, + NetworkAction::Allow { + matched_policy: Some("internal_api".to_string()) + } + ); + assert!(authorization.exact_declared_endpoint_host); + assert_eq!(authorization.endpoint_configs.len(), 1); + assert_eq!( + get_str_array(&authorization.endpoint_configs[0], "allowed_ips"), + vec!["10.0.5.0/24"] + ); + assert_eq!(authorization.matched_endpoints.len(), 1); + assert_eq!( + authorization.matched_endpoints[0].policy_name, + "internal_api" + ); + assert_eq!(authorization.matched_endpoints[0].endpoint_index, 0); + assert_eq!( + get_str(&authorization.matched_endpoints[0].endpoint, "host").as_deref(), + Some("my-service.corp.net") + ); + } + + #[test] + fn egress_authorization_preserves_explicit_tcp_endpoint_identity() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + r#" +network_policies: + native_tcp: + name: native_tcp + endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + binaries: + - path: /usr/bin/client +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#, + ) + .expect("explicit TCP policy should load"); + let input = NetworkInput { + host: "database.example.com".into(), + port: 5432, + binary_path: PathBuf::from("/usr/bin/client"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let authorization = engine.authorize_egress(&input).unwrap(); + + assert!(authorization.endpoint_configs.is_empty()); + assert_eq!(authorization.matched_endpoints.len(), 1); + let matched = &authorization.matched_endpoints[0]; + assert_eq!(matched.policy_name, "native_tcp"); + assert_eq!(matched.endpoint_index, 0); + assert_eq!( + get_str(&matched.endpoint, "protocol").as_deref(), + Some("tcp") + ); + } + + #[test] + fn proto_activation_rejects_explicit_tcp_credential_binding() { + let proto = openshell_policy::parse_sandbox_policy( + r#" +version: 1 +network_policies: + native_tcp: + name: native_tcp + endpoints: + - host: database.example.com + port: 5432 + protocol: tcp + credential_binding: + provider: database + binaries: + - path: /usr/bin/client +"#, + ) + .expect("policy should parse before semantic validation"); + + let error = OpaEngine::from_proto(&proto) + .err() + .expect("explicit TCP must reject credential binding during activation"); + + assert!(error.to_string().contains("credential_binding")); + assert!(error.to_string().contains("protocol tcp")); + } + #[test] fn allowed_ips_mode2_returns_allowed_ips() { let engine = allowed_ips_engine(); @@ -6695,22 +7298,36 @@ process: #[test] fn resolve_binary_skips_glob_paths() { // Glob patterns should never be resolved — they're matched differently - assert!(resolve_binary_in_container("/usr/bin/*", 1).is_none()); - assert!(resolve_binary_in_container("/usr/local/bin/**", 1).is_none()); + assert_eq!( + resolve_binary_in_container("/usr/bin/*", 1), + BinaryResolution::Literal + ); + assert_eq!( + resolve_binary_in_container("/usr/local/bin/**", 1), + BinaryResolution::Literal + ); } #[test] fn resolve_binary_skips_pid_zero() { // pid=0 means the container hasn't started yet - assert!(resolve_binary_in_container("/usr/bin/python3", 0).is_none()); + assert_eq!( + resolve_binary_in_container("/usr/bin/python3", 0), + BinaryResolution::Literal + ); } #[test] - fn resolve_binary_returns_none_for_nonexistent_path() { - // A path that doesn't exist in any container should gracefully return None + fn resolve_binary_does_not_resolve_nonexistent_path() { + // A path that doesn't exist should never yield a Resolved target. The + // exact non-Resolved variant depends on platform/privilege (Absent when + // the process root is readable, Inaccessible when it is not, Literal on + // the non-Linux stub), so assert only that nothing is resolved. + let result = + resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1); assert!( - resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1) - .is_none() + !matches!(result, BinaryResolution::Resolved(_)), + "nonexistent path must not resolve, got: {result:?}" ); } @@ -6918,6 +7535,243 @@ network_policies: ); } + #[test] + fn classify_not_found_is_absent() { + // A missing candidate under an accessible root is expected, not an error. + assert_eq!( + BinaryResolution::classify_first_probe_error(std::io::ErrorKind::NotFound), + BinaryResolution::Absent + ); + } + + #[test] + fn classify_permission_denied_is_candidate_inaccessible() { + // A non-NotFound failure at the candidate probe (root already confirmed + // reachable) is a target-path problem, not a process-root one, so it + // must not emit the CAP_SYS_PTRACE process-root guidance. + assert_eq!( + BinaryResolution::classify_first_probe_error(std::io::ErrorKind::PermissionDenied), + BinaryResolution::CandidateInaccessible(std::io::ErrorKind::PermissionDenied) + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn absent_candidate_resolves_to_absent() { + // Regression for #2883: a candidate path that does not exist under an + // accessible /proc//root must classify as Absent (quiet), not as a + // container-filesystem-access failure. + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // Use a path guaranteed absent: a real temp dir with an uncreated + // child. A hard-coded path like /app/.venv/bin/python could exist in a + // valid environment and make the resolver return Resolved/Literal. + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-python"); + let pid = std::process::id(); // our own live, accessible root + assert_eq!( + resolve_binary_in_container(missing.to_str().unwrap(), pid), + BinaryResolution::Absent + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn unreachable_process_root_is_inaccessible() { + // A pid whose /proc//root does not exist (process gone) must be + // reported as an access failure, not silently treated as an absent + // candidate — even though both surface as ENOENT at the leaf path. + // u32::MAX is far above /proc/sys/kernel/pid_max, so it never exists. + let dead_pid = u32::MAX; + assert!( + matches!( + resolve_binary_in_container("/usr/bin/python3", dead_pid), + BinaryResolution::Inaccessible(_) + ), + "an unreachable process root must classify as Inaccessible" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_cycle_exhausts_limit_as_chain_broken() { + // A symlink cycle (a -> b -> a) never resolves to a real target. The + // manual read_link walk resolves one hop at a time, so the kernel never + // raises ELOOP; exhausting the iteration cap must classify as + // ChainBroken, not silently pass as Resolved/Literal. + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a"); + let b = dir.path().join("b"); + symlink(&b, &a).unwrap(); // a -> b + symlink(&a, &b).unwrap(); // b -> a + let pid = std::process::id(); + assert!( + matches!( + resolve_binary_in_container(a.to_str().unwrap(), pid), + BinaryResolution::ChainBroken(_) + ), + "a symlink cycle must classify as ChainBroken" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_chain_at_limit_resolves_to_target() { + // Linux SYMLOOP_MAX is 40: a chain of exactly 40 symlinks ending at a + // real file resolves successfully in the kernel. The manual walk must + // follow all 40 hops and accept the final target rather than exhausting + // the cap and reporting ChainBroken (off-by-one regression). + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + std::fs::write(&target, b"").unwrap(); + + // link0 -> link1 -> ... -> link39 -> real (40 symlinks, absolute + // targets so the resolver takes the is_absolute() branch). + let link = |i: usize| dir.path().join(format!("link{i}")); + symlink(&target, link(39)).unwrap(); + for i in (0..39).rev() { + symlink(link(i + 1), link(i)).unwrap(); + } + + let pid = std::process::id(); + let result = resolve_binary_in_container(link(0).to_str().unwrap(), pid); + assert!( + matches!(result, BinaryResolution::Resolved(_)), + "a 40-link chain ending at a real file must resolve, got {result:?}" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn symlink_chain_over_limit_is_chain_broken() { + // A chain of 41 symlinks exceeds SYMLOOP_MAX: the walk must give up and + // classify as ChainBroken, proving the 40-hop budget stays enforced. + use std::os::unix::fs::symlink; + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + std::fs::write(&target, b"").unwrap(); + + // link0 -> ... -> link40 -> real (41 symlinks). + let link = |i: usize| dir.path().join(format!("link{i}")); + symlink(&target, link(40)).unwrap(); + for i in (0..40).rev() { + symlink(link(i + 1), link(i)).unwrap(); + } + + let pid = std::process::id(); + let result = resolve_binary_in_container(link(0).to_str().unwrap(), pid); + assert!( + matches!(result, BinaryResolution::ChainBroken(_)), + "a 41-link chain must classify as ChainBroken, got {result:?}" + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn absent_candidates_emit_no_warnings() { + // Regression for #2883: several expected-but-absent compatibility + // candidates under an accessible process root must not produce a burst + // of WARN-level noise. Logging now lives at the caller, so drive the + // real caller (proto_to_opa_data_json) and count WARN events. + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tracing::Subscriber; + use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; + use tracing_subscriber::registry::LookupSpan; + + #[derive(Clone)] + struct WarnCounter(Arc); + impl LookupSpan<'a>> Layer for WarnCounter { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + if *event.metadata().level() == tracing::Level::WARN { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + } + + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + let warns = Arc::new(AtomicUsize::new(0)); + let subscriber = tracing_subscriber::registry().with(WarnCounter(Arc::clone(&warns))); + + // Candidates that do not exist under our accessible /proc//root. + // Historically each absent candidate emitted its own WARN. Use uncreated + // children of a real temp dir so the paths are guaranteed absent (not + // merely conventional): a hard-coded path could exist on a runner and + // stop exercising the Absent branch, or be inaccessible and emit a + // legitimate warning that fails this test. + let dir = tempfile::tempdir().unwrap(); + let candidates = [ + dir.path().join("app-python"), + dir.path().join("sandbox-python"), + dir.path().join("opt-python"), + ]; + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "pypi".to_string(), + NetworkPolicyRule { + name: "pypi".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: candidates + .iter() + .map(|p| NetworkBinary { + path: p.to_str().unwrap().to_string(), + ..Default::default() + }) + .collect(), + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: None, + landlock: None, + process: None, + network_policies, + network_middlewares: std::collections::HashMap::default(), + }; + + let pid = std::process::id(); // accessible root, leaf paths absent + tracing::subscriber::with_default(subscriber, || { + let _ = proto_to_opa_data_json(&proto, pid); + }); + + assert_eq!( + warns.load(Ordering::SeqCst), + 0, + "absent compatibility candidates must not emit warnings" + ); + } + #[test] fn hot_reload_preserves_symlink_expansion_behavior() { // Simulates the hot-reload path: initial load at pid=0, then reload @@ -7068,6 +7922,144 @@ network_policies: assert!(described[0].is_resolved()); } + #[tokio::test] + async fn middleware_session_budget_survives_atomic_registry_reload() { + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); + let registry = MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("built-in registry"); + engine + .replace_middleware_registry(registry) + .expect("install registry"); + let old_runner = engine.middleware_runner().expect("old runner"); + let entry = ChainEntry { + name: "regex".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_REGEX.into(), + order: 0, + config: prost_types::Struct { + fields: std::iter::once(( + "mode".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("redact".into())), + }, + )) + .collect(), + }, + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }; + let preflight_input = + |session_id: String| openshell_supervisor_middleware::WebSocketPreflightInput { + session_id, + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }; + let mut old_sessions = Vec::new(); + for index in 0..openshell_supervisor_middleware::MAX_CONCURRENT_MIDDLEWARE_SESSIONS { + let preflight = old_runner + .preflight_websocket( + std::slice::from_ref(&entry), + preflight_input(format!("old-generation-{index}")), + ) + .await + .expect("admit old-generation session"); + old_sessions.push(preflight.session.expect("built-in inspects session")); + } + + let replacement = MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("replacement registry"); + engine + .reload_policy_and_middleware_from_proto_with_pid(&proto, 0, replacement) + .expect("atomic policy and registry reload"); + let current_runner = engine.middleware_runner().expect("current runner"); + let overflow = current_runner + .preflight_websocket( + std::slice::from_ref(&entry), + preflight_input("new-generation-overflow".into()), + ) + .await + .expect("capacity exhaustion is a typed preflight outcome"); + assert!(!overflow.allowed); + assert!(overflow.session_capacity_exhausted); + + old_sessions + .pop() + .expect("old-generation session") + .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .await; + let admitted = current_runner + .preflight_websocket( + std::slice::from_ref(&entry), + preflight_input("new-generation-admitted".into()), + ) + .await + .expect("released capacity is reusable after reload"); + assert!(admitted.allowed); + assert!(admitted.session.is_some()); + } + + #[tokio::test] + async fn websocket_assembly_budget_survives_policy_reload() { + use crate::l7::websocket::{ + MAX_CONCURRENT_WEBSOCKET_ASSEMBLIES, WebSocketAssemblyAdmissionOutcome, + }; + + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("initial policy"); + let old_tunnel = engine + .clone_engine_for_tunnel(engine.current_generation()) + .expect("old tunnel"); + let old_budget = old_tunnel.websocket_assembly_budget(); + let mut old_admissions = Vec::new(); + for _ in 0..MAX_CONCURRENT_WEBSOCKET_ASSEMBLIES { + match old_budget.reserve().await.expect("reserve old assembly") { + WebSocketAssemblyAdmissionOutcome::Admitted(admission) => { + old_admissions.push(admission); + } + WebSocketAssemblyAdmissionOutcome::QueueExhausted => { + panic!("active assembly capacity exhausted too early") + } + } + } + + engine + .reload_from_proto_with_pid(&proto, 0) + .expect("policy reload"); + let new_tunnel = engine + .clone_engine_for_tunnel(engine.current_generation()) + .expect("new tunnel"); + let new_budget = new_tunnel.websocket_assembly_budget(); + let waiting = tokio::spawn(async move { new_budget.reserve().await }); + tokio::task::yield_now().await; + assert!( + !waiting.is_finished(), + "new generation must share old generation assembly capacity" + ); + + drop(old_admissions.pop()); + assert!(matches!( + waiting + .await + .expect("join waiting assembly") + .expect("waiting assembly result"), + WebSocketAssemblyAdmissionOutcome::Admitted(_) + )); + } + #[tokio::test] async fn policy_only_reload_keeps_connected_middleware_registry() { let proto = test_proto(); @@ -7254,7 +8246,10 @@ network_policies: let pid = std::process::id(); let link_path = link.to_string_lossy().to_string(); // Actually attempt the same resolution our production code uses - resolve_binary_in_container(&link_path, pid).is_some() + matches!( + resolve_binary_in_container(&link_path, pid), + BinaryResolution::Resolved(_) + ) } #[cfg(target_os = "linux")] @@ -7283,11 +8278,9 @@ network_policies: let link_path = link.to_string_lossy().to_string(); let result = resolve_binary_in_container(&link_path, our_pid); - assert!( - result.is_some(), - "Should resolve symlink via /proc//root/" - ); - let resolved = result.unwrap(); + let BinaryResolution::Resolved(resolved) = result else { + panic!("Should resolve symlink via /proc//root/, got: {result:?}"); + }; assert!( resolved.ends_with("python3.11"), "Resolved path should point to target: {resolved}" @@ -7313,9 +8306,10 @@ network_policies: let path = tmp.path().to_string_lossy().to_string(); let result = resolve_binary_in_container(&path, our_pid); - assert!( - result.is_none(), - "Non-symlink file should return None, got: {result:?}" + assert_eq!( + result, + BinaryResolution::Literal, + "Non-symlink file should not expand, got: {result:?}" ); } @@ -7343,8 +8337,9 @@ network_policies: let link_path = top_link.to_string_lossy().to_string(); let result = resolve_binary_in_container(&link_path, our_pid); - assert!(result.is_some(), "Should resolve multi-level symlink chain"); - let resolved = result.unwrap(); + let BinaryResolution::Resolved(resolved) = result else { + panic!("Should resolve multi-level symlink chain, got: {result:?}"); + }; assert!( resolved.ends_with("cpython3.11"), "Should resolve to final target: {resolved}" @@ -7721,11 +8716,11 @@ host_match if { ]; for (pattern, host) in cases { let rust = openshell_core::host_pattern::host_matches(pattern, host).unwrap(); - engine - .set_input_json( - &serde_json::json!({ "pattern": pattern, "host": host }).to_string(), - ) - .unwrap(); + set_regorus_input( + &mut engine, + serde_json::json!({ "pattern": pattern, "host": host }), + ) + .unwrap(); let rego = engine.eval_rule("data.test.host_match".into()).unwrap() == regorus::Value::from(true); assert_eq!( diff --git a/crates/openshell-supervisor-network/src/policy_dns/mod.rs b/crates/openshell-supervisor-network/src/policy_dns/mod.rs new file mode 100644 index 0000000000..b7dd13ca9a --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/mod.rs @@ -0,0 +1,989 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![allow( + clippy::redundant_pub_crate, + reason = "the crate-private API is consumed by the runtime activation slice" +)] + +//! Policy-gated DNS and synthetic resolved-endpoint correlation. +//! +//! The shared supervisor owns DNS eligibility and mapping state. Supported +//! runtimes provide namespace-local DNS and transparent TCP capture sockets. + +#![allow( + dead_code, + unused_imports, + reason = "the policy DNS boundary retains metrics and helpers for later runtime integrations" +)] + +mod name; +mod resolver; +mod runtime; +mod store; +mod wire; + +pub(crate) use name::NormalizedName; +pub(crate) use resolver::{AddressFamily, SocketTrustedResolver, TrustedAnswer, TrustedResolver}; +pub(crate) use runtime::{PolicyDnsRuntime, PolicyDnsRuntimeConfig}; +pub(crate) use store::{ + MappingLookup, MappingLookupError, PolicyEndpointId, PublishError, PublishRequest, + ResolvedEndpointRecord, ResolvedEndpointStore, ResolvedPortContract, StoreConfig, + SyntheticPools, +}; + +use crate::opa::OpaEngine; +use crate::proxy::destination::{build_validation_plan, filter_resolved_addresses}; +use crate::proxy::is_host_gateway_alias; +use openshell_core::host_pattern::HostSelector; +use openshell_ocsf::{ + ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, + NetworkActivityBuilder, SeverityId, StateId, StatusId, ocsf_emit, +}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +pub(crate) const MIN_MAPPING_TTL: Duration = Duration::from_secs(1); +pub(crate) const MAX_MAPPING_TTL: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SyntheticAnswer { + pub(crate) address: std::net::IpAddr, + pub(crate) ttl: Duration, + pub(crate) mapping_id: uuid::Uuid, + pub(crate) mapping_generation: u64, + pub(crate) policy_generation: u64, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PolicyDnsError { + #[error("DNS query name is invalid")] + InvalidName, + #[error("DNS name is not eligible for policy DNS")] + Ineligible, + #[error("trusted host gateway is unavailable for the reserved alias")] + TrustedGatewayUnavailable, + #[error("trusted resolver failed: {0}")] + Resolver(#[from] resolver::ResolveError), + #[error("no trusted resolver address passed endpoint destination policy")] + NoValidAddress, + #[error("policy generation changed before DNS mapping publication")] + StalePolicy, + #[error("resolved endpoint mapping could not be published: {0}")] + Publish(#[from] PublishError), + #[error("policy DNS eligibility snapshot failed: {0}")] + Policy(String), +} + +/// Policy-gated DNS evaluator and synthetic mapping publisher. +/// +/// No socket is bound by this type. A later runtime adapter owns listener and +/// namespace lifecycle and calls the bounded wire helpers in this module. +pub(crate) struct PolicyDnsService { + policy: Arc, + resolver: R, + store: Arc, + trusted_host_gateway: Option, +} + +impl PolicyDnsService { + pub(crate) fn new( + policy: Arc, + resolver: R, + store: Arc, + trusted_host_gateway: Option, + ) -> Self { + Self { + policy, + resolver, + store, + trusted_host_gateway, + } + } + + pub(crate) async fn answer_query( + &self, + raw_name: &str, + family: AddressFamily, + now: Instant, + ) -> Result { + let normalized_name = + NormalizedName::parse(raw_name).map_err(|_| PolicyDnsError::InvalidName)?; + if is_host_gateway_alias(normalized_name.as_str()) && self.trusted_host_gateway.is_none() { + emit_dns_denial( + &normalized_name, + "policy_dns_trusted_gateway_unavailable", + "Policy DNS refused a reserved host-gateway alias because no trusted gateway is configured", + ); + return Err(PolicyDnsError::TrustedGatewayUnavailable); + } + let snapshot = self + .policy + .policy_dns_eligibility_snapshot() + .map_err(|error| PolicyDnsError::Policy(error.to_string()))?; + let eligible = eligible_endpoints( + &snapshot.endpoints, + &normalized_name, + self.trusted_host_gateway, + )?; + if eligible.is_empty() { + emit_dns_denial( + &normalized_name, + "policy_dns_ineligible", + "Policy DNS refused a name that is not eligible in the active policy", + ); + return Err(PolicyDnsError::Ineligible); + } + + // The trusted resolver is invoked only after the immutable snapshot + // proved policy eligibility. It never consults sandbox resolver state. + let endpoint_context = eligible_endpoint_context(&eligible); + let trusted_answer = match self.resolver.resolve(&normalized_name, family).await { + Ok(answer) => answer, + Err(error) => { + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + resolver_failure_detail(&error), + "Policy DNS trusted resolver query failed", + ); + return Err(PolicyDnsError::Resolver(error)); + } + }; + let ttl = clamp_mapping_ttl(trusted_answer.ttl); + let allocation_identity = allocation_identity(&eligible); + let mut contracts = Vec::new(); + for endpoint in eligible { + for port in endpoint.ports { + let Ok(pinned_addresses) = filter_resolved_addresses( + &endpoint.destination_plan, + normalized_name.as_str(), + port, + &trusted_answer.addresses, + ) else { + continue; + }; + contracts.push(ResolvedPortContract { + endpoint_id: endpoint.endpoint_id.clone(), + port, + destination_plan: endpoint.destination_plan.clone(), + pinned_addresses, + }); + } + } + contracts.sort_by(|left, right| { + (&left.endpoint_id, left.port).cmp(&(&right.endpoint_id, right.port)) + }); + if contracts.is_empty() { + emit_dns_denial( + &normalized_name, + "policy_dns_no_valid_address", + "Policy DNS rejected every trusted resolver address", + ); + return Err(PolicyDnsError::NoValidAddress); + } + + let request = PublishRequest { + normalized_name: normalized_name.clone(), + family, + allocation_identity, + policy_generation: snapshot.generation, + ttl, + contracts, + }; + let record = match self + .policy + .with_current_generation(snapshot.generation, |current_generation| { + self.store.publish(request, current_generation, now) + }) { + Ok(Some(Ok(record))) => record, + Ok(Some(Err(error))) => { + // InvalidMapping is unreachable for the well-formed request + // assembled above, and LockPoisoned requires a prior panic + // while holding the store lock. Keep both defensive outcomes + // observable because the store API intentionally rejects them. + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + publication_failure_detail(error), + "Policy DNS resolved-endpoint mapping publication failed", + ); + return Err(PolicyDnsError::Publish(error)); + } + Ok(None) => { + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + "policy_dns_publication_stale_generation", + "Policy DNS discarded a stale resolved-endpoint mapping", + ); + return Err(PolicyDnsError::StalePolicy); + } + Err(error) => { + emit_dns_failure( + &normalized_name, + family, + &endpoint_context, + snapshot.generation, + "policy_dns_publication_generation_check_failed", + "Policy DNS could not validate the active policy generation before publication", + ); + return Err(PolicyDnsError::Policy(error.to_string())); + } + }; + emit_mapping_publication(&record); + Ok(SyntheticAnswer { + address: record.synthetic_address, + ttl, + mapping_id: record.mapping_id, + mapping_generation: record.mapping_generation, + policy_generation: record.policy_generation, + }) + } + + pub(crate) fn store(&self) -> &Arc { + &self.store + } +} + +struct EligibleEndpoint { + endpoint_id: PolicyEndpointId, + ports: Vec, + destination_plan: crate::proxy::destination::DestinationValidationPlan, + contract_fingerprint: String, +} + +fn eligible_endpoints( + endpoints: &[crate::opa::MatchedEndpoint], + name: &NormalizedName, + trusted_host_gateway: Option, +) -> Result, PolicyDnsError> { + let mut eligible = Vec::new(); + for endpoint in endpoints { + let Some(pattern) = value_string(&endpoint.endpoint, "host") else { + continue; + }; + let pattern = pattern.trim_end_matches('.').to_ascii_lowercase(); + let selector = HostSelector::new(std::slice::from_ref(&pattern), &[]) + .map_err(PolicyDnsError::Policy)?; + if !selector.matches(name.as_str()) { + continue; + } + let ports = value_ports(&endpoint.endpoint); + if ports.is_empty() { + continue; + } + let raw_allowed_ips = value_string_array(&endpoint.endpoint, "allowed_ips"); + let exact_declared_host = !pattern.contains('*') && pattern == name.as_str(); + let destination_plan = build_validation_plan( + name.as_str(), + name.as_str(), + trusted_host_gateway, + &raw_allowed_ips, + exact_declared_host, + ) + .map_err(|error| PolicyDnsError::Policy(error.reason))?; + eligible.push(EligibleEndpoint { + endpoint_id: PolicyEndpointId { + policy_name: endpoint.policy_name.clone(), + endpoint_index: endpoint.endpoint_index, + }, + ports, + destination_plan, + contract_fingerprint: endpoint.endpoint.to_string(), + }); + } + Ok(eligible) +} + +fn allocation_identity(endpoints: &[EligibleEndpoint]) -> [u8; 32] { + let mut contracts = endpoints + .iter() + .map(|endpoint| { + format!( + "{}\0{}\0{}", + endpoint.endpoint_id.policy_name, + endpoint.endpoint_id.endpoint_index, + endpoint.contract_fingerprint + ) + }) + .collect::>(); + contracts.sort(); + let mut hasher = Sha256::new(); + for contract in contracts { + hasher.update(contract.as_bytes()); + hasher.update([0xff]); + } + hasher.finalize().into() +} + +fn eligible_endpoint_context(endpoints: &[EligibleEndpoint]) -> Vec { + let mut endpoint_ids = endpoints + .iter() + .map(|endpoint| endpoint.endpoint_id.clone()) + .collect::>(); + endpoint_ids.sort(); + endpoint_ids.dedup(); + endpoint_ids +} + +fn value_field<'a>(value: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { + let regorus::Value::Object(fields) = value else { + return None; + }; + fields.get(®orus::Value::String(key.into())) +} + +fn value_string(value: ®orus::Value, key: &str) -> Option { + match value_field(value, key) { + Some(regorus::Value::String(value)) => Some(value.to_string()), + _ => None, + } +} + +fn value_string_array(value: ®orus::Value, key: &str) -> Vec { + match value_field(value, key) { + Some(regorus::Value::Array(values)) => values + .iter() + .filter_map(|value| match value { + regorus::Value::String(value) => Some(value.to_string()), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} + +fn value_ports(value: ®orus::Value) -> Vec { + let mut ports = match value_field(value, "ports") { + Some(regorus::Value::Array(values)) => values + .iter() + .filter_map(|value| match value { + regorus::Value::Number(number) => number + .as_i64() + .and_then(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0), + _ => None, + }) + .collect::>(), + _ => Vec::new(), + }; + ports.sort_unstable(); + ports.dedup(); + ports +} + +fn clamp_mapping_ttl(ttl: Duration) -> Duration { + ttl.max(MIN_MAPPING_TTL).min(MAX_MAPPING_TTL) +} + +fn emit_dns_denial(name: &NormalizedName, detail: &str, message: &str) { + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Refuse) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(name.as_str(), 53)) + .status_detail(detail) + .message(message) + .build() + ); +} + +fn resolver_failure_detail(error: &resolver::ResolveError) -> &'static str { + match error { + resolver::ResolveError::Timeout => "policy_dns_upstream_timeout", + resolver::ResolveError::Io(_) => "policy_dns_upstream_io_failed", + resolver::ResolveError::Oversized => "policy_dns_upstream_oversized_response", + resolver::ResolveError::Malformed => "policy_dns_upstream_malformed_response", + resolver::ResolveError::NxDomain => "policy_dns_upstream_nxdomain", + resolver::ResolveError::Response(_) => "policy_dns_upstream_error_response", + resolver::ResolveError::NoData => "policy_dns_upstream_no_data", + resolver::ResolveError::CnameLimit => "policy_dns_upstream_cname_limit", + } +} + +fn publication_failure_detail(error: PublishError) -> &'static str { + match error { + PublishError::StalePolicy => "policy_dns_publication_stale_generation", + PublishError::InvalidMapping => "policy_dns_publication_invalid_mapping", + PublishError::PoolExhausted => "policy_dns_publication_pool_exhausted", + PublishError::LockPoisoned => "policy_dns_publication_store_unavailable", + } +} + +fn build_dns_failure_event( + name: &NormalizedName, + family: AddressFamily, + eligible_endpoints: &[PolicyEndpointId], + policy_generation: u64, + detail: &str, + message: &str, +) -> openshell_ocsf::OcsfEvent { + let endpoint_context = eligible_endpoints + .iter() + .map(|endpoint| { + serde_json::json!({ + "policy_name": endpoint.policy_name.as_str(), + "endpoint_index": endpoint.endpoint_index, + }) + }) + .collect::>(); + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Refuse) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(name.as_str(), 53)) + .status_detail(detail) + .unmapped("normalized_name", name.as_str()) + .unmapped("address_family", family.as_str()) + .unmapped( + "eligible_endpoints", + serde_json::Value::Array(endpoint_context), + ) + .unmapped("policy_generation", policy_generation) + .message(message) + .build() +} + +fn emit_dns_failure( + name: &NormalizedName, + family: AddressFamily, + eligible_endpoints: &[PolicyEndpointId], + policy_generation: u64, + detail: &str, + message: &str, +) { + ocsf_emit!(build_dns_failure_event( + name, + family, + eligible_endpoints, + policy_generation, + detail, + message, + )); +} + +fn emit_mapping_publication(record: &ResolvedEndpointRecord) { + ocsf_emit!(build_mapping_publication_event(record)); +} + +fn build_mapping_publication_event(record: &ResolvedEndpointRecord) -> openshell_ocsf::OcsfEvent { + let approved_real_ip_candidates = record + .contracts + .iter() + .flat_map(|contract| contract.pinned_addresses.iter().copied()) + .collect::>() + .into_iter() + .map(|address| address.to_string()) + .collect::>(); + let allowed_ports = record.allowed_ports().into_iter().collect::>(); + let mapping_id = record.mapping_id.to_string(); + let message = format!( + "Policy DNS mapped {} resolved={} synthetic={} ports={} mapping_id={mapping_id}", + record.normalized_name, + approved_real_ip_candidates.join(","), + record.synthetic_address, + allowed_ports + .iter() + .map(u16::to_string) + .collect::>() + .join(","), + ); + + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "published") + .unmapped("normalized_domain", record.normalized_name.as_str()) + .unmapped("address_family", format!("{:?}", record.family)) + .unmapped( + "approved_real_ip_candidates", + serde_json::json!(approved_real_ip_candidates), + ) + .unmapped("synthetic_ip", record.synthetic_address.to_string()) + .unmapped("allowed_ports", serde_json::json!(allowed_ports)) + .unmapped("policy_generation", record.policy_generation) + .unmapped("mapping_generation", record.mapping_generation) + .unmapped("mapping_id", mapping_id) + .message(message) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::sync::Notify; + + struct FakeResolver { + calls: AtomicUsize, + answer: TrustedAnswer, + } + + struct NxDomainResolver; + + impl TrustedResolver for NxDomainResolver { + async fn resolve( + &self, + _name: &NormalizedName, + _family: AddressFamily, + ) -> Result { + Err(resolver::ResolveError::NxDomain) + } + } + + impl TrustedResolver for FakeResolver { + async fn resolve( + &self, + _name: &NormalizedName, + _family: AddressFamily, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.answer.clone()) + } + } + + fn service(policy_yaml: &str, addresses: Vec) -> PolicyDnsService { + service_with_gateway(policy_yaml, addresses, None) + } + + fn service_with_gateway( + policy_yaml: &str, + addresses: Vec, + trusted_host_gateway: Option, + ) -> PolicyDnsService { + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), policy_yaml) + .unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 8), + "fd00:1::1".parse::().unwrap()..="fd00:1::8".parse::().unwrap(), + ) + .unwrap(); + PolicyDnsService::new( + policy, + FakeResolver { + calls: AtomicUsize::new(0), + answer: TrustedAnswer { + addresses, + ttl: Duration::from_secs(300), + }, + }, + Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 16).unwrap(), + )), + trusted_host_gateway, + ) + } + + const BASE_POLICY: &str = r" +network_policies: + database: + name: database + endpoints: + - { host: db.example, port: 5432, protocol: tcp } + binaries: [{ path: /usr/bin/psql }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +"; + + #[tokio::test] + async fn refuses_ineligible_name_before_upstream_resolution() { + let service = service(BASE_POLICY, vec!["8.8.8.8".parse().unwrap()]); + let result = service + .answer_query("other.example", AddressFamily::Ipv4, Instant::now()) + .await; + assert!(matches!(result, Err(PolicyDnsError::Ineligible))); + assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn eligible_nxdomain_fails_without_publishing_a_mapping() { + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) + .unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 1), + "fd00:1::1".parse::().unwrap()..="fd00:1::1".parse::().unwrap(), + ) + .unwrap(); + let store = Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 1).unwrap(), + )); + let service = PolicyDnsService::new(policy, NxDomainResolver, store.clone(), None); + + let result = service + .answer_query("DB.EXAMPLE.", AddressFamily::Ipv4, Instant::now()) + .await; + + assert!(matches!( + result, + Err(PolicyDnsError::Resolver(resolver::ResolveError::NxDomain)) + )); + assert!(matches!( + store.lookup( + "198.18.0.1".parse().unwrap(), + 5432, + service.policy.current_generation(), + Instant::now() + ), + Err(MappingLookupError::Missing) + )); + } + + #[tokio::test] + async fn eligible_name_filters_answers_and_publishes_bounded_mapping() { + let service = service( + BASE_POLICY, + vec!["127.0.0.1".parse().unwrap(), "10.2.3.4".parse().unwrap()], + ); + let now = Instant::now(); + let answer = service + .answer_query("DB.EXAMPLE.", AddressFamily::Ipv4, now) + .await + .unwrap(); + assert_eq!(answer.ttl, MAX_MAPPING_TTL); + let mapping = service + .store + .lookup(answer.address, 5432, answer.policy_generation, now) + .unwrap(); + assert_eq!(mapping.record.normalized_name.as_str(), "db.example"); + assert_eq!( + mapping.record.contracts[0].pinned_addresses, + ["10.2.3.4".parse::().unwrap()] + ); + } + + #[tokio::test] + async fn mapping_publication_ocsf_exposes_correlatable_resolution_chain() { + let service = service( + BASE_POLICY, + vec!["10.2.3.5".parse().unwrap(), "10.2.3.4".parse().unwrap()], + ); + let now = Instant::now(); + let answer = service + .answer_query("DB.EXAMPLE.", AddressFamily::Ipv4, now) + .await + .unwrap(); + let mapping = service + .store + .lookup(answer.address, 5432, answer.policy_generation, now) + .unwrap(); + + let event = build_mapping_publication_event(&mapping.record); + let json = event.to_json().unwrap(); + let unmapped = &json["unmapped"]; + assert_eq!(unmapped["normalized_domain"], "db.example"); + assert_eq!(unmapped["synthetic_ip"], answer.address.to_string()); + assert_eq!(unmapped["allowed_ports"], serde_json::json!([5432])); + assert_eq!( + unmapped["approved_real_ip_candidates"], + serde_json::json!(["10.2.3.4", "10.2.3.5"]) + ); + assert_eq!(unmapped["mapping_id"], answer.mapping_id.to_string()); + assert_eq!(unmapped["mapping_generation"], answer.mapping_generation); + assert_eq!(unmapped["policy_generation"], answer.policy_generation); + + let shorthand = event.format_shorthand(); + assert!(shorthand.contains("Policy DNS mapped db.example")); + assert!(shorthand.contains("resolved=10.2.3.4,10.2.3.5")); + assert!(shorthand.contains(&format!("synthetic={}", answer.address))); + assert!(shorthand.contains(&format!("mapping_id={}", answer.mapping_id))); + } + + #[tokio::test] + async fn wildcard_is_eligible_but_uses_public_only_destination_rules() { + let yaml = BASE_POLICY.replace("db.example", "'*.example.com'"); + let service = service(&yaml, vec!["10.2.3.4".parse().unwrap()]); + let result = service + .answer_query("db.example.com", AddressFamily::Ipv4, Instant::now()) + .await; + assert!(matches!(result, Err(PolicyDnsError::NoValidAddress))); + } + + #[tokio::test] + async fn allowed_ips_filters_each_answer_without_rejecting_usable_addresses() { + let yaml = + BASE_POLICY.replace("protocol: tcp", "protocol: tcp, allowed_ips: [10.2.0.0/16]"); + let service = service( + &yaml, + vec!["10.3.4.5".parse().unwrap(), "10.2.3.4".parse().unwrap()], + ); + let now = Instant::now(); + let answer = service + .answer_query("db.example", AddressFamily::Ipv4, now) + .await + .unwrap(); + let mapping = service + .store + .lookup(answer.address, 5432, answer.policy_generation, now) + .unwrap(); + assert_eq!( + mapping.record.contracts[0].pinned_addresses, + ["10.2.3.4".parse::().unwrap()] + ); + } + + const HOST_GATEWAY_POLICY: &str = r" +network_policies: + gateway: + name: gateway + endpoints: + - { host: host.openshell.internal, port: 8080, protocol: tcp } + binaries: [{ path: /usr/bin/client }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +"; + + fn gateway_service( + addresses: Vec, + trusted_host_gateway: Option, + ) -> PolicyDnsService { + service_with_gateway(HOST_GATEWAY_POLICY, addresses, trusted_host_gateway) + } + + #[tokio::test] + async fn reserved_gateway_alias_without_trusted_address_never_queries_resolver() { + for alias in [ + "host.openshell.internal", + "host.containers.internal", + "host.docker.internal", + ] { + let yaml = HOST_GATEWAY_POLICY.replace("host.openshell.internal", alias); + let service = service_with_gateway(&yaml, vec!["169.254.1.2".parse().unwrap()], None); + + let result = service + .answer_query(alias, AddressFamily::Ipv4, Instant::now()) + .await; + + assert!(matches!( + result, + Err(PolicyDnsError::TrustedGatewayUnavailable) + )); + assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn reserved_gateway_alias_pins_only_the_exact_trusted_address() { + let trusted: IpAddr = "169.254.1.2".parse().unwrap(); + let service = gateway_service( + vec![ + "169.254.169.254".parse().unwrap(), + "169.254.1.3".parse().unwrap(), + "10.2.3.4".parse().unwrap(), + trusted, + ], + Some(trusted), + ); + let now = Instant::now(); + + let answer = service + .answer_query("host.openshell.internal", AddressFamily::Ipv4, now) + .await + .unwrap(); + let mapping = service + .store + .lookup(answer.address, 8080, answer.policy_generation, now) + .unwrap(); + + assert_eq!(mapping.record.contracts[0].pinned_addresses, [trusted]); + } + + #[tokio::test] + async fn reserved_gateway_alias_rejects_mismatch_metadata_private_and_wrong_family_answers() { + let trusted: IpAddr = "169.254.1.2".parse().unwrap(); + for (family, address) in [ + (AddressFamily::Ipv4, "169.254.1.3"), + (AddressFamily::Ipv4, "169.254.169.254"), + (AddressFamily::Ipv4, "10.2.3.4"), + (AddressFamily::Ipv6, "fe80::2"), + ] { + let service = gateway_service(vec![address.parse().unwrap()], Some(trusted)); + let result = service + .answer_query("host.openshell.internal", family, Instant::now()) + .await; + assert!( + matches!(result, Err(PolicyDnsError::NoValidAddress)), + "{address} must not satisfy the trusted gateway contract" + ); + } + } + + struct BlockingResolver { + started: Arc, + release: Arc, + } + + impl TrustedResolver for BlockingResolver { + async fn resolve( + &self, + _name: &NormalizedName, + _family: AddressFamily, + ) -> Result { + self.started.notify_one(); + self.release.notified().await; + Ok(TrustedAnswer { + addresses: vec!["8.8.8.8".parse().unwrap()], + ttl: Duration::from_secs(10), + }) + } + } + + #[tokio::test] + async fn delayed_stale_resolution_cannot_replace_newer_generation_mapping() { + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) + .unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 2), + "fd00:1::1".parse::().unwrap()..="fd00:1::2".parse::().unwrap(), + ) + .unwrap(); + let store = Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 4).unwrap(), + )); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let service = Arc::new(PolicyDnsService::new( + policy.clone(), + BlockingResolver { + started: started.clone(), + release: release.clone(), + }, + store.clone(), + None, + )); + let query = tokio::spawn(async move { + service + .answer_query("db.example", AddressFamily::Ipv4, Instant::now()) + .await + }); + started.notified().await; + policy + .reload(include_str!("../../data/sandbox-policy.rego"), BASE_POLICY) + .unwrap(); + let current_service = PolicyDnsService::new( + policy.clone(), + FakeResolver { + calls: AtomicUsize::new(0), + answer: TrustedAnswer { + addresses: vec!["8.8.4.4".parse().unwrap()], + ttl: Duration::from_secs(10), + }, + }, + store.clone(), + None, + ); + let now = Instant::now(); + let current = current_service + .answer_query("db.example", AddressFamily::Ipv4, now) + .await + .unwrap(); + release.notify_one(); + assert!(matches!( + query.await.unwrap(), + Err(PolicyDnsError::StalePolicy) + )); + let mapping = store + .lookup(current.address, 5432, current.policy_generation, now) + .unwrap(); + assert_eq!( + mapping.record.policy_generation, + policy.current_generation() + ); + assert_eq!( + mapping.record.contracts[0].pinned_addresses, + ["8.8.4.4".parse::().unwrap()] + ); + } + + #[test] + fn policy_dns_failure_events_have_stable_actionable_context() { + let name = NormalizedName::parse("DB.EXAMPLE.").unwrap(); + let endpoints = vec![PolicyEndpointId { + policy_name: "database".to_string(), + endpoint_index: 2, + }]; + let event = build_dns_failure_event( + &name, + AddressFamily::Ipv4, + &endpoints, + 7, + "policy_dns_upstream_nxdomain", + "Policy DNS trusted resolver query failed", + ); + let json = serde_json::to_value(event).unwrap(); + + assert_eq!(json["activity_name"], "Refuse"); + assert_eq!(json["action"], "Denied"); + assert_eq!(json["severity"], "Low"); + assert_eq!(json["status"], "Failure"); + assert_eq!(json["status_detail"], "policy_dns_upstream_nxdomain"); + assert_eq!(json["dst_endpoint"]["domain"], "db.example"); + assert_eq!(json["dst_endpoint"]["port"], 53); + assert_eq!(json["unmapped"]["normalized_name"], "db.example"); + assert_eq!(json["unmapped"]["address_family"], "ipv4"); + assert_eq!(json["unmapped"]["policy_generation"], 7); + assert_eq!( + json["unmapped"]["eligible_endpoints"], + serde_json::json!([{"policy_name": "database", "endpoint_index": 2}]) + ); + } + + #[test] + fn resolver_and_publication_failures_have_stable_reason_codes() { + assert_eq!( + resolver_failure_detail(&resolver::ResolveError::NxDomain), + "policy_dns_upstream_nxdomain" + ); + assert_eq!( + resolver_failure_detail(&resolver::ResolveError::Timeout), + "policy_dns_upstream_timeout" + ); + assert_eq!( + publication_failure_detail(PublishError::StalePolicy), + "policy_dns_publication_stale_generation" + ); + assert_eq!( + publication_failure_detail(PublishError::InvalidMapping), + "policy_dns_publication_invalid_mapping" + ); + assert_eq!( + publication_failure_detail(PublishError::PoolExhausted), + "policy_dns_publication_pool_exhausted" + ); + assert_eq!( + publication_failure_detail(PublishError::LockPoisoned), + "policy_dns_publication_store_unavailable" + ); + } + + #[test] + fn ttl_is_floored_and_capped() { + assert_eq!(clamp_mapping_ttl(Duration::ZERO), MIN_MAPPING_TTL); + assert_eq!( + clamp_mapping_ttl(Duration::from_secs(10)), + Duration::from_secs(10) + ); + assert_eq!(clamp_mapping_ttl(Duration::from_secs(300)), MAX_MAPPING_TTL); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/name.rs b/crates/openshell-supervisor-network/src/policy_dns/name.rs new file mode 100644 index 0000000000..0f2317e096 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/name.rs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical DNS-name handling for policy lookup and correlation keys. + +use hickory_proto::rr::Name; +use std::fmt; + +/// A lower-case absolute DNS name without its presentation trailing dot. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct NormalizedName(String); + +impl NormalizedName { + pub(crate) fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.parse::().is_ok() { + return Err(NameError); + } + + let absolute = if trimmed.ends_with('.') { + trimmed.to_string() + } else { + format!("{trimmed}.") + }; + let parsed = Name::from_ascii(&absolute).map_err(|_| NameError)?; + if parsed.is_root() { + return Err(NameError); + } + + // DNS names are case-insensitive. Canonicalizing to lowercase also + // prevents DNS 0x20 case variation from becoming an unobserved data + // channel in policy matching and synthetic-allocation identities. + let normalized = parsed.to_ascii().trim_end_matches('.').to_ascii_lowercase(); + if normalized.is_empty() { + return Err(NameError); + } + Ok(Self(normalized)) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } + + pub(crate) fn as_absolute_name(&self) -> Name { + Name::from_ascii(format!("{}.", self.0)).expect("normalized DNS name must remain valid") + } +} + +impl fmt::Display for NormalizedName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NameError; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_case_and_trailing_dot() { + let lower = NormalizedName::parse("Db.Example.COM.").unwrap(); + assert_eq!(lower.as_str(), "db.example.com"); + assert_eq!(NormalizedName::parse("db.example.com").unwrap(), lower); + } + + #[test] + fn rejects_empty_root_ip_literals_and_invalid_labels() { + for raw in ["", ".", "192.0.2.10", "2001:db8::1", "bad name.example"] { + assert!(NormalizedName::parse(raw).is_err(), "accepted {raw:?}"); + } + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/resolver.rs b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs new file mode 100644 index 0000000000..1044538ad1 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/resolver.rs @@ -0,0 +1,524 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded DNS exchange with an explicitly configured trusted resolver. +//! +//! This module never reads sandbox resolver state or `/etc/hosts`. The caller +//! supplies an already-parsed resolver socket address, and Hickory owns all DNS +//! wire encoding and decoding. + +use super::name::NormalizedName; +use hickory_proto::op::{Message, MessageType, OpCode, Query, ResponseCode}; +use hickory_proto::rr::{Name, RData, RecordType}; +use openshell_core::net::connect_tcp_nodelay_best_effort; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UdpSocket; +use tokio::time::timeout; + +pub(crate) const DEFAULT_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(2); +pub(crate) const MAX_DNS_MESSAGE_BYTES: usize = 8 * 1024; +pub(crate) const MAX_RETAINED_ADDRESSES: usize = 16; +pub(crate) const MAX_CNAME_HOPS: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum AddressFamily { + Ipv4, + Ipv6, +} + +impl AddressFamily { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Ipv4 => "ipv4", + Self::Ipv6 => "ipv6", + } + } + + pub(crate) fn record_type(self) -> RecordType { + match self { + Self::Ipv4 => RecordType::A, + Self::Ipv6 => RecordType::AAAA, + } + } + + pub(crate) fn accepts(self, address: IpAddr) -> bool { + matches!( + (self, address), + (Self::Ipv4, IpAddr::V4(_)) | (Self::Ipv6, IpAddr::V6(_)) + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TrustedAnswer { + pub(crate) addresses: Vec, + pub(crate) ttl: Duration, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ResolveError { + #[error("trusted DNS exchange timed out")] + Timeout, + #[error("trusted DNS I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("trusted DNS response exceeded the configured size bound")] + Oversized, + #[error("trusted DNS response was malformed or did not match the query")] + Malformed, + #[error("trusted DNS returned NXDOMAIN")] + NxDomain, + #[error("trusted DNS returned response code {0:?}")] + Response(ResponseCode), + #[error("trusted DNS returned no usable address records")] + NoData, + #[error("trusted DNS CNAME chain looped or exceeded the hop limit")] + CnameLimit, +} + +#[allow(async_fn_in_trait)] +pub(crate) trait TrustedResolver: Send + Sync { + async fn resolve( + &self, + name: &NormalizedName, + family: AddressFamily, + ) -> Result; +} + +/// A DNS client pinned to one operator-supplied upstream socket address. +pub(crate) struct SocketTrustedResolver { + server: SocketAddr, + exchange_timeout: Duration, +} + +impl SocketTrustedResolver { + pub(crate) fn new(server: SocketAddr) -> Self { + Self { + server, + exchange_timeout: DEFAULT_EXCHANGE_TIMEOUT, + } + } + + #[cfg(test)] + pub(crate) fn with_timeout(server: SocketAddr, exchange_timeout: Duration) -> Self { + Self { + server, + exchange_timeout, + } + } + + async fn exchange(&self, name: Name, record_type: RecordType) -> Result { + let query = Query::query(name, record_type); + let mut request = Message::query(); + let id = request.metadata.id; + request.metadata.recursion_desired = true; + request.queries.push(query.clone()); + let wire = request.to_vec().map_err(|_| ResolveError::Malformed)?; + + let udp_response = self.udp_exchange(&wire).await?; + let response = parse_response(&udp_response, id, &query)?; + if response.metadata.truncation { + let tcp_response = self.tcp_exchange(&wire).await?; + parse_response(&tcp_response, id, &query) + } else { + Ok(response) + } + } + + async fn udp_exchange(&self, request: &[u8]) -> Result, ResolveError> { + let bind = if self.server.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let socket = UdpSocket::bind(bind).await?; + socket.connect(self.server).await?; + + timeout(self.exchange_timeout, socket.send(request)) + .await + .map_err(|_| ResolveError::Timeout)??; + let mut response = vec![0_u8; MAX_DNS_MESSAGE_BYTES + 1]; + let received = timeout(self.exchange_timeout, socket.recv(&mut response)) + .await + .map_err(|_| ResolveError::Timeout)??; + if received > MAX_DNS_MESSAGE_BYTES { + return Err(ResolveError::Oversized); + } + response.truncate(received); + Ok(response) + } + + async fn tcp_exchange(&self, request: &[u8]) -> Result, ResolveError> { + let mut stream = timeout( + self.exchange_timeout, + connect_tcp_nodelay_best_effort(&[self.server]), + ) + .await + .map_err(|_| ResolveError::Timeout)??; + + let request_len = u16::try_from(request.len()).map_err(|_| ResolveError::Oversized)?; + timeout(self.exchange_timeout, stream.write_u16(request_len)) + .await + .map_err(|_| ResolveError::Timeout)??; + timeout(self.exchange_timeout, stream.write_all(request)) + .await + .map_err(|_| ResolveError::Timeout)??; + + let response_len = timeout(self.exchange_timeout, stream.read_u16()) + .await + .map_err(|_| ResolveError::Timeout)?? as usize; + if response_len > MAX_DNS_MESSAGE_BYTES { + return Err(ResolveError::Oversized); + } + let mut response = vec![0_u8; response_len]; + timeout(self.exchange_timeout, stream.read_exact(&mut response)) + .await + .map_err(|_| ResolveError::Timeout)??; + Ok(response) + } +} + +impl TrustedResolver for SocketTrustedResolver { + async fn resolve( + &self, + name: &NormalizedName, + family: AddressFamily, + ) -> Result { + let mut current = name.as_absolute_name(); + let mut visited = BTreeSet::new(); + let mut chain_ttl = u32::MAX; + let mut cname_hops = 0; + visited.insert(canonical_name(¤t)); + + for _ in 0..=MAX_CNAME_HOPS { + let current_key = canonical_name(¤t); + let response = self.exchange(current.clone(), family.record_type()).await?; + let parsed = parse_answer_records(&response, family); + let mut cursor = current_key; + + loop { + if let Some(records) = parsed.addresses.get(&cursor) { + let mut addresses = records + .iter() + .map(|(address, _)| *address) + .collect::>(); + retain_first_addresses(&mut addresses); + addresses.truncate(MAX_RETAINED_ADDRESSES); + let address_ttl = records.iter().map(|(_, ttl)| *ttl).min().unwrap_or(1); + return Ok(TrustedAnswer { + addresses, + ttl: Duration::from_secs(u64::from(chain_ttl.min(address_ttl))), + }); + } + + let Some((target, ttl)) = parsed.cnames.get(&cursor) else { + return Err(ResolveError::NoData); + }; + cname_hops += 1; + if cname_hops > MAX_CNAME_HOPS { + return Err(ResolveError::CnameLimit); + } + chain_ttl = chain_ttl.min(*ttl); + let target_key = canonical_name(target); + if !visited.insert(target_key.clone()) { + return Err(ResolveError::CnameLimit); + } + cursor = target_key; + + if !parsed.addresses.contains_key(&cursor) && !parsed.cnames.contains_key(&cursor) { + current = target.clone(); + break; + } + } + } + + Err(ResolveError::CnameLimit) + } +} + +fn retain_first_addresses(addresses: &mut Vec) { + let mut seen = HashSet::new(); + addresses.retain(|address| seen.insert(*address)); +} + +fn parse_response(wire: &[u8], id: u16, query: &Query) -> Result { + if wire.len() > MAX_DNS_MESSAGE_BYTES { + return Err(ResolveError::Oversized); + } + let response = Message::from_vec(wire).map_err(|_| ResolveError::Malformed)?; + if response.metadata.id != id + || response.metadata.message_type != MessageType::Response + || response.metadata.op_code != OpCode::Query + || response.queries.len() != 1 + || response.queries.first() != Some(query) + { + return Err(ResolveError::Malformed); + } + match response.metadata.response_code { + ResponseCode::NoError => Ok(response), + ResponseCode::NXDomain => Err(ResolveError::NxDomain), + code => Err(ResolveError::Response(code)), + } +} + +struct ParsedRecords { + addresses: BTreeMap>, + cnames: BTreeMap, +} + +fn parse_answer_records(message: &Message, family: AddressFamily) -> ParsedRecords { + let mut parsed = ParsedRecords { + addresses: BTreeMap::new(), + cnames: BTreeMap::new(), + }; + + for record in &message.answers { + let owner = canonical_name(&record.name); + match &record.data { + RData::A(value) if family == AddressFamily::Ipv4 => { + parsed + .addresses + .entry(owner) + .or_default() + .push((IpAddr::V4(value.0), record.ttl)); + } + RData::AAAA(value) if family == AddressFamily::Ipv6 => { + parsed + .addresses + .entry(owner) + .or_default() + .push((IpAddr::V6(value.0), record.ttl)); + } + RData::CNAME(target) => { + parsed + .cnames + .entry(owner) + .or_insert_with(|| (target.0.clone(), record.ttl)); + } + _ => {} + } + } + parsed +} + +fn canonical_name(name: &Name) -> String { + name.to_ascii().trim_end_matches('.').to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use hickory_proto::rr::Record; + use hickory_proto::rr::rdata::{A, CNAME}; + use openshell_core::net::set_tcp_nodelay_best_effort; + use tokio::net::TcpListener; + + async fn bind_dns_test_server() -> (UdpSocket, TcpListener, SocketAddr) { + const MAX_BIND_ATTEMPTS: usize = 100; + + for _ in 0..MAX_BIND_ATTEMPTS { + // Port zero only guarantees availability for the protocol being bound. + let tcp = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server = tcp.local_addr().unwrap(); + match UdpSocket::bind(server).await { + Ok(udp) => return (udp, tcp, server), + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {} + Err(error) => panic!("failed to bind DNS test UDP socket: {error}"), + } + } + + panic!("failed to bind DNS test TCP and UDP sockets to the same port"); + } + + #[test] + fn resolver_address_deduplication_preserves_answer_order() { + let mut addresses = vec![ + "203.0.113.20".parse().unwrap(), + "203.0.113.10".parse().unwrap(), + "203.0.113.20".parse().unwrap(), + ]; + + retain_first_addresses(&mut addresses); + + assert_eq!( + addresses, + vec![ + "203.0.113.20".parse::().unwrap(), + "203.0.113.10".parse::().unwrap(), + ] + ); + } + + #[test] + fn answer_parser_keeps_only_requested_family_and_bounds_are_constants() { + let owner = Name::from_ascii("db.example.").unwrap(); + let mut message = Message::response(1, OpCode::Query); + message.add_answer(Record::from_rdata( + owner.clone(), + 120, + RData::A(A::new(203, 0, 113, 10)), + )); + message.add_answer(Record::from_rdata( + owner, + 120, + RData::AAAA("2001:db8::10".parse::().unwrap().into()), + )); + + let parsed = parse_answer_records(&message, AddressFamily::Ipv4); + assert_eq!(parsed.addresses["db.example"].len(), 1); + assert_eq!(MAX_RETAINED_ADDRESSES, 16); + assert_eq!(MAX_DNS_MESSAGE_BYTES, 8192); + } + + #[test] + fn parser_retains_cname_owner_target_and_ttl() { + let owner = Name::from_ascii("db.example.").unwrap(); + let target = Name::from_ascii("target.example.").unwrap(); + let mut message = Message::response(1, OpCode::Query); + message.add_answer(Record::from_rdata( + owner, + 17, + RData::CNAME(CNAME(target.clone())), + )); + let parsed = parse_answer_records(&message, AddressFamily::Ipv4); + assert_eq!(parsed.cnames["db.example"], (target, 17)); + } + + #[test] + fn response_validation_rejects_wrong_transaction_or_question() { + let query = Query::query(Name::from_ascii("db.example.").unwrap(), RecordType::A); + let mut response = Message::response(9, OpCode::Query); + response.queries.push(query.clone()); + let wire = response.to_vec().unwrap(); + assert!(matches!( + parse_response(&wire, 10, &query), + Err(ResolveError::Malformed) + )); + } + + #[tokio::test] + async fn truncated_udp_retries_over_tcp_and_follows_cname() { + let (udp, tcp, server) = bind_dns_test_server().await; + + let udp_task = tokio::spawn(async move { + let mut wire = [0_u8; MAX_DNS_MESSAGE_BYTES]; + let (length, peer) = udp.recv_from(&mut wire).await.unwrap(); + let request = Message::from_vec(&wire[..length]).unwrap(); + let mut response = Message::response(request.metadata.id, OpCode::Query); + response.metadata.truncation = true; + response.queries = request.queries; + udp.send_to(&response.to_vec().unwrap(), peer) + .await + .unwrap(); + }); + let tcp_task = tokio::spawn(async move { + let (mut stream, _) = tcp.accept().await.unwrap(); + set_tcp_nodelay_best_effort(&stream); + let length = stream.read_u16().await.unwrap() as usize; + let mut wire = vec![0_u8; length]; + stream.read_exact(&mut wire).await.unwrap(); + let request = Message::from_vec(&wire).unwrap(); + let requested = request.queries[0].name.clone(); + let canonical = Name::from_ascii("canonical.example.").unwrap(); + let mut response = Message::response(request.metadata.id, OpCode::Query); + response.queries = request.queries; + response.add_answer(Record::from_rdata( + requested, + 12, + RData::CNAME(CNAME(canonical.clone())), + )); + response.add_answer(Record::from_rdata( + canonical, + 20, + RData::A(A::new(8, 8, 8, 8)), + )); + let wire = response.to_vec().unwrap(); + stream + .write_u16(u16::try_from(wire.len()).unwrap()) + .await + .unwrap(); + stream.write_all(&wire).await.unwrap(); + }); + + let resolver = SocketTrustedResolver::new(server); + let answer = resolver + .resolve( + &NormalizedName::parse("db.example").unwrap(), + AddressFamily::Ipv4, + ) + .await + .unwrap(); + assert_eq!(answer.addresses, ["8.8.8.8".parse::().unwrap()]); + assert_eq!(answer.ttl, Duration::from_secs(12)); + udp_task.await.unwrap(); + tcp_task.await.unwrap(); + } + + #[tokio::test] + async fn cname_hop_overflow_fails_closed() { + let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let server = udp.local_addr().unwrap(); + let task = tokio::spawn(async move { + let mut wire = [0_u8; MAX_DNS_MESSAGE_BYTES]; + let (length, peer) = udp.recv_from(&mut wire).await.unwrap(); + let request = Message::from_vec(&wire[..length]).unwrap(); + let mut response = Message::response(request.metadata.id, OpCode::Query); + response.queries = request.queries.clone(); + let mut owner = request.queries[0].name.clone(); + for index in 0..=MAX_CNAME_HOPS { + let target = Name::from_ascii(format!("hop{index}.example.")).unwrap(); + response.add_answer(Record::from_rdata( + owner, + 10, + RData::CNAME(CNAME(target.clone())), + )); + owner = target; + } + response.add_answer(Record::from_rdata(owner, 10, RData::A(A::new(8, 8, 8, 8)))); + udp.send_to(&response.to_vec().unwrap(), peer) + .await + .unwrap(); + }); + let resolver = SocketTrustedResolver::new(server); + assert!(matches!( + resolver + .resolve( + &NormalizedName::parse("db.example").unwrap(), + AddressFamily::Ipv4 + ) + .await, + Err(ResolveError::CnameLimit) + )); + task.await.unwrap(); + } + + #[tokio::test] + async fn trusted_exchange_timeout_is_bounded() { + let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let resolver = SocketTrustedResolver::with_timeout( + udp.local_addr().unwrap(), + Duration::from_millis(10), + ); + assert!(matches!( + resolver + .resolve( + &NormalizedName::parse("db.example").unwrap(), + AddressFamily::Ipv4 + ) + .await, + Err(ResolveError::Timeout) + )); + drop(udp); + } + + #[test] + fn oversized_response_is_rejected_before_decode() { + let query = Query::query(Name::from_ascii("db.example.").unwrap(), RecordType::A); + assert!(matches!( + parse_response(&vec![0; MAX_DNS_MESSAGE_BYTES + 1], 1, &query), + Err(ResolveError::Oversized) + )); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/runtime.rs b/crates/openshell-supervisor-network/src/policy_dns/runtime.rs new file mode 100644 index 0000000000..ad6095efa9 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/runtime.rs @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime-owned policy DNS listeners for combined Linux supervisors. + +use super::resolver::MAX_DNS_MESSAGE_BYTES; +use super::store::{ResolvedEndpointStore, StoreConfig, SyntheticPools}; +use super::{PolicyDnsService, SocketTrustedResolver, wire}; +use crate::opa::OpaEngine; +use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::task::JoinHandle; + +const IPV4_POOL_PREFIX: u8 = 23; +const IPV6_POOL_PREFIX: u8 = 119; +const IPV4_EPOCH_WINDOWS: u64 = 1 << (IPV4_POOL_PREFIX - 15); +const MAX_MAPPINGS: usize = 1024; +const MAX_CONCURRENT_UDP_QUERIES: usize = 64; + +#[derive(Debug, Clone)] +pub(crate) struct PolicyDnsRuntimeConfig { + pub(crate) ipv4_cidr: ipnet::Ipv4Net, + pub(crate) ipv6_cidr: ipnet::Ipv6Net, + pools: SyntheticPools, +} + +impl PolicyDnsRuntimeConfig { + pub(crate) fn for_epoch(epoch: u64) -> Result { + let ipv4_parent: ipnet::Ipv4Net = "198.18.0.0/15".parse().unwrap(); + let ipv4_window = epoch % IPV4_EPOCH_WINDOWS; + let ipv4_start = u32::from(ipv4_parent.network()) + + u32::try_from(ipv4_window * (1 << (32 - IPV4_POOL_PREFIX))).unwrap(); + let ipv4_cidr = ipnet::Ipv4Net::new(Ipv4Addr::from(ipv4_start), IPV4_POOL_PREFIX) + .map_err(|error| miette::miette!(error.to_string()))?; + + let ipv6_parent: ipnet::Ipv6Net = "fd23:6f70:656e::/48".parse().unwrap(); + let ipv6_window = u128::from(epoch); + let ipv6_start = + u128::from(ipv6_parent.network()) + (ipv6_window << (128 - IPV6_POOL_PREFIX)); + let ipv6_cidr = ipnet::Ipv6Net::new(Ipv6Addr::from(ipv6_start), IPV6_POOL_PREFIX) + .map_err(|error| miette::miette!(error.to_string()))?; + + let pools = SyntheticPools::new( + ipv4_cidr.network()..=ipv4_cidr.broadcast(), + ipv6_cidr.network()..=ipv6_cidr.broadcast(), + ) + .map_err(|error| miette::miette!(error.to_string()))?; + Ok(Self { + ipv4_cidr, + ipv6_cidr, + pools, + }) + } +} + +pub(crate) struct PolicyDnsRuntime { + pub(crate) store: Arc, + tasks: Vec>, +} + +impl PolicyDnsRuntime { + pub(crate) fn start( + policy: Arc, + udp: tokio::net::UdpSocket, + tcp: tokio::net::TcpListener, + trusted_host_gateway: Option, + config: PolicyDnsRuntimeConfig, + engine_ready: tokio::sync::watch::Receiver, + ) -> Result { + let upstream = trusted_resolver_from_resolv_conf()?; + let store = Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(config.pools, MAX_MAPPINGS) + .map_err(|error| miette::miette!(error.to_string()))?, + )); + let service = Arc::new(PolicyDnsService::new( + policy, + SocketTrustedResolver::new(upstream), + store.clone(), + trusted_host_gateway, + )); + let address = udp.local_addr().into_diagnostic()?; + + let udp_service = service.clone(); + let udp = Arc::new(udp); + let udp_concurrency = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_UDP_QUERIES)); + let mut udp_engine_ready = engine_ready.clone(); + let udp_task = tokio::spawn(async move { + if udp_engine_ready.wait_for(|ready| *ready).await.is_err() { + return; + } + let mut request = vec![0_u8; MAX_DNS_MESSAGE_BYTES + 1]; + loop { + let Ok(permit) = udp_concurrency.clone().acquire_owned().await else { + break; + }; + let Ok((length, peer)) = udp.recv_from(&mut request).await else { + break; + }; + let request = request[..length].to_vec(); + let service = udp_service.clone(); + let udp = udp.clone(); + tokio::spawn(async move { + let _permit = permit; + // Docker and Podman do not currently prove usable IPv6 + // egress. Return NOERROR/NODATA for AAAA so dual-stack + // clients can fall back to the usable IPv4 path. + if let Ok(response) = + wire::handle_udp_query_with_ipv6(&service, &request, false).await + { + let _ = udp.send_to(&response, peer).await; + } + }); + } + }); + + let mut tcp_engine_ready = engine_ready; + let tcp_task = tokio::spawn(async move { + if tcp_engine_ready.wait_for(|ready| *ready).await.is_err() { + return; + } + loop { + let Ok((mut stream, _)) = tcp.accept().await else { + break; + }; + set_tcp_nodelay_best_effort(&stream); + let service = service.clone(); + tokio::spawn(async move { + // DNS-over-TCP connections may carry multiple sequential + // length-prefixed messages. libc commonly reuses one + // connection for A and AAAA during getaddrinfo(). + while let Ok(wire_length) = stream.read_u16().await { + let length = usize::from(wire_length); + if length > MAX_DNS_MESSAGE_BYTES { + return; + } + let mut frame = Vec::with_capacity(length + 2); + frame.extend_from_slice(&wire_length.to_be_bytes()); + frame.resize(length + 2, 0); + if stream.read_exact(&mut frame[2..]).await.is_err() { + return; + } + let Ok(response) = + wire::handle_tcp_query_with_ipv6(&service, &frame, false).await + else { + return; + }; + if stream.write_all(&response).await.is_err() { + return; + } + } + }); + } + }); + let expiry_store = store.clone(); + let expiry_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + interval.tick().await; + let _ = expiry_store.expire(std::time::Instant::now()); + } + }); + + ocsf_emit!( + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "ready") + .message(format!("Policy DNS listening on {address}")) + .build() + ); + Ok(Self { + store, + tasks: vec![udp_task, tcp_task, expiry_task], + }) + } +} + +impl Drop for PolicyDnsRuntime { + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + } +} + +fn trusted_resolver_from_resolv_conf() -> Result { + let contents = std::fs::read_to_string("/etc/resolv.conf") + .into_diagnostic() + .wrap_err("failed to read trusted supervisor resolver configuration")?; + for line in contents.lines() { + let line = line.split('#').next().unwrap_or_default(); + let mut fields = line.split_whitespace(); + if fields.next() != Some("nameserver") { + continue; + } + let Some(value) = fields.next() else { + continue; + }; + if let Ok(ip) = value.parse::() { + return Ok(SocketAddr::new(ip, 53)); + } + } + Err(miette::miette!( + "no literal nameserver is configured in supervisor /etc/resolv.conf" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn production_pool_is_disjoint_from_workload_veth() { + let workload: ipnet::IpNet = "10.200.0.0/24".parse().unwrap(); + let config = PolicyDnsRuntimeConfig::for_epoch(42).unwrap(); + for address in [ + IpAddr::V4(config.ipv4_cidr.network()), + IpAddr::V4(config.ipv4_cidr.broadcast()), + ] { + assert!(!workload.contains(&address)); + } + } + + #[test] + fn adjacent_boot_epochs_use_disjoint_capture_ranges() { + let first = PolicyDnsRuntimeConfig::for_epoch(1).unwrap(); + let second = PolicyDnsRuntimeConfig::for_epoch(2).unwrap(); + assert_ne!(first.ipv4_cidr, second.ipv4_cidr); + assert_ne!(first.ipv6_cidr, second.ipv6_cidr); + let parent: ipnet::Ipv4Net = "198.18.0.0/15".parse().unwrap(); + for address in [first.ipv4_cidr.network(), second.ipv4_cidr.broadcast()] { + assert!(parent.contains(&address)); + } + } + + #[test] + fn production_pools_and_store_capacity_expand_together() { + let config = PolicyDnsRuntimeConfig::for_epoch(7).unwrap(); + let ipv4_capacity = 1_usize << (32 - config.ipv4_cidr.prefix_len()); + let ipv6_capacity = 1_usize << (128 - config.ipv6_cidr.prefix_len()); + assert_eq!(ipv4_capacity, 512); + assert_eq!(ipv6_capacity, 512); + assert_eq!(MAX_MAPPINGS, ipv4_capacity + ipv6_capacity); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/store.rs b/crates/openshell-supervisor-network/src/policy_dns/store.rs new file mode 100644 index 0000000000..ea2cd7c0f8 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/store.rs @@ -0,0 +1,783 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Synthetic-address allocation and resolved endpoint mappings. + +use super::name::NormalizedName; +use super::resolver::AddressFamily; +use crate::proxy::destination::{ + DestinationRequest, DestinationValidationPlan, UpstreamConnector, build_pinned_validation_plan, + validate_destination, +}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::ops::RangeInclusive; +use std::sync::RwLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct PolicyEndpointId { + pub(crate) policy_name: String, + pub(crate) endpoint_index: usize, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedPortContract { + pub(crate) endpoint_id: PolicyEndpointId, + pub(crate) port: u16, + pub(crate) destination_plan: DestinationValidationPlan, + pub(crate) pinned_addresses: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct PublishRequest { + pub(crate) normalized_name: NormalizedName, + pub(crate) family: AddressFamily, + /// Digest of every compatible endpoint identity and its policy metadata. + /// A changed endpoint contract receives a new synthetic identity even when + /// it selects the same normalized name. + pub(crate) allocation_identity: [u8; 32], + pub(crate) policy_generation: u64, + pub(crate) ttl: Duration, + pub(crate) contracts: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedEndpointRecord { + pub(crate) synthetic_address: IpAddr, + pub(crate) normalized_name: NormalizedName, + pub(crate) family: AddressFamily, + pub(crate) policy_generation: u64, + pub(crate) mapping_generation: u64, + pub(crate) mapping_id: Uuid, + pub(crate) created_at: Instant, + pub(crate) expires_at: Instant, + pub(crate) contracts: Vec, +} + +impl ResolvedEndpointRecord { + pub(crate) fn allowed_ports(&self) -> BTreeSet { + self.contracts + .iter() + .map(|contract| contract.port) + .collect() + } +} + +#[derive(Debug, Clone)] +pub(crate) struct MappingLookup { + pub(crate) record: ResolvedEndpointRecord, + pub(crate) port: u16, +} + +impl MappingLookup { + pub(crate) fn endpoint_ids(&self) -> impl Iterator { + self.record + .contracts + .iter() + .filter(move |contract| contract.port == self.port) + .map(|contract| &contract.endpoint_id) + } + + /// Build the unopened connector for a process-authorized endpoint. + /// + /// Selecting by endpoint identity prevents a compatible endpoint record + /// from becoming a new policy precedence rule. The pinned destination mode + /// never resolves `normalized_name` again. + pub(crate) async fn connector_for( + &self, + endpoint_id: &PolicyEndpointId, + ) -> Result { + let mut seen = HashSet::new(); + let addresses = self + .record + .contracts + .iter() + .filter(|contract| contract.port == self.port && &contract.endpoint_id == endpoint_id) + .flat_map(|contract| contract.pinned_addresses.iter().copied()) + .filter(|address| seen.insert(*address)) + .collect::>(); + if addresses.is_empty() { + return Err(MappingLookupError::EndpointMismatch); + } + let plan = build_pinned_validation_plan(addresses) + .map_err(|_| MappingLookupError::InvalidMapping)?; + validate_destination(DestinationRequest { + host: self.record.normalized_name.as_str(), + port: self.port, + sandbox_entrypoint_pid: 0, + plan: &plan, + }) + .await + .map_err(|_| MappingLookupError::InvalidMapping) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SyntheticPools { + ipv4: RangeInclusive, + ipv6: RangeInclusive, +} + +impl SyntheticPools { + /// Construct injectable pools. Production runtime ranges are deliberately + /// selected only after PR3 checks route collisions in each namespace. + pub(crate) fn new( + ipv4: RangeInclusive, + ipv6: RangeInclusive, + ) -> Result { + if ipv4.is_empty() || ipv6.is_empty() { + return Err(StoreConfigError::InvalidPool); + } + for address in [IpAddr::V4(*ipv4.start()), IpAddr::V4(*ipv4.end())] { + if openshell_core::net::is_always_blocked_ip(address) { + return Err(StoreConfigError::InvalidPool); + } + } + for address in [IpAddr::V6(*ipv6.start()), IpAddr::V6(*ipv6.end())] { + if openshell_core::net::is_always_blocked_ip(address) { + return Err(StoreConfigError::InvalidPool); + } + } + Ok(Self { ipv4, ipv6 }) + } + + fn capacity(&self, family: AddressFamily) -> usize { + let capacity = match family { + AddressFamily::Ipv4 => { + u128::from(u32::from(*self.ipv4.end())) - u128::from(u32::from(*self.ipv4.start())) + + 1 + } + AddressFamily::Ipv6 => { + u128::from(*self.ipv6.end()) - u128::from(*self.ipv6.start()) + 1 + } + }; + usize::try_from(capacity).unwrap_or(usize::MAX) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct StoreConfig { + pub(crate) pools: SyntheticPools, + pub(crate) max_mappings: usize, +} + +impl StoreConfig { + pub(crate) fn new( + pools: SyntheticPools, + max_mappings: usize, + ) -> Result { + if max_mappings == 0 { + return Err(StoreConfigError::ZeroCapacity); + } + Ok(Self { + pools, + max_mappings, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum StoreConfigError { + #[error("synthetic address pool is empty or contains an always-blocked boundary")] + InvalidPool, + #[error("resolved endpoint store capacity must be non-zero")] + ZeroCapacity, +} + +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PublishError { + #[error("policy generation changed before mapping publication")] + StalePolicy, + #[error("resolved endpoint publication was empty or invalid")] + InvalidMapping, + #[error("synthetic address pool is exhausted")] + PoolExhausted, + #[error("resolved endpoint store lock was poisoned")] + LockPoisoned, +} + +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MappingLookupError { + #[error("transparent TCP mapping is missing")] + Missing, + #[error("transparent TCP mapping is expired")] + Expired, + #[error("transparent TCP mapping belongs to a stale policy generation")] + StalePolicy, + #[error("transparent TCP mapping does not authorize the requested port")] + PortMismatch, + #[error("transparent TCP mapping does not contain the authorized endpoint")] + EndpointMismatch, + #[error("transparent TCP mapping is internally invalid")] + InvalidMapping, + #[error("resolved endpoint store lock was poisoned")] + LockPoisoned, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct AllocationKey { + normalized_name: NormalizedName, + family: AddressFamily, + allocation_identity: [u8; 32], +} + +struct StoreState { + records: BTreeMap, + allocations: BTreeMap, + expired_allocations: BTreeSet, + next_ipv4: u32, + end_ipv4: u32, + next_ipv6: u128, + end_ipv6: u128, + next_mapping_generation: u64, +} + +pub(crate) struct ResolvedEndpointStore { + state: RwLock, + config: StoreConfig, + ipv4_pool_high_water_emitted: AtomicBool, + ipv6_pool_high_water_emitted: AtomicBool, +} + +impl ResolvedEndpointStore { + pub(crate) fn new(config: StoreConfig) -> Self { + let next_ipv4 = u32::from(*config.pools.ipv4.start()); + let end_ipv4 = u32::from(*config.pools.ipv4.end()); + let next_ipv6 = u128::from(*config.pools.ipv6.start()); + let end_ipv6 = u128::from(*config.pools.ipv6.end()); + Self { + state: RwLock::new(StoreState { + records: BTreeMap::new(), + allocations: BTreeMap::new(), + expired_allocations: BTreeSet::new(), + next_ipv4, + end_ipv4, + next_ipv6, + end_ipv6, + next_mapping_generation: 0, + }), + config, + ipv4_pool_high_water_emitted: AtomicBool::new(false), + ipv6_pool_high_water_emitted: AtomicBool::new(false), + } + } + + pub(crate) fn publish( + &self, + request: PublishRequest, + current_policy_generation: u64, + now: Instant, + ) -> Result { + if request.policy_generation != current_policy_generation { + return Err(PublishError::StalePolicy); + } + if request.ttl.is_zero() + || request.contracts.is_empty() + || request.contracts.iter().any(|contract| { + contract.port == 0 + || contract.pinned_addresses.is_empty() + || contract + .pinned_addresses + .iter() + .any(|address| !request.family.accepts(*address)) + }) + { + return Err(PublishError::InvalidMapping); + } + + let key = AllocationKey { + normalized_name: request.normalized_name.clone(), + family: request.family, + allocation_identity: request.allocation_identity, + }; + let mut state = self.state.write().map_err(|_| PublishError::LockPoisoned)?; + let synthetic_address = if let Some(address) = state.allocations.get(&key) { + *address + } else { + if state.allocations.len() >= self.config.max_mappings { + return Err(PublishError::PoolExhausted); + } + let address = + allocate_address(&mut state, request.family).ok_or(PublishError::PoolExhausted)?; + state.allocations.insert(key, address); + let allocated = state + .allocations + .keys() + .filter(|key| key.family == request.family) + .count(); + let capacity = self + .config + .pools + .capacity(request.family) + .min(self.config.max_mappings); + let emitted = match request.family { + AddressFamily::Ipv4 => &self.ipv4_pool_high_water_emitted, + AddressFamily::Ipv6 => &self.ipv6_pool_high_water_emitted, + }; + if allocated.saturating_mul(5) >= capacity.saturating_mul(4) + && !emitted.swap(true, Ordering::Relaxed) + { + openshell_ocsf::ocsf_emit!(build_pool_high_water_event( + allocated, + capacity, + request.family, + )); + } + address + }; + + // Defense in depth for callers outside the OPA generation guard: a + // delayed publication from an older generation must never replace a + // newer live correlation for the same stable allocation identity. + if state + .records + .get(&synthetic_address) + .is_some_and(|record| record.policy_generation > request.policy_generation) + { + return Err(PublishError::StalePolicy); + } + + state.next_mapping_generation = state.next_mapping_generation.saturating_add(1); + let record = ResolvedEndpointRecord { + synthetic_address, + normalized_name: request.normalized_name, + family: request.family, + policy_generation: request.policy_generation, + mapping_generation: state.next_mapping_generation, + mapping_id: Uuid::new_v4(), + created_at: now, + expires_at: now + request.ttl, + contracts: request.contracts, + }; + state.expired_allocations.remove(&synthetic_address); + state.records.insert(synthetic_address, record.clone()); + Ok(record) + } + + pub(crate) fn lookup( + &self, + synthetic_address: IpAddr, + port: u16, + current_policy_generation: u64, + now: Instant, + ) -> Result { + let state = self + .state + .read() + .map_err(|_| MappingLookupError::LockPoisoned)?; + let Some(record) = state.records.get(&synthetic_address) else { + return if state.expired_allocations.contains(&synthetic_address) { + Err(MappingLookupError::Expired) + } else { + Err(MappingLookupError::Missing) + }; + }; + if now >= record.expires_at { + return Err(MappingLookupError::Expired); + } + if record.policy_generation != current_policy_generation { + return Err(MappingLookupError::StalePolicy); + } + if !record + .contracts + .iter() + .any(|contract| contract.port == port) + { + return Err(MappingLookupError::PortMismatch); + } + Ok(MappingLookup { + record: record.clone(), + port, + }) + } + + /// Remove expired active records without freeing their synthetic identity. + pub(crate) fn expire(&self, now: Instant) -> Result { + let mut state = self + .state + .write() + .map_err(|_| MappingLookupError::LockPoisoned)?; + let expired = state + .records + .iter() + .filter_map(|(address, record)| (now >= record.expires_at).then_some(*address)) + .collect::>(); + for address in &expired { + state.records.remove(address); + state.expired_allocations.insert(*address); + } + Ok(expired.len()) + } +} + +fn build_pool_high_water_event( + allocated: usize, + capacity: usize, + family: AddressFamily, +) -> openshell_ocsf::OcsfEvent { + use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId}; + + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Low) + .status(StatusId::Success) + .state(StateId::Enabled, "high_water") + .unmapped("allocated_identities", allocated) + .unmapped("mapping_capacity", capacity) + .unmapped("address_family", family.as_str()) + .message(format!( + "Policy DNS {} synthetic pool reached high water: {allocated}/{capacity} identities allocated", + family.as_str() + )) + .build() +} + +fn allocate_address(state: &mut StoreState, family: AddressFamily) -> Option { + match family { + AddressFamily::Ipv4 if state.next_ipv4 <= state.end_ipv4 => { + let address = IpAddr::V4(Ipv4Addr::from(state.next_ipv4)); + state.next_ipv4 = state.next_ipv4.saturating_add(1); + Some(address) + } + AddressFamily::Ipv6 if state.next_ipv6 <= state.end_ipv6 => { + let address = IpAddr::V6(Ipv6Addr::from(state.next_ipv6)); + state.next_ipv6 = state.next_ipv6.saturating_add(1); + Some(address) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::destination::{AddressAuthorization, DestinationValidationPlan}; + use std::sync::{Arc, Barrier}; + + fn store(max_mappings: usize) -> ResolvedEndpointStore { + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 2), + "fd00:1::1".parse().unwrap()..="fd00:1::2".parse().unwrap(), + ) + .unwrap(); + ResolvedEndpointStore::new(StoreConfig::new(pools, max_mappings).unwrap()) + } + + fn request(name: &str, generation: u64, ttl: Duration) -> PublishRequest { + PublishRequest { + normalized_name: NormalizedName::parse(name).unwrap(), + family: AddressFamily::Ipv4, + allocation_identity: [1; 32], + policy_generation: generation, + ttl, + contracts: vec![ResolvedPortContract { + endpoint_id: PolicyEndpointId { + policy_name: "database".to_string(), + endpoint_index: 0, + }, + port: 5432, + destination_plan: DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }, + pinned_addresses: vec!["203.0.113.8".parse().unwrap()], + }], + } + } + + #[test] + fn refresh_retains_synthetic_identity_and_changes_mapping_generation() { + let store = store(2); + let now = Instant::now(); + let first = store + .publish(request("db.example", 7, Duration::from_secs(10)), 7, now) + .unwrap(); + let second = store + .publish( + request("DB.EXAMPLE.", 7, Duration::from_secs(20)), + 7, + now + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(first.synthetic_address, second.synthetic_address); + assert_ne!(first.mapping_id, second.mapping_id); + assert!(second.mapping_generation > first.mapping_generation); + assert_eq!(first.policy_generation, second.policy_generation); + } + + #[test] + fn distinct_names_sharing_real_ip_get_distinct_correlations() { + let store = store(2); + let now = Instant::now(); + let left = store + .publish(request("left.example", 1, Duration::from_secs(10)), 1, now) + .unwrap(); + let right = store + .publish(request("right.example", 1, Duration::from_secs(10)), 1, now) + .unwrap(); + assert_ne!(left.synthetic_address, right.synthetic_address); + assert_eq!( + left.contracts[0].pinned_addresses, + right.contracts[0].pinned_addresses + ); + } + + #[test] + fn wrong_port_stale_generation_and_expiry_fail_closed() { + let store = store(2); + let now = Instant::now(); + let record = store + .publish(request("db.example", 4, Duration::from_secs(2)), 4, now) + .unwrap(); + assert!(matches!( + store.lookup(record.synthetic_address, 3306, 4, now), + Err(MappingLookupError::PortMismatch) + )); + assert!(matches!( + store.lookup(record.synthetic_address, 5432, 5, now), + Err(MappingLookupError::StalePolicy) + )); + assert!(matches!( + store.lookup( + record.synthetic_address, + 5432, + 4, + now + Duration::from_secs(2) + ), + Err(MappingLookupError::Expired) + )); + } + + #[test] + fn expiry_never_reassigns_synthetic_address_to_another_name() { + let store = store(2); + let now = Instant::now(); + let first = store + .publish(request("first.example", 1, Duration::from_secs(1)), 1, now) + .unwrap(); + assert_eq!(store.expire(now + Duration::from_secs(1)).unwrap(), 1); + let second = store + .publish( + request("second.example", 1, Duration::from_secs(10)), + 1, + now + Duration::from_secs(1), + ) + .unwrap(); + assert_ne!(first.synthetic_address, second.synthetic_address); + assert!(matches!( + store.lookup( + first.synthetic_address, + 5432, + 1, + now + Duration::from_secs(1) + ), + Err(MappingLookupError::Expired) + )); + } + + #[test] + fn changed_endpoint_contract_never_reuses_synthetic_identity() { + let store = store(2); + let now = Instant::now(); + let first = store + .publish(request("db.example", 1, Duration::from_secs(5)), 1, now) + .unwrap(); + let mut changed = request("db.example", 2, Duration::from_secs(5)); + changed.allocation_identity = [2; 32]; + let second = store.publish(changed, 2, now).unwrap(); + assert_ne!(first.synthetic_address, second.synthetic_address); + assert!(matches!( + store.lookup(first.synthetic_address, 5432, 2, now), + Err(MappingLookupError::StalePolicy) + )); + } + + #[test] + fn pool_exhaustion_and_stale_publication_publish_nothing() { + let store = store(1); + let now = Instant::now(); + assert!(matches!( + store.publish(request("stale.example", 1, Duration::from_secs(5)), 2, now), + Err(PublishError::StalePolicy) + )); + let first = store + .publish(request("first.example", 2, Duration::from_secs(5)), 2, now) + .unwrap(); + assert!(matches!( + store.publish(request("second.example", 2, Duration::from_secs(5)), 2, now), + Err(PublishError::PoolExhausted) + )); + let preserved = store + .lookup(first.synthetic_address, 5432, 2, now) + .expect("pool exhaustion must preserve the existing mapping"); + assert_eq!(preserved.record.mapping_id, first.mapping_id); + assert!(matches!( + store.lookup("198.18.0.2".parse().unwrap(), 5432, 2, now), + Err(MappingLookupError::Missing) + )); + } + + #[test] + fn pool_high_water_event_reports_capacity_without_reclaiming_addresses() { + let event = build_pool_high_water_event(4, 5, AddressFamily::Ipv4); + let json = serde_json::to_value(event).unwrap(); + assert_eq!(json["unmapped"]["allocated_identities"], 4); + assert_eq!(json["unmapped"]["mapping_capacity"], 5); + assert_eq!(json["unmapped"]["address_family"], "ipv4"); + assert_eq!(json["state"], "high_water"); + } + + #[test] + fn production_sized_ipv4_pool_reaches_its_own_high_water_mark() { + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 0)..=Ipv4Addr::new(198, 18, 1, 255), + "fd23:6f70:656e::".parse().unwrap()..="fd23:6f70:656e::1ff".parse().unwrap(), + ) + .unwrap(); + assert_eq!(pools.capacity(AddressFamily::Ipv4), 512); + assert_eq!(pools.capacity(AddressFamily::Ipv6), 512); + let store = ResolvedEndpointStore::new(StoreConfig::new(pools, 1024).unwrap()); + let now = Instant::now(); + + for index in 0..410 { + store + .publish( + request(&format!("host-{index}.example"), 1, Duration::from_secs(5)), + 1, + now, + ) + .unwrap(); + } + + assert!(store.ipv4_pool_high_water_emitted.load(Ordering::Relaxed)); + assert!(!store.ipv6_pool_high_water_emitted.load(Ordering::Relaxed)); + } + + #[test] + fn older_generation_cannot_replace_newer_live_mapping() { + let store = store(1); + let now = Instant::now(); + let newer = store + .publish(request("db.example", 2, Duration::from_secs(10)), 2, now) + .unwrap(); + + assert!(matches!( + store.publish( + request("db.example", 1, Duration::from_secs(10)), + 1, + now + Duration::from_secs(1), + ), + Err(PublishError::StalePolicy) + )); + + let mapping = store.lookup(newer.synthetic_address, 5432, 2, now).unwrap(); + assert_eq!(mapping.record.mapping_id, newer.mapping_id); + assert_eq!(mapping.record.policy_generation, 2); + } + + #[test] + fn real_address_never_inherits_synthetic_mapping() { + let store = store(1); + let now = Instant::now(); + store + .publish(request("db.example", 1, Duration::from_secs(5)), 1, now) + .unwrap(); + assert!(matches!( + store.lookup("203.0.113.8".parse().unwrap(), 5432, 1, now), + Err(MappingLookupError::Missing) + )); + } + + #[test] + fn ipv6_pool_allocates_only_ipv6_synthetic_addresses() { + let store = store(1); + let now = Instant::now(); + let mut request = request("db.example", 1, Duration::from_secs(5)); + request.family = AddressFamily::Ipv6; + request.contracts[0].pinned_addresses = vec!["2001:db8::8".parse().unwrap()]; + let record = store.publish(request, 1, now).unwrap(); + assert!(record.synthetic_address.is_ipv6()); + assert_eq!(record.family, AddressFamily::Ipv6); + } + + #[test] + fn concurrent_refreshes_never_publish_partial_records() { + let store = Arc::new(store(1)); + let barrier = Arc::new(Barrier::new(9)); + let now = Instant::now(); + let mut workers = Vec::new(); + for _ in 0..8 { + let store = store.clone(); + let barrier = barrier.clone(); + workers.push(std::thread::spawn(move || { + barrier.wait(); + store + .publish(request("db.example", 3, Duration::from_secs(5)), 3, now) + .unwrap() + })); + } + barrier.wait(); + let records = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert!( + records + .iter() + .all(|record| record.synthetic_address == records[0].synthetic_address) + ); + let lookup = store + .lookup(records[0].synthetic_address, 5432, 3, now) + .unwrap(); + assert!(!lookup.record.contracts.is_empty()); + assert!(!lookup.record.contracts[0].pinned_addresses.is_empty()); + assert!(records.iter().all(|record| record.mapping_generation > 0)); + } + + #[tokio::test] + async fn connector_uses_only_pinned_addresses_and_endpoint_identity() { + let store = store(1); + let now = Instant::now(); + let record = store + .publish( + request("must-not-resolve.invalid", 1, Duration::from_secs(5)), + 1, + now, + ) + .unwrap(); + let lookup = store + .lookup(record.synthetic_address, 5432, 1, now) + .unwrap(); + let endpoint = lookup.endpoint_ids().next().unwrap().clone(); + let connector = lookup.connector_for(&endpoint).await.unwrap(); + assert_eq!(connector.addrs(), &["203.0.113.8:5432".parse().unwrap()]); + } + + #[tokio::test] + async fn connector_preserves_resolver_address_order_while_deduplicating() { + let store = store(1); + let now = Instant::now(); + let mut request = request("must-not-resolve.invalid", 1, Duration::from_secs(5)); + request.contracts[0].pinned_addresses = vec![ + "203.0.113.20".parse().unwrap(), + "203.0.113.10".parse().unwrap(), + "203.0.113.20".parse().unwrap(), + ]; + let record = store.publish(request, 1, now).unwrap(); + let lookup = store + .lookup(record.synthetic_address, 5432, 1, now) + .unwrap(); + let endpoint = lookup.endpoint_ids().next().unwrap().clone(); + + let connector = lookup.connector_for(&endpoint).await.unwrap(); + + assert_eq!( + connector.addrs(), + &[ + "203.0.113.20:5432".parse().unwrap(), + "203.0.113.10:5432".parse().unwrap(), + ] + ); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_dns/wire.rs b/crates/openshell-supervisor-network/src/policy_dns/wire.rs new file mode 100644 index 0000000000..b9b79213d4 --- /dev/null +++ b/crates/openshell-supervisor-network/src/policy_dns/wire.rs @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! DNS request and response wire handling. + +use super::resolver::{AddressFamily, MAX_DNS_MESSAGE_BYTES, ResolveError, TrustedResolver}; +use super::{PolicyDnsError, PolicyDnsService}; +use hickory_proto::op::{Message, MessageType, OpCode, ResponseCode}; +use hickory_proto::rr::rdata::{A, AAAA}; +use hickory_proto::rr::{DNSClass, RData, Record, RecordType}; +use std::net::IpAddr; +use std::time::Instant; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum WireError { + #[error("DNS response encoding failed")] + Encode, + #[error("DNS-over-TCP frame is invalid")] + InvalidTcpFrame, +} + +/// Handle one DNS datagram without binding a runtime listener. +pub(crate) async fn handle_udp_query( + service: &PolicyDnsService, + wire: &[u8], +) -> Result, WireError> { + handle_udp_query_with_ipv6(service, wire, true).await +} + +pub(crate) async fn handle_udp_query_with_ipv6( + service: &PolicyDnsService, + wire: &[u8], + ipv6_egress: bool, +) -> Result, WireError> { + let fallback_id = wire + .get(..2) + .map(|bytes| u16::from_be_bytes([bytes[0], bytes[1]])) + .unwrap_or_default(); + if wire.len() > MAX_DNS_MESSAGE_BYTES { + return encode_message(Message::error_msg( + fallback_id, + OpCode::Query, + ResponseCode::FormErr, + )); + } + let Ok(request) = Message::from_vec(wire) else { + return encode_message(Message::error_msg( + fallback_id, + OpCode::Query, + ResponseCode::FormErr, + )); + }; + let Some((query, family)) = validate_request(&request) else { + let code = if request.metadata.message_type != MessageType::Query + || request.metadata.op_code != OpCode::Query + || request.queries.len() != 1 + { + ResponseCode::FormErr + } else { + ResponseCode::NotImp + }; + return encode_message(response_with_code(&request, code)); + }; + + let raw_name = query.name.to_ascii(); + if family == AddressFamily::Ipv6 && !ipv6_egress { + return encode_message(response_with_code(&request, ResponseCode::NoError)); + } + match service + .answer_query(&raw_name, family, Instant::now()) + .await + { + Ok(answer) => { + let rdata = match answer.address { + IpAddr::V4(address) if family == AddressFamily::Ipv4 => RData::A(A(address)), + IpAddr::V6(address) if family == AddressFamily::Ipv6 => RData::AAAA(AAAA(address)), + _ => return encode_message(response_with_code(&request, ResponseCode::ServFail)), + }; + let mut response = response_with_code(&request, ResponseCode::NoError); + response.answers.push(Record::from_rdata( + query.name.clone(), + u32::try_from(answer.ttl.as_secs()).unwrap_or(u32::MAX), + rdata, + )); + encode_message(response) + } + Err(PolicyDnsError::Ineligible | PolicyDnsError::TrustedGatewayUnavailable) => { + encode_message(response_with_code(&request, ResponseCode::Refused)) + } + Err(PolicyDnsError::Resolver(ResolveError::NxDomain)) => { + encode_message(response_with_code(&request, ResponseCode::NXDomain)) + } + Err(PolicyDnsError::Resolver(ResolveError::NoData)) => { + encode_message(response_with_code(&request, ResponseCode::NoError)) + } + Err(PolicyDnsError::InvalidName) => { + encode_message(response_with_code(&request, ResponseCode::FormErr)) + } + Err( + PolicyDnsError::Resolver(_) + | PolicyDnsError::NoValidAddress + | PolicyDnsError::StalePolicy + | PolicyDnsError::Publish(_) + | PolicyDnsError::Policy(_), + ) => encode_message(response_with_code(&request, ResponseCode::ServFail)), + } +} + +/// Handle exactly one length-prefixed DNS-over-TCP message. +pub(crate) async fn handle_tcp_query( + service: &PolicyDnsService, + frame: &[u8], +) -> Result, WireError> { + handle_tcp_query_with_ipv6(service, frame, true).await +} + +pub(crate) async fn handle_tcp_query_with_ipv6( + service: &PolicyDnsService, + frame: &[u8], + ipv6_egress: bool, +) -> Result, WireError> { + let declared = frame + .get(..2) + .map(|bytes| usize::from(u16::from_be_bytes([bytes[0], bytes[1]]))) + .ok_or(WireError::InvalidTcpFrame)?; + if declared > MAX_DNS_MESSAGE_BYTES || frame.len() != declared + 2 { + return Err(WireError::InvalidTcpFrame); + } + let response = handle_udp_query_with_ipv6(service, &frame[2..], ipv6_egress).await?; + let length = u16::try_from(response.len()).map_err(|_| WireError::Encode)?; + let mut framed = Vec::with_capacity(response.len() + 2); + framed.extend_from_slice(&length.to_be_bytes()); + framed.extend_from_slice(&response); + Ok(framed) +} + +fn validate_request(request: &Message) -> Option<(&hickory_proto::op::Query, AddressFamily)> { + if request.metadata.message_type != MessageType::Query + || request.metadata.op_code != OpCode::Query + || request.queries.len() != 1 + { + return None; + } + let query = request.queries.first()?; + if query.query_class != DNSClass::IN { + return None; + } + let family = match query.query_type { + RecordType::A => AddressFamily::Ipv4, + RecordType::AAAA => AddressFamily::Ipv6, + _ => return None, + }; + Some((query, family)) +} + +fn response_with_code(request: &Message, code: ResponseCode) -> Message { + let mut response = Message::response(request.metadata.id, request.metadata.op_code); + response.metadata.recursion_desired = request.metadata.recursion_desired; + response.metadata.recursion_available = true; + response.metadata.response_code = code; + response.queries.clone_from(&request.queries); + response +} + +fn encode_message(message: Message) -> Result, WireError> { + message.to_vec().map_err(|_| WireError::Encode) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opa::OpaEngine; + use crate::policy_dns::name::NormalizedName; + use crate::policy_dns::resolver::TrustedAnswer; + use crate::policy_dns::store::{ResolvedEndpointStore, StoreConfig, SyntheticPools}; + use hickory_proto::op::Query; + use hickory_proto::rr::Name; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + struct FakeResolver { + calls: AtomicUsize, + } + + struct NoDataResolver; + + impl TrustedResolver for NoDataResolver { + async fn resolve( + &self, + _name: &NormalizedName, + _family: AddressFamily, + ) -> Result { + Err(ResolveError::NoData) + } + } + + impl TrustedResolver for FakeResolver { + async fn resolve( + &self, + _name: &NormalizedName, + family: AddressFamily, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(TrustedAnswer { + addresses: match family { + AddressFamily::Ipv4 => vec!["8.8.8.8".parse().unwrap()], + AddressFamily::Ipv6 => vec!["2001:4860:4860::8888".parse().unwrap()], + }, + ttl: Duration::from_secs(10), + }) + } + } + + fn service_with_resolver(resolver: R) -> PolicyDnsService { + let yaml = r" +network_policies: + database: + name: database + endpoints: [{ host: db.example, port: 5432, protocol: tcp }] + binaries: [{ path: /usr/bin/psql }] +filesystem_policy: { include_workdir: true, read_only: [], read_write: [] } +landlock: { compatibility: best_effort } +process: { run_as_user: sandbox, run_as_group: sandbox } +"; + let policy = Arc::new( + OpaEngine::from_strings(include_str!("../../data/sandbox-policy.rego"), yaml).unwrap(), + ); + let pools = SyntheticPools::new( + Ipv4Addr::new(198, 18, 0, 1)..=Ipv4Addr::new(198, 18, 0, 4), + "fd00:1::1".parse::().unwrap()..="fd00:1::4".parse::().unwrap(), + ) + .unwrap(); + PolicyDnsService::new( + policy, + resolver, + Arc::new(ResolvedEndpointStore::new( + StoreConfig::new(pools, 8).unwrap(), + )), + None, + ) + } + + fn service() -> PolicyDnsService { + service_with_resolver(FakeResolver { + calls: AtomicUsize::new(0), + }) + } + + fn request(name: &str, record_type: RecordType) -> Vec { + let mut message = Message::new(42, MessageType::Query, OpCode::Query); + message.metadata.recursion_desired = true; + message + .queries + .push(Query::query(Name::from_ascii(name).unwrap(), record_type)); + message.to_vec().unwrap() + } + + #[tokio::test] + async fn udp_and_tcp_queries_return_synthetic_answers() { + let service = service(); + let udp = handle_udp_query(&service, &request("DB.EXAMPLE.", RecordType::A)) + .await + .unwrap(); + let udp_message = Message::from_vec(&udp).unwrap(); + assert_eq!(udp_message.metadata.response_code, ResponseCode::NoError); + assert!(matches!(udp_message.answers[0].data, RData::A(_))); + + let query = request("db.example.", RecordType::A); + let mut frame = Vec::with_capacity(query.len() + 2); + frame.extend_from_slice(&u16::try_from(query.len()).unwrap().to_be_bytes()); + frame.extend_from_slice(&query); + let tcp = handle_tcp_query(&service, &frame).await.unwrap(); + let declared = usize::from(u16::from_be_bytes([tcp[0], tcp[1]])); + assert_eq!(declared, tcp.len() - 2); + assert_eq!( + Message::from_vec(&tcp[2..]).unwrap().metadata.response_code, + ResponseCode::NoError + ); + + let ipv6 = handle_udp_query(&service, &request("db.example.", RecordType::AAAA)) + .await + .unwrap(); + assert!(matches!( + Message::from_vec(&ipv6).unwrap().answers[0].data, + RData::AAAA(_) + )); + } + + #[tokio::test] + async fn ineligible_query_is_refused_without_upstream_call() { + let service = service(); + let wire = handle_udp_query(&service, &request("other.example.", RecordType::A)) + .await + .unwrap(); + assert_eq!( + Message::from_vec(&wire).unwrap().metadata.response_code, + ResponseCode::Refused + ); + assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn eligible_family_without_records_returns_empty_success() { + let service = service_with_resolver(NoDataResolver); + let wire = handle_udp_query(&service, &request("db.example.", RecordType::AAAA)) + .await + .unwrap(); + let response = Message::from_vec(&wire).unwrap(); + assert_eq!(response.metadata.response_code, ResponseCode::NoError); + assert!(response.answers.is_empty()); + } + + #[tokio::test] + async fn runtime_without_ipv6_egress_suppresses_aaaa_without_resolving() { + let service = service(); + let wire = + handle_udp_query_with_ipv6(&service, &request("db.example.", RecordType::AAAA), false) + .await + .unwrap(); + let response = Message::from_vec(&wire).unwrap(); + assert_eq!(response.metadata.response_code, ResponseCode::NoError); + assert!(response.answers.is_empty()); + assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn unsupported_type_is_not_implemented_and_malformed_tcp_is_rejected() { + let service = service(); + let wire = handle_udp_query(&service, &request("db.example.", RecordType::TXT)) + .await + .unwrap(); + assert_eq!( + Message::from_vec(&wire).unwrap().metadata.response_code, + ResponseCode::NotImp + ); + assert!(matches!( + handle_tcp_query(&service, &[0, 10, 1, 2]).await, + Err(WireError::InvalidTcpFrame) + )); + } +} diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index e915c18c9b..ef899b666e 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -24,7 +24,7 @@ pub const POLICY_LOCAL_HOST: &str = "policy.local"; /// Single source of truth: the skill installer writes here, the L7 deny body /// references this path in `next_steps`, and the skill's own documentation /// renders the same path. Changing the location is a one-line update here. -pub const SKILL_PATH: &str = "/etc/openshell/skills/policy_advisor.md"; +pub use openshell_core::container_paths::POLICY_ADVISOR_SKILL_PATH as SKILL_PATH; /// Human-readable guidance for agents that are more likely to follow plain /// instructions than structured next-step JSON alone. @@ -133,6 +133,10 @@ impl PolicyLocalContext { *self.current_policy.write().await = Some(policy); } + pub fn workspace(&self) -> String { + self.workspace_rx.borrow().clone() + } + #[must_use] pub fn agent_proposals(&self) -> AgentProposals { self.agent_proposals.clone() @@ -817,7 +821,8 @@ async fn fetch_chunk_or_404( /// next on the redraft loop — identity (`chunk_id`, `status`), the proposal /// it submitted (`rule_name`, `binary`), the two feedback signals /// (`rejection_reason` from the reviewer, `validation_result` from the -/// gateway prover), and (on /wait) `policy_reloaded` so the agent can tell +/// gateway prover, and `application_error` from complete candidate preflight), +/// plus the review token/candidate hashes and (on /wait) `policy_reloaded` so the agent can tell /// "approved AND the new rule is loaded — safe to retry" from "approved /// but the supervisor hasn't reloaded yet — re-issue /wait or surface to /// user". Display-only proto fields (`hit_count`, `confidence`, `stage`, @@ -834,6 +839,10 @@ fn chunk_state_payload( "binary": chunk.binary, "rejection_reason": chunk.rejection_reason, "validation_result": chunk.validation_result, + "application_error": chunk.application_error, + "review_token": chunk.review_token, + "current_effective_policy_hash": chunk.current_effective_policy_hash, + "candidate_effective_policy_hash": chunk.candidate_effective_policy_hash, }); if timed_out { payload["timed_out"] = serde_json::json!(true); @@ -1052,6 +1061,7 @@ fn policy_chunk_from_add_rule( binary, validation_result: String::new(), rejection_reason: String::new(), + ..Default::default() }) } @@ -1167,6 +1177,8 @@ fn network_endpoint_from_json( allow_encoded_slash: endpoint.allow_encoded_slash, websocket_credential_rewrite: false, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, advisor_proposed: false, // GraphQL persisted-query knobs and path scoping default empty — // agent proposals don't author them today. @@ -1179,6 +1191,8 @@ fn network_endpoint_from_json( credential_signing: String::new(), signing_service: String::new(), signing_region: String::new(), + // policy.local proposals cannot reference a concrete sandbox provider. + credential_binding: None, }) } @@ -1426,6 +1440,7 @@ mod tests { assert_eq!(rule.endpoints[0].port, 443); assert_eq!(rule.endpoints[0].ports, vec![443]); assert_eq!(rule.endpoints[0].protocol, "rest"); + assert!(rule.endpoints[0].advisor_proposed); #[allow(deprecated)] { assert!(rule.binaries[0].harness); @@ -1790,6 +1805,10 @@ mod tests { binary: "/usr/bin/curl".to_string(), rejection_reason: "scope too broad".to_string(), validation_result: "no exfil paths".to_string(), + application_error: "candidate invalid: malformed GraphQL operation".to_string(), + review_token: "review-v1".to_string(), + current_effective_policy_hash: "current-hash".to_string(), + candidate_effective_policy_hash: "candidate-hash".to_string(), ..Default::default() }; let pending = chunk_state_payload(&chunk, false, false); @@ -1797,6 +1816,13 @@ mod tests { assert_eq!(pending["status"], "rejected"); assert_eq!(pending["rejection_reason"], "scope too broad"); assert_eq!(pending["validation_result"], "no exfil paths"); + assert_eq!( + pending["application_error"], + "candidate invalid: malformed GraphQL operation" + ); + assert_eq!(pending["review_token"], "review-v1"); + assert_eq!(pending["current_effective_policy_hash"], "current-hash"); + assert_eq!(pending["candidate_effective_policy_hash"], "candidate-hash"); // timed_out and policy_reloaded only appear when relevant. assert!(pending.get("timed_out").is_none()); assert!( diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 917aa1fc7b..dc2736a4ea 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3,26 +3,34 @@ //! HTTP CONNECT proxy with OPA policy evaluation and process-identity binding. -mod destination; +pub(crate) mod destination; mod egress; mod relay; use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; +#[cfg(target_os = "linux")] +use crate::policy_dns::{MappingLookupError, PolicyEndpointId, ResolvedEndpointStore}; use crate::policy_local::{POLICY_LOCAL_HOST, PolicyLocalContext}; use crate::upstream_proxy::{self, UpstreamProxyConfig}; use miette::{IntoDiagnostic, Result}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::denial::DenialEvent; -use openshell_core::net::{is_always_blocked_ip, is_internal_ip, is_link_local_ip}; +use openshell_core::net::{ + connect_tcp_nodelay_best_effort, is_always_blocked_ip, is_internal_ip, is_link_local_ip, + set_tcp_nodelay_best_effort, +}; use openshell_core::policy::ProxyPolicy; -use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, - NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, AiModel, ApiActivityBuilder, DispositionId, Endpoint, + HttpActivityBuilder, HttpRequest, 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; @@ -56,9 +64,35 @@ const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from_millis(1); const INFERENCE_LOCAL_HOST: &str = "inference.local"; const INFERENCE_LOCAL_PORT: u16 = 443; +const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str = + "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint"; #[cfg(target_os = "linux")] const SIDECAR_SUPERVISOR_TOPOLOGY: &str = "sidecar"; +fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .firewall_rule(policy_name, "credential-binding") + .message(format!( + "Credential use denied: credential is not authorized for {host}:{port}" + )) + .status_detail("credential_endpoint_mismatch") + .build(); + ocsf_emit!(event); + let finding = crate::l7::build_credential_endpoint_mismatch_finding( + policy_name, + host, + None, + "Provider credential endpoint binding mismatch; request denied", + ); + ocsf_emit!(finding); +} + /// Hostnames injected by compute drivers as `/etc/hosts` aliases for the host /// machine. Traffic to these names is eligible for the trusted-gateway SSRF /// exemption when the resolved IP matches the driver-injected value read from @@ -69,6 +103,27 @@ const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.docker.internal", ]; +fn revision_scoped_dynamic_credentials( + snapshot: &ProviderCredentialSnapshot, +) -> std::collections::HashMap { + snapshot + .dynamic_credentials + .iter() + .map(|(key, credential)| { + let scoped_key = key.rsplit_once('\t').map_or_else( + || format!("rev:{}\t{key}", snapshot.revision), + |(endpoint_selector, provider_credential)| { + format!( + "{endpoint_selector}\trev:{}\t{provider_credential}", + snapshot.revision + ) + }, + ); + (scoped_key, credential.clone()) + }) + .collect() +} + /// Cloud instance metadata IPs that are NEVER exempted from SSRF blocking, /// even when they coincidentally match a host-gateway alias resolution. /// This list covers the well-known IMDS endpoints across major cloud providers. @@ -173,11 +228,11 @@ impl InferenceContext { } } -#[derive(Debug)] pub struct ProxyHandle { #[allow(dead_code)] http_addr: Option, join: JoinHandle<()>, + exited_rx: Option>, } impl ProxyHandle { @@ -282,7 +337,13 @@ impl ProxyHandle { ocsf_emit!(event); } + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); let join = tokio::spawn(async move { + // Hold the sender for the lifetime of this task — when the task + // exits (panic, abort, or loop break), the sender drops and the + // receiver fires, notifying the sandbox that the proxy is gone. + let _proxy_exit_guard = exited_tx; + // Wait for the OPA engine's symlink resolution reload to complete // before accepting connections. This prevents requests from // observing a generation transition mid-flight, which would cause @@ -314,6 +375,7 @@ impl ProxyHandle { Ok((stream, _addr)) => { consecutive_resource_errors = 0; consecutive_unknown_errors = 0; + set_tcp_nodelay_best_effort(&stream); let opa = opa_engine.clone(); let cache = identity_cache.clone(); let spid = entrypoint_pid.clone(); @@ -323,13 +385,14 @@ impl ProxyHandle { let proposals = agent_proposals.clone(); let gw = trusted_host_gateway.clone(); let up_proxy = upstream_proxy.clone(); + let credentials = provider_credentials.clone(); let resolver = provider_credentials .as_ref() .and_then(ProviderCredentialState::resolver); let dynamic_credentials = provider_credentials.as_ref().map(|state| { - Arc::new(std::sync::RwLock::new( - state.snapshot().dynamic_credentials.clone(), - )) + Arc::new(std::sync::RwLock::new(revision_scoped_dynamic_credentials( + &state.snapshot(), + ))) }); let dtx = denial_tx.clone(); let atx = activity_tx.clone(); @@ -346,6 +409,7 @@ impl ProxyHandle { proposals, gw, up_proxy, + credentials, resolver, dynamic_credentials, dtx, @@ -364,23 +428,36 @@ impl ProxyHandle { }); } Err(err) => { - let outcome = handle_accept_error( + match classify_accept_error( &err, &mut consecutive_resource_errors, &mut consecutive_unknown_errors, - ); - - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(outcome.severity) - .status(StatusId::Failure) - .message(outcome.message) - .build(); - ocsf_emit!(event); - - match outcome.backoff { - Some(backoff) => tokio::time::sleep(backoff).await, - None => break, + ) { + AcceptAction::Terminal => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message(format!( + "Proxy accept loop exiting on terminal error: {err}", + )) + .build(); + ocsf_emit!(event); + break; + } + AcceptAction::Retry { backoff, severity } => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(severity) + .status(StatusId::Failure) + .message(format!( + "Proxy accept error (retrying in {}ms): {err}", + backoff.as_millis(), + )) + .build(); + ocsf_emit!(event); + tokio::time::sleep(backoff).await; + } } } } @@ -390,6 +467,7 @@ impl ProxyHandle { Ok(Self { http_addr: Some(local_addr), join, + exited_rx: Some(exited_rx), }) } @@ -397,6 +475,10 @@ impl ProxyHandle { pub const fn http_addr(&self) -> Option { self.http_addr } + + pub fn take_exit_receiver(&mut self) -> Option> { + self.exited_rx.take() + } } impl Drop for ProxyHandle { @@ -405,192 +487,665 @@ impl Drop for ProxyHandle { } } -fn emit_activity(tx: &Option, denied: bool, deny_group: &'static str) { - if let Some(tx) = tx { - let _ = try_record_activity(tx, denied, deny_group); - } +/// RAII handle for transparent TCP accept loops. +#[cfg(target_os = "linux")] +pub(crate) struct TransparentTcpHandle { + joins: Vec>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AcceptErrorClass { - Transient, - Terminal, - Unknown, +#[cfg(target_os = "linux")] +impl TransparentTcpHandle { + #[allow(clippy::too_many_arguments)] + pub(crate) fn start( + listeners: Vec, + store: Arc, + opa_engine: Arc, + identity_cache: Arc, + entrypoint_pid: Arc, + agent_proposals: openshell_core::proposals::AgentProposals, + denial_tx: Option>, + activity_tx: Option, + upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs, + engine_ready: tokio::sync::watch::Receiver, + ) -> Result { + let upstream_proxy = Arc::new( + UpstreamProxyConfig::from_args(upstream_proxy_args) + .map_err(|error| miette::miette!(error))?, + ); + let mut joins = Vec::with_capacity(listeners.len()); + for listener in listeners { + let store = store.clone(); + let engine = opa_engine.clone(); + let cache = identity_cache.clone(); + let pid = entrypoint_pid.clone(); + let proposals = agent_proposals.clone(); + let denial_tx = denial_tx.clone(); + let activity_tx = activity_tx.clone(); + let upstream_proxy = upstream_proxy.clone(); + let mut engine_ready = engine_ready.clone(); + joins.push(tokio::spawn(async move { + if tokio::time::timeout( + std::time::Duration::from_secs(15), + engine_ready.wait_for(|ready| *ready), + ) + .await + .is_err() + { + warn!( + "Engine readiness signal not received within 15s; proceeding with transparent TCP accept loop" + ); + } + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + set_tcp_nodelay_best_effort(&stream); + let store = store.clone(); + let engine = engine.clone(); + let cache = cache.clone(); + let pid = pid.clone(); + let proposals = proposals.clone(); + let denial_tx = denial_tx.clone(); + let activity_tx = activity_tx.clone(); + let upstream_proxy = upstream_proxy.clone(); + tokio::spawn(async move { + if let Err(error) = handle_transparent_tcp_connection( + stream, + store, + engine, + cache, + pid, + proposals, + denial_tx, + activity_tx, + upstream_proxy, + ) + .await + { + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(format!("Transparent TCP connection error: {error}")) + .build() + ); + } + }); + } + })); + } + Ok(Self { joins }) + } } -#[cfg(unix)] -fn classify_accept_error(err: &std::io::Error) -> AcceptErrorClass { - match err.raw_os_error() { - Some( - libc::EMFILE - | libc::ENFILE - | libc::ENOBUFS - | libc::ENOMEM - | libc::ECONNABORTED - | libc::ECONNRESET - | libc::EINTR - | libc::ENETDOWN - | libc::EPROTO - | libc::ENOPROTOOPT - | libc::EHOSTDOWN - | libc::EHOSTUNREACH - | libc::EOPNOTSUPP - | libc::ENETUNREACH - | libc::ENOSR - | libc::ESOCKTNOSUPPORT - | libc::EPROTONOSUPPORT - | libc::ETIMEDOUT, - ) => AcceptErrorClass::Transient, - #[cfg(target_os = "linux")] - Some(libc::ENONET) => AcceptErrorClass::Transient, - Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK) => AcceptErrorClass::Terminal, - _ => AcceptErrorClass::Unknown, - } -} - -#[cfg(not(unix))] -fn classify_accept_error(_err: &std::io::Error) -> AcceptErrorClass { - AcceptErrorClass::Unknown -} - -#[cfg(unix)] -fn is_resource_pressure_error(err: &std::io::Error) -> bool { - matches!( - err.raw_os_error(), - Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR) - ) +#[cfg(target_os = "linux")] +impl Drop for TransparentTcpHandle { + fn drop(&mut self) { + for join in &self.joins { + join.abort(); + } + } } -#[cfg(not(unix))] -fn is_resource_pressure_error(_err: &std::io::Error) -> bool { - false -} +#[cfg(target_os = "linux")] +#[allow(clippy::too_many_arguments)] +async fn handle_transparent_tcp_connection( + mut client: TcpStream, + store: Arc, + opa_engine: Arc, + identity_cache: Arc, + entrypoint_pid: Arc, + agent_proposals: openshell_core::proposals::AgentProposals, + denial_tx: Option>, + activity_tx: Option, + upstream_proxy: Arc>, +) -> Result<()> { + let workload_addr = client.peer_addr().into_diagnostic()?; + let original = original_destination(&client).into_diagnostic()?; + let current_generation = opa_engine.current_generation(); + let mapping = match store.lookup( + original.ip(), + original.port(), + current_generation, + std::time::Instant::now(), + ) { + Ok(mapping) => mapping, + Err(error) => { + emit_transparent_mapping_denial(workload_addr, original, error); + emit_activity(&activity_tx, true, "transparent_tcp_mapping"); + return Ok(()); + } + }; + let host = mapping.record.normalized_name.as_str().to_string(); + let port = original.port(); + let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, original); + let intent = EgressIntent::transparent_tcp(host.clone(), port); + let engine = opa_engine.clone(); + let cache = identity_cache.clone(); + let pid = entrypoint_pid.clone(); + let decision = tokio::task::spawn_blocking(move || { + authorize_egress_intent(connection, &engine, &cache, &pid, intent) + }) + .await + .map_err(|error| miette::miette!("identity resolution task panicked: {error}"))?; -const ACCEPT_BACKOFF_BASE_MS: u64 = 100; -const ACCEPT_BACKOFF_MAX_MS: u64 = 5_000; -const MAX_CONSECUTIVE_UNKNOWN_ERRORS: u32 = 5; + if let NetworkAction::Deny { reason } = &decision.action { + emit_transparent_policy_denial(&decision, workload_addr, &host, port); + emit_denial( + &denial_tx, + &host, + port, + decision + .binary + .as_ref() + .map_or("-", |path| path.to_str().unwrap_or("-")), + &decision, + reason, + "transparent-tcp", + ); + emit_activity(&activity_tx, true, "transparent_tcp_policy"); + return Ok(()); + } -fn accept_backoff(consecutive_errors: u32) -> std::time::Duration { - let exponent = consecutive_errors.saturating_sub(1).min(7); - let ms = ACCEPT_BACKOFF_BASE_MS - .saturating_mul(1u64 << exponent) - .min(ACCEPT_BACKOFF_MAX_MS); - std::time::Duration::from_millis(ms) -} + // Authorization may race a policy reload. Re-pin the exact generation + // that produced the decision, then reacquire the DNS mapping against that + // generation before correlating endpoint identity or constructing a + // connector. This prevents combining an old DNS answer with a newer + // policy decision (or vice versa). + let Ok(generation_guard) = + relay::pin_policy_generation(&opa_engine, decision.policy_generation) + else { + emit_transparent_mapping_denial(workload_addr, original, MappingLookupError::StalePolicy); + emit_activity(&activity_tx, true, "transparent_tcp_mapping"); + return Ok(()); + }; + let mapping = match store.lookup( + original.ip(), + original.port(), + decision.policy_generation, + std::time::Instant::now(), + ) { + Ok(mapping) => mapping, + Err(error) => { + emit_transparent_mapping_denial(workload_addr, original, error); + emit_activity(&activity_tx, true, "transparent_tcp_mapping"); + return Ok(()); + } + }; -struct AcceptErrorOutcome { - severity: SeverityId, - message: String, - backoff: Option, -} + let endpoint_id = decision + .endpoint + .matched_endpoints + .iter() + .map(|endpoint| PolicyEndpointId { + policy_name: endpoint.policy_name.clone(), + endpoint_index: endpoint.endpoint_index, + }) + .find(|candidate| mapping.endpoint_ids().any(|mapped| mapped == candidate)); + let Some(endpoint_id) = endpoint_id else { + let reason = "authorized endpoint did not match DNS correlation"; + emit_transparent_policy_denial(&decision, workload_addr, &host, port); + emit_denial( + &denial_tx, + &host, + port, + decision + .binary + .as_ref() + .map_or("-", |path| path.to_str().unwrap_or("-")), + &decision, + reason, + "transparent-tcp", + ); + emit_activity(&activity_tx, true, "transparent_tcp_policy"); + return Ok(()); + }; -fn handle_accept_error( - err: &std::io::Error, - consecutive_resource_errors: &mut u32, - consecutive_unknown_errors: &mut u32, -) -> AcceptErrorOutcome { - let class = classify_accept_error(err); - - match class { - AcceptErrorClass::Terminal => AcceptErrorOutcome { - severity: SeverityId::High, - message: format!("Proxy accept error (terminal, exiting): {err}"), - backoff: None, - }, - AcceptErrorClass::Unknown => { - *consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1); - if *consecutive_unknown_errors > MAX_CONSECUTIVE_UNKNOWN_ERRORS { - AcceptErrorOutcome { - severity: SeverityId::High, - message: format!( - "Proxy accept error (exceeded {MAX_CONSECUTIVE_UNKNOWN_ERRORS} retries, exiting): {err}" - ), - backoff: None, - } - } else { - let backoff = accept_backoff(*consecutive_unknown_errors); - AcceptErrorOutcome { - severity: SeverityId::Medium, - message: format!( - "Proxy accept error (retry {}/{MAX_CONSECUTIVE_UNKNOWN_ERRORS} in {}ms): {err}", - *consecutive_unknown_errors, - backoff.as_millis(), - ), - backoff: Some(backoff), - } - } + let connector = mapping.connector_for(&endpoint_id).await.map_err(|error| { + miette::miette!("transparent TCP pinned destination is invalid: {error}") + })?; + let mut ctx = relay::http_context( + &decision, + None, + None, + activity_tx.clone(), + None, + agent_proposals, + // The transparent TCP path carries no PolicyLocalContext, so no + // workspace is available here; matches the CONNECT path default when + // policy-local context is absent. + String::new(), + ); + let middleware_gate = middleware_uninspectable_gate(&opa_engine, &ctx)?; + if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::Deny { + crate::l7::middleware::emit_middleware_uninspectable(&ctx, "transparent tcp", true); + return Ok(()); + } + if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::BypassWithFinding { + crate::l7::middleware::emit_middleware_uninspectable(&ctx, "transparent tcp", false); + } + let approved_real_ip_candidates = connector.addrs().to_vec(); + generation_guard.ensure_current()?; + let mut upstream = + dial_transparent_upstream(&upstream_proxy, &host, port, &approved_real_ip_candidates) + .await + .into_diagnostic()?; + let upstream_socket_peer = upstream.peer_addr().into_diagnostic()?; + let (connected_real_destination, dial_mode) = match upstream.connect_target() { + Some(upstream_proxy::ConnectTarget::Ip(ip)) => ( + Some(SocketAddr::new(ip, port)), + "upstream_proxy_validated_ip", + ), + Some(upstream_proxy::ConnectTarget::Hostname) => { + // Transparent TCP authorization is correlated to the resolver's + // validated address set. A hostname-mode CONNECT would make the + // corporate proxy resolve again and break that binding. Treat a + // future invariant regression as an audited denial, not a panic. + emit_transparent_policy_denial(&decision, workload_addr, &host, port); + emit_denial( + &denial_tx, + &host, + port, + decision + .binary + .as_ref() + .map_or("-", |path| path.to_str().unwrap_or("-")), + &decision, + "upstream proxy did not preserve the validated IP target", + "transparent-tcp", + ); + emit_activity(&activity_tx, true, "transparent_tcp_destination"); + return Ok(()); } - AcceptErrorClass::Transient => { - *consecutive_unknown_errors = 0; - if is_resource_pressure_error(err) { - *consecutive_resource_errors = consecutive_resource_errors.saturating_add(1); - let backoff = accept_backoff(*consecutive_resource_errors); - AcceptErrorOutcome { - severity: SeverityId::Medium, - message: format!( - "Proxy accept error (retrying in {}ms): {err}", - backoff.as_millis(), - ), - backoff: Some(backoff), - } - } else { - AcceptErrorOutcome { - severity: SeverityId::Low, - message: format!("Proxy accept error (retrying in 100ms): {err}"), - backoff: Some(std::time::Duration::from_millis(100)), - } - } + None => (Some(upstream_socket_peer), "direct"), + }; + generation_guard.ensure_current()?; + ctx.request_default_port = None; + let policy_name = match &decision.action { + NetworkAction::Allow { matched_policy } => matched_policy.as_deref().unwrap_or("-"), + NetworkAction::Deny { .. } => "-", + }; + let binary = decision + .binary + .as_ref() + .map_or_else(|| "-".to_string(), |path| path.display().to_string()); + let pid = decision + .binary_pid + .map_or_else(|| "-".to_string(), |pid| pid.to_string()); + ocsf_emit!(build_transparent_tcp_allow_ocsf_event( + TransparentTcpAllowAudit { + workload: workload_addr, + synthetic_destination: original, + normalized_domain: &host, + approved_real_ip_candidates: &approved_real_ip_candidates, + connected_real_destination, + upstream_socket_peer, + dial_mode, + mapping_id: mapping.record.mapping_id, + mapping_generation: mapping.record.mapping_generation, + mapping_policy_generation: mapping.record.policy_generation, + authorization_policy_generation: decision.policy_generation, + binary: &binary, + pid: &pid, + policy_name, } - } + )); + emit_activity(&activity_tx, false, "transparent_tcp"); + relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await } -fn l7_inspection_active(l7_route: Option<&L7RouteSnapshot>) -> bool { - l7_route.is_some_and(|route| !route.configs.is_empty()) +#[cfg(any(target_os = "linux", test))] +struct TransparentTcpAllowAudit<'a> { + workload: SocketAddr, + synthetic_destination: SocketAddr, + normalized_domain: &'a str, + approved_real_ip_candidates: &'a [SocketAddr], + connected_real_destination: Option, + upstream_socket_peer: SocketAddr, + dial_mode: &'a str, + mapping_id: uuid::Uuid, + mapping_generation: u64, + mapping_policy_generation: u64, + authorization_policy_generation: u64, + binary: &'a str, + pid: &'a str, + policy_name: &'a str, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TunnelProtocol { - Tls, - Http1, - H2cPriorKnowledge, - Unsupported, +#[cfg(any(target_os = "linux", test))] +fn build_transparent_tcp_allow_ocsf_event( + audit: TransparentTcpAllowAudit<'_>, +) -> openshell_ocsf::OcsfEvent { + let logical_destination = format!( + "{}:{}", + audit.normalized_domain, + audit.synthetic_destination.port() + ); + let mapping_id = audit.mapping_id.to_string(); + let actual_target = audit + .connected_real_destination + .map_or_else(|| "proxy-resolved".to_string(), |target| target.to_string()); + let message = format!( + "Transparent TCP mapping_id={mapping_id} synthetic={} real={actual_target}", + audit.synthetic_destination, + ); + let approved_real_ip_candidates = audit + .approved_real_ip_candidates + .iter() + .map(ToString::to_string) + .collect::>(); + let mut builder = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .dst_endpoint(Endpoint::from_domain( + audit.normalized_domain, + audit.synthetic_destination.port(), + )) + .src_endpoint_addr(audit.workload.ip(), audit.workload.port()) + .actor_process(Process::from_bypass(audit.binary, audit.pid, "")) + .firewall_rule(audit.policy_name, "opa") + .unmapped("matched_policy", audit.policy_name) + .unmapped("normalized_domain", audit.normalized_domain) + .unmapped("logical_destination", logical_destination) + .unmapped( + "synthetic_destination", + audit.synthetic_destination.to_string(), + ) + .unmapped( + "approved_real_ip_candidates", + serde_json::json!(approved_real_ip_candidates), + ) + .unmapped( + "upstream_socket_peer", + audit.upstream_socket_peer.to_string(), + ) + .unmapped("dial_mode", audit.dial_mode) + .unmapped("mapping_id", mapping_id) + .unmapped("mapping_generation", audit.mapping_generation) + .unmapped("policy_generation", audit.mapping_policy_generation) + .unmapped("mapping_policy_generation", audit.mapping_policy_generation) + .unmapped( + "authorization_policy_generation", + audit.authorization_policy_generation, + ) + .message(message) + .status_detail("transparent_tcp_allowed"); + if let Some(destination) = audit.connected_real_destination { + builder = builder.unmapped("connected_real_destination", destination.to_string()); + } + builder.build() } -fn classify_tunnel_protocol(peek: &[u8]) -> TunnelProtocol { - if crate::l7::tls::looks_like_tls(peek) { - return TunnelProtocol::Tls; - } - if crate::l7::rest::looks_like_http(peek) { - return TunnelProtocol::Http1; +#[cfg(target_os = "linux")] +fn original_destination(stream: &TcpStream) -> std::io::Result { + use std::os::fd::AsRawFd; + let fd = stream.as_raw_fd(); + 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), + )); + } } - if crate::l7::rest::looks_like_http2_prior_knowledge(peek) { - return TunnelProtocol::H2cPriorKnowledge; + #[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()); + } + Ok(SocketAddr::new( + IpAddr::V6(std::net::Ipv6Addr::from(address.sin6_addr.s6_addr)), + u16::from_be(address.sin6_port), + )) } - TunnelProtocol::Unsupported -} - -fn could_be_tls_prefix(peek: &[u8]) -> bool { - matches!(peek, [0x16] | [0x16, 0x03]) -} - -fn could_be_supported_tunnel_protocol_prefix(peek: &[u8]) -> bool { - could_be_tls_prefix(peek) - || crate::l7::rest::could_be_http_request_prefix(peek) - || crate::l7::rest::could_be_http2_prior_knowledge_prefix(peek) } -/// Why tunnel payload inspection is mandatory for a connection, in message -/// precedence order: an L7-configured endpoint owns the wording even when a -/// fail-closed middleware chain also matches. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InspectionRequirement { - None, - L7Route, - RequiredMiddleware, +#[cfg(target_os = "linux")] +fn emit_transparent_mapping_denial( + workload: SocketAddr, + original: SocketAddr, + error: MappingLookupError, +) { + let detail = match error { + MappingLookupError::Missing => "transparent_tcp_mapping_missing", + MappingLookupError::Expired => "transparent_tcp_mapping_expired", + MappingLookupError::StalePolicy => "transparent_tcp_mapping_stale_policy", + MappingLookupError::PortMismatch => "transparent_tcp_port_mismatch", + MappingLookupError::EndpointMismatch + | MappingLookupError::InvalidMapping + | MappingLookupError::LockPoisoned => "transparent_tcp_destination_denied", + }; + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_ip(original.ip(), original.port())) + .src_endpoint_addr(workload.ip(), workload.port()) + .message(format!("Transparent TCP denied: {error}")) + .status_detail(detail) + .build() + ); } -fn inspection_requirement( - should_inspect_l7: bool, +#[cfg(target_os = "linux")] +fn emit_transparent_policy_denial( + decision: &EgressDecision, + workload: SocketAddr, + host: &str, + port: u16, +) { + let status_detail = if matches!(decision.action, NetworkAction::Deny { .. }) { + "transparent_tcp_identity_denied" + } else { + "transparent_tcp_destination_denied" + }; + let binary = decision + .binary + .as_ref() + .map_or_else(|| "-".to_string(), |path| path.display().to_string()); + let pid = decision + .binary_pid + .map_or_else(|| "-".to_string(), |pid| pid.to_string()); + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .src_endpoint_addr(workload.ip(), workload.port()) + .actor_process(Process::from_bypass(&binary, &pid, "-")) + .firewall_rule("-", "opa") + .message(format!("Transparent TCP denied {host}:{port}")) + .status_detail(status_detail) + .build() + ); +} + +const MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS: u32 = 10; + +#[derive(Debug, PartialEq)] +enum AcceptAction { + Terminal, + Retry { + backoff: std::time::Duration, + severity: SeverityId, + }, +} + +fn classify_accept_error( + err: &std::io::Error, + consecutive_resource_errors: &mut u32, + consecutive_unknown_errors: &mut u32, +) -> AcceptAction { + #[cfg(unix)] + if matches!( + err.raw_os_error(), + Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK) + ) { + return AcceptAction::Terminal; + } + + #[cfg(unix)] + if matches!( + err.raw_os_error(), + Some( + libc::EMFILE + | libc::ENFILE + | libc::ENOBUFS + | libc::ENOMEM + | libc::ECONNABORTED + | libc::ECONNRESET + | libc::EINTR + | libc::ENETDOWN + | libc::EPROTO + | libc::ENOPROTOOPT + | libc::EHOSTDOWN + | libc::EHOSTUNREACH + | libc::EOPNOTSUPP + | libc::ENETUNREACH + | libc::ENOSR + | libc::ESOCKTNOSUPPORT + | libc::EPROTONOSUPPORT + | libc::ETIMEDOUT + ) + ) { + *consecutive_unknown_errors = 0; + + #[cfg(unix)] + let is_resource_pressure = matches!( + err.raw_os_error(), + Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR) + ); + #[cfg(not(unix))] + let is_resource_pressure = false; + + if is_resource_pressure { + *consecutive_resource_errors = consecutive_resource_errors.saturating_add(1); + let backoff_ms = 100u64 + .saturating_mul(1u64 << (*consecutive_resource_errors).min(7).saturating_sub(1)) + .min(5_000); + return AcceptAction::Retry { + backoff: std::time::Duration::from_millis(backoff_ms), + severity: SeverityId::Medium, + }; + } + + *consecutive_resource_errors = 0; + return AcceptAction::Retry { + backoff: std::time::Duration::from_millis(100), + severity: SeverityId::Low, + }; + } + + #[cfg(unix)] + #[cfg(target_os = "linux")] + if matches!(err.raw_os_error(), Some(libc::ENONET)) { + *consecutive_unknown_errors = 0; + *consecutive_resource_errors = 0; + return AcceptAction::Retry { + backoff: std::time::Duration::from_millis(100), + severity: SeverityId::Low, + }; + } + + *consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1); + if *consecutive_unknown_errors >= MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + return AcceptAction::Terminal; + } + AcceptAction::Retry { + backoff: std::time::Duration::from_millis(100), + severity: SeverityId::Low, + } +} + +fn emit_activity(tx: &Option, denied: bool, deny_group: &'static str) { + if let Some(tx) = tx { + let _ = try_record_activity(tx, denied, deny_group); + } +} + +fn l7_inspection_active(l7_route: Option<&L7RouteSnapshot>) -> bool { + l7_route.is_some_and(|route| !route.configs.is_empty()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TunnelProtocol { + Tls, + Http1, + H2cPriorKnowledge, + Unsupported, +} + +fn classify_tunnel_protocol(peek: &[u8]) -> TunnelProtocol { + if crate::l7::tls::looks_like_tls(peek) { + return TunnelProtocol::Tls; + } + if crate::l7::rest::looks_like_http(peek) { + return TunnelProtocol::Http1; + } + if crate::l7::rest::looks_like_http2_prior_knowledge(peek) { + return TunnelProtocol::H2cPriorKnowledge; + } + TunnelProtocol::Unsupported +} + +fn could_be_tls_prefix(peek: &[u8]) -> bool { + matches!(peek, [0x16] | [0x16, 0x03]) +} + +fn could_be_supported_tunnel_protocol_prefix(peek: &[u8]) -> bool { + could_be_tls_prefix(peek) + || crate::l7::rest::could_be_http_request_prefix(peek) + || crate::l7::rest::could_be_http2_prior_knowledge_prefix(peek) +} + +/// Why tunnel payload inspection is mandatory for a connection, in message +/// precedence order: an L7-configured endpoint owns the wording even when a +/// fail-closed middleware chain also matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InspectionRequirement { + None, + L7Route, + RequiredMiddleware, +} + +fn inspection_requirement( + should_inspect_l7: bool, middleware_gate: crate::l7::middleware::UninspectableTrafficGate, ) -> InspectionRequirement { if should_inspect_l7 { @@ -869,6 +1424,50 @@ fn build_forward_allow_ocsf_event( .build() } +fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(format!("FORWARD parse error for {path}")) + .build() +} + +#[allow(clippy::too_many_arguments)] +fn build_forward_l7_parse_rejection_ocsf_event( + peer_addr: SocketAddr, + method: &str, + host: &str, + port: u16, + path: &str, + binary: &str, + pid: &str, + ancestors: &str, + cmdline: &str, + policy: &str, + detail: &str, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("http", host, path, port), + )) + .dst_endpoint(Endpoint::from_domain(host, port)) + .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) + .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) + .firewall_rule(policy, "l7") + .message(format!( + "FORWARD_L7 denied non-canonical request-target for {method} {host}:{port}{path}" + )) + .status_detail(detail) + .build() +} + #[allow(clippy::too_many_arguments)] fn build_forward_policy_deny_ocsf_event( peer_addr: SocketAddr, @@ -1094,6 +1693,7 @@ async fn handle_tcp_connection( agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, upstream_proxy: Arc>, + provider_credentials: Option, secret_resolver: Option>, dynamic_credentials: Option< Arc< @@ -1163,6 +1763,7 @@ async fn handle_tcp_connection( policy_local_ctx, agent_proposals, trusted_host_gateway, + provider_credentials, secret_resolver, dynamic_credentials, denial_tx.as_ref(), @@ -1171,8 +1772,9 @@ async fn handle_tcp_connection( .await; } - let (host, port) = parse_target(target)?; - let host_lc = host.to_ascii_lowercase(); + let (raw_host, port) = parse_target(target)?; + let host = normalize_host(&raw_host); + let (host_lc, raw_host_lc) = (host.to_ascii_lowercase(), raw_host.to_ascii_lowercase()); if host_lc == INFERENCE_LOCAL_HOST && port == INFERENCE_LOCAL_PORT { respond(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; @@ -1306,7 +1908,7 @@ async fn handle_tcp_connection( } let connect_generation_guard = - match relay::pin_policy_generation(&opa_engine, decision.l4_policy_generation) { + match relay::pin_policy_generation(&opa_engine, decision.policy_generation) { Ok(guard) => guard, Err(error) => { reject_stale_connect_policy( @@ -1327,12 +1929,13 @@ async fn handle_tcp_connection( // allowed_ips validation below — so an internal-address CONNECT still gets // the SSRF 403 and telemetry in degraded state — but before the upstream // connect and before `200 Connection Established`. - hydrate_tls_mode(&opa_engine, &mut decision); + hydrate_tls_mode(&mut decision); let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; + let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); - match hydrate_destination_plan(&opa_engine, &mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_connect_destination( @@ -1362,7 +1965,7 @@ async fn handle_tcp_connection( // Defense-in-depth: resolve DNS and reject connections to internal IPs. let dns_connect_start = std::time::Instant::now(); let connector = match validate_destination(DestinationRequest { - host: &host, + host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, @@ -1432,10 +2035,54 @@ async fn handle_tcp_connection( return Ok(()); } + if credential_guard.blocks_connect() { + const DETAIL: &str = + "credentialed endpoint requires L7 inspection; raw tunnel is not explicitly allowed"; + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint_addr(workload_addr.ip(), workload_addr.port()) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), + ) + .firewall_rule(policy_str, "credentials") + .message(format!( + "CONNECT refused for {host_lc}:{port}: uninspected credential traffic" + )) + .status_detail(DETAIL) + .build(); + ocsf_emit!(event); + crate::l7::emit_uninspected_credential_finding( + &host_lc, + policy_str, + if effective_tls_skip { "tls-skip" } else { "l4" }, + ); + emit_activity_simple(activity_tx.as_ref(), true, "uninspected_credentials"); + emit_denial( + &denial_tx, + &host_lc, + port, + &binary_str, + &decision, + DETAIL, + "connect-uninspected-credentials", + ); + respond( + &mut client, + &build_json_error_response(403, "Forbidden", "uninspected_credentials", DETAIL), + ) + .await?; + return Ok(()); + } + // CONNECT must use one policy generation from authorization through route - // hydration and relay startup. A later L7 lookup must never make a stale - // L4 allow appear current. - hydrate_l7_route(&opa_engine, &mut decision); + // materialization and relay startup. + hydrate_l7_route(&mut decision); let l7_route = decision.endpoint.l7_route.as_ref(); if let Err(error) = relay::validate_route_generation(l7_route, connect_generation_guard.captured_generation()) @@ -1446,7 +2093,7 @@ async fn handle_tcp_connection( } let upstream_result = tokio::select! { - result = dial_upstream(&upstream_proxy, &host_lc, port, connector.addrs()) => Some(result), + result = dial_upstream(&upstream_proxy, &host_lc, &raw_host_lc, port, connector.addrs()) => Some(result), () = connect_generation_guard.wait_until_stale() => None, }; let Some(upstream_result) = upstream_result else { @@ -1500,12 +2147,18 @@ async fn handle_tcp_connection( // gate needs it) and drives the raw-tunnel branch below. // Build request-processing context shared by CONNECT and forward HTTP. - let ctx = relay::http_context( + let workspace = policy_local_ctx + .as_ref() + .map(|ctx| ctx.workspace()) + .unwrap_or_default(); + let mut ctx = relay::http_context( &decision, + provider_credentials, secret_resolver.clone(), activity_tx.clone(), dynamic_credentials.clone(), agent_proposals, + workspace, ); if effective_tls_skip { @@ -1559,6 +2212,7 @@ async fn handle_tcp_connection( if tunnel_protocol == TunnelProtocol::Tls { // TLS detected — terminate unconditionally. if let Some(ref tls) = tls_state { + ctx.request_default_port = Some(443); let tls_result = async { let mut tls_client = crate::l7::tls::tls_terminate_client(client, tls, &host_lc).await?; @@ -1573,7 +2227,7 @@ async fn handle_tcp_connection( relay::relay_http_stream(&mut tls_client, &mut tls_upstream, relay_context).await }; - if let Err(e) = tls_result.await { + if let Err(e) = Box::pin(tls_result).await { if is_benign_relay_error(&e) { debug!( host = %host_lc, @@ -1638,6 +2292,7 @@ async fn handle_tcp_connection( } } else if tunnel_protocol == TunnelProtocol::Http1 { // Plaintext HTTP detected. + ctx.request_default_port = Some(80); let is_l7_relay = l7_route.is_some_and(|route| !route.configs.is_empty()); let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) else { @@ -1972,7 +2627,7 @@ fn authorize_egress_intent( EgressDecision { intent: intent.clone(), action: NetworkAction::Deny { reason }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity, endpoint: EndpointDecision::default(), binary, @@ -2038,13 +2693,13 @@ fn authorize_egress_intent( cmdline_paths: cmdline_paths.clone(), }; - let result = match engine.evaluate_network_action_with_generation(&input) { - Ok((action, generation)) => EgressDecision { + let result = match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { intent: intent.clone(), - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(bin_path), binary_pid: Some(binary_pid), ancestors, @@ -2093,15 +2748,15 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres cmdline_paths: vec![], }; - match engine.evaluate_network_action_with_generation(&input) { - Ok((action, generation)) => EgressDecision { + match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { intent, - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: None, binary_pid: None, ancestors: vec![], @@ -2112,7 +2767,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres action: NetworkAction::Deny { reason: format!("policy evaluation error: {e}"), }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), @@ -2143,7 +2798,7 @@ fn authorize_egress_intent( action: NetworkAction::Deny { reason: "identity binding unavailable on this platform".into(), }, - l4_policy_generation: engine.current_generation(), + policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::UnsupportedPlatform, ), @@ -2299,6 +2954,90 @@ async fn process_inference_keepalive Option { + #[derive(serde::Deserialize)] + struct Req { + model: Option, + } + serde_json::from_slice::(body) + .ok() + .and_then(|r| r.model) + .filter(|m| !m.is_empty()) +} + +/// Extract token usage from an inference response body. +fn extract_usage_from_response(body: &[u8]) -> (Option, Option) { + #[derive(serde::Deserialize)] + struct Resp { + usage: Option, + } + #[derive(serde::Deserialize)] + struct Usage { + prompt_tokens: Option, + completion_tokens: Option, + } + match serde_json::from_slice::(body) { + Ok(r) => { + let u = r.usage.unwrap_or(Usage { + prompt_tokens: None, + completion_tokens: None, + }); + (u.prompt_tokens, u.completion_tokens) + } + Err(_) => (None, None), + } +} + +/// Emit an OCSF API Activity [6003] event with the `ai_operation` profile after an inference call. +#[allow(clippy::too_many_arguments)] +fn emit_ai_inference( + method: &str, + path: &str, + route_model: Option<&str>, + route_provider: Option<&str>, + status: StatusId, + input_tokens: Option, + output_tokens: Option, + latency: std::time::Duration, +) { + let model_name = route_model.unwrap_or("unknown"); + let provider_name = route_provider.unwrap_or("unknown"); + let latency_ms = u64::try_from(latency.as_millis()).unwrap_or(u64::MAX); + + let operation = format!("{method} {path}"); + + let mut builder = ApiActivityBuilder::new(openshell_ocsf::ctx::ctx(), &operation) + .severity(SeverityId::Informational) + .status(status) + .ai_model(AiModel::new(model_name, provider_name)) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("https", INFERENCE_LOCAL_HOST, path, 443), + )) + .dst_endpoint(Endpoint::from_domain( + INFERENCE_LOCAL_HOST, + INFERENCE_LOCAL_PORT, + )) + .unmapped("latency_ms", latency_ms); + + if let Some(t) = input_tokens { + builder = builder.unmapped("input_tokens", t); + } + if let Some(t) = output_tokens { + builder = builder.unmapped("output_tokens", t); + } + + let msg = format!( + "Model call: {model_name} via {provider_name} ({}in, {}out)", + input_tokens.map_or_else(|| "?".to_string(), |t| t.to_string()), + output_tokens.map_or_else(|| "?".to_string(), |t| t.to_string()), + ); + builder = builder.message(msg); + + ocsf_emit!(builder.build()); +} + /// Route a parsed inference request locally via the sandbox router, or deny it. /// /// Returns `Ok(true)` if the request was routed to an inference backend, @@ -2354,6 +3093,16 @@ async fn route_inference_request( // the body on a size-cap or idle-timeout truncation, corrupting a // payload the client parses as one JSON object. Framing is declared per // protocol on the matched pattern. + let _req_model = extract_model_from_request(&request.body); + let normalized_protocol = pattern.protocol.to_ascii_lowercase(); + let selected_route = routes + .iter() + .find(|r| r.protocols.iter().any(|p| p == &normalized_protocol)) + .or_else(|| routes.first()); + let route_model = selected_route.map(|r| r.model.clone()); + let route_endpoint = selected_route.map(|r| r.endpoint.clone()); + let infer_start = std::time::Instant::now(); + if pattern.is_buffered() { match ctx .router @@ -2368,11 +3117,40 @@ async fn route_inference_request( .await { Ok(resp) => { + let (input_tokens, output_tokens) = extract_usage_from_response(&resp.body); + let resp_status = if (200..300).contains(&resp.status) { + StatusId::Success + } else { + StatusId::Failure + }; + emit_ai_inference( + &request.method, + &normalized_path, + resp.route_model.as_deref(), + resp.route_endpoint.as_deref(), + resp_status, + input_tokens, + output_tokens, + infer_start.elapsed(), + ); + let resp_headers = sanitize_inference_response_headers(resp.headers); let response = format_http_response(resp.status, &resp_headers, &resp.body); write_all(tls_client, &response).await?; } - Err(e) => write_inference_router_error(tls_client, &e).await?, + Err(e) => { + emit_ai_inference( + &request.method, + &normalized_path, + route_model.as_deref(), + route_endpoint.as_deref(), + StatusId::Failure, + None, + None, + infer_start.elapsed(), + ); + write_inference_router_error(tls_client, &e).await?; + } } return Ok(true); } @@ -2395,6 +3173,9 @@ async fn route_inference_request( format_sse_error, }; + let stream_route_model = resp.route_model.take(); + let stream_route_endpoint = resp.route_endpoint.take(); + let resp_headers = sanitize_inference_response_headers( std::mem::take(&mut resp.headers).into_iter().collect(), ); @@ -2411,7 +3192,9 @@ async fn route_inference_request( // coalesce the framing header + data + trailer into a single // write_all call, reducing the number of TLS records per chunk // from 3 to 1 while preserving incremental delivery. + let resp_status_ok = (200..300).contains(&resp.status); let mut total_bytes: usize = 0; + let mut stream_failed = false; loop { match tokio::time::timeout(CHUNK_IDLE_TIMEOUT, resp.next_chunk()).await { Ok(Ok(Some(chunk))) => { @@ -2426,6 +3209,7 @@ async fn route_inference_request( "response truncated: exceeded maximum streaming body size", ); let _ = write_all(tls_client, &format_chunk(&err)).await; + stream_failed = true; break; } let encoded = format_chunk(&chunk); @@ -2446,6 +3230,7 @@ async fn route_inference_request( ocsf_emit!(event); let err = format_sse_error("response truncated: upstream read error"); let _ = write_all(tls_client, &format_chunk(&err)).await; + stream_failed = true; break; } Err(_) => { @@ -2463,6 +3248,7 @@ async fn route_inference_request( let err = format_sse_error("response truncated: chunk idle timeout exceeded"); let _ = write_all(tls_client, &format_chunk(&err)).await; + stream_failed = true; break; } } @@ -2470,8 +3256,37 @@ async fn route_inference_request( // Terminate the chunked stream. write_all(tls_client, format_chunk_terminator()).await?; + + // Emit API Activity for the completed streaming call. + let stream_status = if stream_failed || !resp_status_ok { + StatusId::Failure + } else { + StatusId::Success + }; + emit_ai_inference( + &request.method, + &normalized_path, + stream_route_model.as_deref(), + stream_route_endpoint.as_deref(), + stream_status, + None, + None, + infer_start.elapsed(), + ); + } + Err(e) => { + emit_ai_inference( + &request.method, + &normalized_path, + route_model.as_deref(), + route_endpoint.as_deref(), + StatusId::Failure, + None, + None, + infer_start.elapsed(), + ); + write_inference_router_error(tls_client, &e).await?; } - Err(e) => write_inference_router_error(tls_client, &e).await?, } Ok(true) } else { @@ -2635,27 +3450,25 @@ async fn reject_stale_connect_policy( /// /// Returns `Some(L7EndpointConfig)` if the matched endpoint has L7 config (protocol field), /// `None` for L4-only endpoints. -fn hydrate_l7_route(engine: &OpaEngine, decision: &mut EgressDecision) { +fn hydrate_l7_route(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; - decision.endpoint.l7_route = query_l7_route_snapshot(engine, decision, &host, port); + decision.endpoint.l7_route = query_l7_route_snapshot(decision, &host, port); } -fn hydrate_tls_mode(engine: &OpaEngine, decision: &mut EgressDecision) { +fn hydrate_tls_mode(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; - decision.endpoint.tls_mode = query_tls_mode(engine, decision, &host, port); + decision.endpoint.tls_mode = query_tls_mode(decision, &host, port); } fn hydrate_destination_plan( - engine: &OpaEngine, decision: &mut EgressDecision, trusted_host_gateway: Option, ) -> std::result::Result<(), DestinationDenial> { let host = decision.intent.destination.host.clone(); - let port = decision.intent.destination.port; - let raw_allowed_ips = query_allowed_ips(engine, decision, &host, port); - let exact_declared_host = query_exact_declared_endpoint_host(engine, decision, &host, port); + let raw_allowed_ips = query_allowed_ips(decision); + let exact_declared_host = decision.endpoint.exact_declared_host; let plan = build_validation_plan( &host, &host.to_ascii_lowercase(), @@ -2668,7 +3481,6 @@ fn hydrate_destination_plan( } fn query_l7_route_snapshot( - engine: &OpaEngine, decision: &EgressDecision, host: &str, port: u16, @@ -2682,47 +3494,28 @@ fn query_l7_route_snapshot( return None; } - let input = crate::opa::NetworkInput { - host: host.to_string(), + let configs: Vec<_> = decision + .endpoint + .policy_configs + .iter() + .filter_map(crate::l7::parse_l7_config) + .map(|config| L7ConfigSnapshot { config }) + .collect(); + if configs.is_empty() { + return None; + } + debug!( + host, port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_endpoint_configs_with_generation(&input) { - Ok((vals, generation)) => { - let configs: Vec<_> = vals - .into_iter() - .filter_map(|val| crate::l7::parse_l7_config(&val)) - .map(|config| L7ConfigSnapshot { config }) - .collect(); - debug!( - host, - port, - generation, - config_count = configs.len(), - "Forward proxy L7 route lookup complete" - ); - Some(L7RouteSnapshot { - configs, - l7_policy_generation: generation, - }) - } - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!("Failed to query L7 endpoint config: {e}")) - .build(); - ocsf_emit!(event); - None - } - } -} + generation = decision.policy_generation, + config_count = configs.len(), + "Egress L7 route materialized from authorization snapshot" + ); + Some(L7RouteSnapshot { + configs, + l7_policy_generation: decision.policy_generation, + }) +} fn select_l7_config_for_path<'a>( configs: &'a [L7ConfigSnapshot], @@ -2737,18 +3530,34 @@ fn select_l7_config_for_path<'a>( /// Query the TLS mode for an endpoint, independent of L7 config. /// /// This extracts `tls: skip` from the endpoint even when no `protocol` is set. -fn query_tls_mode( +fn query_tls_mode(decision: &EgressDecision, _host: &str, _port: u16) -> crate::l7::TlsMode { + let has_policy = match &decision.action { + NetworkAction::Allow { matched_policy } => matched_policy.is_some(), + NetworkAction::Deny { .. } => false, + }; + if !has_policy { + return crate::l7::TlsMode::Auto; + } + + decision + .endpoint + .policy_configs + .first() + .map_or(crate::l7::TlsMode::Auto, crate::l7::parse_tls_mode) +} + +fn query_endpoint_credential_guard( engine: &OpaEngine, decision: &EgressDecision, host: &str, port: u16, -) -> crate::l7::TlsMode { +) -> Result { let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), NetworkAction::Deny { .. } => false, }; if !has_policy { - return crate::l7::TlsMode::Auto; + return Ok(crate::l7::EndpointCredentialGuard::default()); } let input = crate::opa::NetworkInput { @@ -2759,11 +3568,31 @@ fn query_tls_mode( ancestors: decision.ancestors.clone(), cmdline_paths: decision.cmdline_paths.clone(), }; - - match engine.query_endpoint_config(&input) { - Ok(Some(val)) => crate::l7::parse_tls_mode(&val), - _ => crate::l7::TlsMode::Auto, + let values = engine.query_endpoint_credential_guards(&input)?; + let credentialed: Vec<_> = values + .iter() + .map(crate::l7::parse_endpoint_credential_guard) + .filter(|guard| guard.provider_credentialed) + .collect(); + if credentialed.is_empty() { + return Ok(crate::l7::EndpointCredentialGuard::default()); } + + Ok(crate::l7::EndpointCredentialGuard { + provider_credentialed: true, + allow_uninspected_credentials: credentialed + .iter() + .all(|guard| guard.allow_uninspected_credentials), + has_l7_protocol: credentialed.iter().all(|guard| guard.has_l7_protocol), + tls: if credentialed + .iter() + .any(|guard| guard.tls == crate::l7::TlsMode::Skip) + { + crate::l7::TlsMode::Skip + } else { + crate::l7::TlsMode::Auto + }, + }) } /// When the policy endpoint host is a literal IP address, the user has @@ -2793,14 +3622,16 @@ fn implicit_allowed_ips_for_ip_host(host: &str) -> Vec { } fn normalize_host_lookup_key(host: &str) -> &str { - host.strip_prefix('[') + let h = host + .strip_prefix('[') .and_then(|trimmed| trimmed.strip_suffix(']')) - .unwrap_or(host) + .unwrap_or(host); + h.strip_suffix('.').unwrap_or(h) } /// Returns `true` if `host` is one of the well-known driver-injected aliases /// for the host machine (e.g. `host.openshell.internal`). -fn is_host_gateway_alias(host: &str) -> bool { +pub(crate) fn is_host_gateway_alias(host: &str) -> bool { let h = normalize_host_lookup_key(host); HOST_GATEWAY_ALIASES .iter() @@ -2830,7 +3661,7 @@ fn is_cloud_metadata_ip(ip: IpAddr) -> bool { /// entry exists, the entry cannot be parsed, or the mapped IP is a cloud /// metadata address. #[cfg(any(target_os = "linux", test))] -fn detect_trusted_host_gateway() -> Option { +pub(crate) fn detect_trusted_host_gateway() -> Option { let contents = std::fs::read_to_string("/etc/hosts").ok()?; let ips = parse_hosts_file_for_host(&contents, "host.openshell.internal"); @@ -2878,7 +3709,7 @@ fn detect_trusted_host_gateway() -> Option { } #[cfg(not(any(target_os = "linux", test)))] -fn detect_trusted_host_gateway() -> Option { +pub(crate) fn detect_trusted_host_gateway() -> Option { None } @@ -3036,15 +3867,18 @@ async fn resolve_socket_addrs( return Ok(addrs); } - let lookup_host = normalize_host_lookup_key(host); - let addrs: Vec = tokio::net::lookup_host((lookup_host, port)) + let dns_host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + let addrs: Vec = tokio::net::lookup_host((dns_host, port)) .await - .map_err(|e| format!("DNS resolution failed for {lookup_host}:{port}: {e}"))? + .map_err(|e| format!("DNS resolution failed for {dns_host}:{port}: {e}"))? .collect(); if addrs.is_empty() { return Err(format!( - "DNS resolution returned no addresses for {lookup_host}:{port}" + "DNS resolution returned no addresses for {dns_host}:{port}" )); } @@ -3175,6 +4009,7 @@ fn validate_declared_endpoint_resolved_addrs( async fn dial_upstream( upstream_proxy: &Option, host_lc: &str, + raw_host_lc: &str, port: u16, addrs: &[SocketAddr], ) -> std::io::Result { @@ -3184,7 +4019,7 @@ async fn dial_upstream( if cfg.connect_by_hostname() { upstream_proxy::connect_via( endpoint, - host_lc, + raw_host_lc, port, upstream_proxy::ConnectTarget::Hostname, ) @@ -3197,13 +4032,43 @@ async fn dial_upstream( } upstream_proxy::ProxyDecision::Direct(direct_addrs) => { Ok(upstream_proxy::PrefixedStream::without_prefix( - TcpStream::connect(&direct_addrs[..]).await?, + connect_tcp_nodelay_best_effort(&direct_addrs[..]).await?, + )) + } + }; + } + Ok(upstream_proxy::PrefixedStream::without_prefix( + connect_tcp_nodelay_best_effort(addrs).await?, + )) +} + +/// Dial a policy-DNS-correlated transparent TCP destination. +/// +/// Unlike explicit proxy traffic, transparent TCP must never honor the +/// operator hostname-CONNECT compatibility mode: the corporate proxy must +/// receive one of the resolver-approved addresses so it cannot perform a +/// second, policy-bypassing DNS resolution. +#[cfg(target_os = "linux")] +async fn dial_transparent_upstream( + upstream_proxy: &Option, + host_lc: &str, + port: u16, + addrs: &[SocketAddr], +) -> std::io::Result { + if let Some(cfg) = upstream_proxy.as_ref() { + return match cfg.decision(host_lc, port, addrs) { + upstream_proxy::ProxyDecision::Proxy(endpoint) => { + upstream_proxy::connect_via_validated(endpoint, host_lc, port, addrs).await + } + upstream_proxy::ProxyDecision::Direct(direct_addrs) => { + Ok(upstream_proxy::PrefixedStream::without_prefix( + connect_tcp_nodelay_best_effort(&direct_addrs[..]).await?, )) } }; } Ok(upstream_proxy::PrefixedStream::without_prefix( - TcpStream::connect(addrs).await?, + connect_tcp_nodelay_best_effort(addrs).await?, )) } @@ -3337,13 +4202,8 @@ fn parse_allowed_ips(raw: &[String]) -> std::result::Result, S } } -/// Query `allowed_ips` from the matched endpoint config for a CONNECT decision. -fn query_allowed_ips( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> Vec { +/// Read `allowed_ips` from the endpoint configs captured during authorization. +fn query_allowed_ips(decision: &EgressDecision) -> Vec { // Only query if action is Allow with a matched policy let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), @@ -3353,71 +4213,29 @@ fn query_allowed_ips( return vec![]; } - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), - }; - - match engine.query_allowed_ips(&input) { - Ok(ips) => ips, - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!( - "Failed to query allowed_ips from endpoint config: {e}" - )) - .build(); - ocsf_emit!(event); - vec![] - } - } + decision + .endpoint + .policy_configs + .first() + .map(|config| endpoint_config_string_array(config, "allowed_ips")) + .unwrap_or_default() } -/// Query whether the matched endpoint was declared as this exact hostname. -fn query_exact_declared_endpoint_host( - engine: &OpaEngine, - decision: &EgressDecision, - host: &str, - port: u16, -) -> bool { - let has_policy = match &decision.action { - NetworkAction::Allow { matched_policy } => matched_policy.is_some(), - NetworkAction::Deny { .. } => false, +fn endpoint_config_string_array(config: ®orus::Value, key: &str) -> Vec { + let regorus::Value::Object(fields) = config else { + return Vec::new(); }; - if !has_policy { - return false; - } - - let input = crate::opa::NetworkInput { - host: host.to_string(), - port, - binary_path: decision.binary.clone().unwrap_or_default(), - binary_sha256: String::new(), - ancestors: decision.ancestors.clone(), - cmdline_paths: decision.cmdline_paths.clone(), + let key = regorus::Value::String(key.into()); + let Some(regorus::Value::Array(values)) = fields.get(&key) else { + return Vec::new(); }; - - match engine.query_exact_declared_endpoint_host(&input) { - Ok(is_exact_declared) => is_exact_declared, - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(host, port)) - .message(format!("Failed to query exact declared endpoint host: {e}")) - .build(); - ocsf_emit!(event); - false - } - } + values + .iter() + .filter_map(|value| match value { + regorus::Value::String(value) => Some(value.to_string()), + _ => None, + }) + .collect() } /// Canonicalize the request-target for inference pattern detection. @@ -3479,23 +4297,32 @@ fn parse_proxy_uri(uri: &str) -> Result<(String, String, u16, String)> { .ok_or_else(|| miette::miette!("Missing scheme in proxy URI: {uri}"))?; let scheme = scheme.to_ascii_lowercase(); - // Split authority from path - let (authority, path) = if rest.starts_with('[') { - // IPv6: [::1]:port/path + // Split authority from the request target. A query may immediately follow + // the authority when the absolute URI has no explicit path, so `/` alone + // is not a sufficient delimiter. + let target_start = if rest.starts_with('[') { + // IPv6: [::1]:port/path or [::1]?query let bracket_end = rest .find(']') .ok_or_else(|| miette::miette!("Unclosed IPv6 bracket in URI: {uri}"))?; - let after_bracket = &rest[bracket_end + 1..]; - after_bracket.find('/').map_or((rest, "/"), |slash_pos| { - ( - &rest[..=bracket_end + slash_pos], - &after_bracket[slash_pos..], - ) - }) - } else if let Some(slash_pos) = rest.find('/') { - (&rest[..slash_pos], &rest[slash_pos..]) + rest[bracket_end + 1..] + .find(['/', '?', '#']) + .map(|position| bracket_end + 1 + position) } else { - (rest, "/") + rest.find(['/', '?', '#']) + }; + let (authority, target) = target_start.map_or((rest, ""), |position| rest.split_at(position)); + if target.contains('#') { + return Err(miette::miette!( + "Fragments are not allowed in proxy URI: {uri}" + )); + } + let path = match target.chars().next() { + None => "/".to_string(), + Some('/') => target.to_string(), + Some('?') => format!("/{target}"), + Some('#') => unreachable!("fragments were rejected above"), + Some(_) => unreachable!("target begins at a recognized delimiter"), }; // Parse host and port from authority @@ -3534,9 +4361,89 @@ fn parse_proxy_uri(uri: &str) -> Result<(String, String, u16, String)> { return Err(miette::miette!("Empty host in URI: {uri}")); } - let path = if path.is_empty() { "/" } else { path }; + Ok((scheme, host, port, path)) +} + +/// Return a query-free, credential-redacted path suitable for forward-proxy +/// telemetry. Malformed targets are represented by a fixed sentinel so parse +/// errors cannot expose query strings or credential environment-key names. +fn forward_telemetry_path(target_uri: &str) -> String { + let Ok((_, _, _, target)) = parse_proxy_uri(target_uri) else { + return "/[INVALID_REQUEST_TARGET]".to_string(); + }; + let path = target + .split_once('?') + .map_or(target.as_str(), |(path, _)| path); + secrets::redact_target_for_policy(path) + .unwrap_or_else(|_| "/[INVALID_REQUEST_TARGET]".to_string()) +} + +#[cfg(test)] +fn endpoint_secret_resolver( + provider_credentials: Option<&ProviderCredentialState>, + fallback: Option>, + host: &str, + port: u16, + canonical_path: &str, +) -> Option> { + endpoint_credentials_for_request(provider_credentials, fallback, host, port, canonical_path) + .resolver +} + +struct ForwardEndpointCredentials { + resolver: Option>, + revision: Option, +} + +fn endpoint_credentials_for_request( + provider_credentials: Option<&ProviderCredentialState>, + fallback: Option>, + host: &str, + port: u16, + canonical_path: &str, +) -> ForwardEndpointCredentials { + let Some(credentials) = provider_credentials else { + return ForwardEndpointCredentials { + resolver: fallback, + revision: None, + }; + }; + let (resolver, revision) = + credentials.resolver_for_endpoint_with_revision(host, port, canonical_path); + ForwardEndpointCredentials { + resolver, + revision: Some(revision), + } +} + +struct PreparedForwardTarget { + canonical_path: String, + raw_query: Option, + upstream_target: String, + telemetry_path: String, +} - Ok((scheme, host, port, path.to_string())) +fn prepare_forward_target( + target: &str, + canonicalize_options: crate::l7::path::CanonicalizeOptions, +) -> Result { + let (canonical, raw_query) = + crate::l7::path::canonicalize_request_target(target, &canonicalize_options)?; + let telemetry_path = secrets::redact_target_for_policy(&canonical.path) + .unwrap_or_else(|_| "/[INVALID_REQUEST_TARGET]".to_string()); + let upstream_target = raw_query + .as_deref() + .filter(|query| !query.is_empty()) + .map_or_else( + || canonical.path.clone(), + |query| format!("{}?{query}", canonical.path), + ); + Ok(PreparedForwardTarget { + canonical_path: canonical.path, + raw_query, + upstream_target, + telemetry_path, + }) } /// Build the HTTP/1.1 `Host` value for a plain-HTTP absolute-form target. @@ -3733,18 +4640,16 @@ fn rewrite_forward_request( } // Fail-closed: scan for any remaining unresolved placeholders - if secret_resolver.is_some() { - let scan_end = if request_body_credential_rewrite { - rewritten_header_end - } else { - output.len() - }; - let output_str = String::from_utf8_lossy(&output[..scan_end]); - if output_str.contains(secrets::PLACEHOLDER_PREFIX_PUBLIC) - || output_str.contains(secrets::PROVIDER_ALIAS_MARKER_PUBLIC) - { - return Err(secrets::UnresolvedPlaceholderError { location: "header" }); - } + let scan_end = if request_body_credential_rewrite { + rewritten_header_end + } else { + output.len() + }; + let output_str = String::from_utf8_lossy(&output[..scan_end]); + if output_str.contains(secrets::PLACEHOLDER_PREFIX_PUBLIC) + || output_str.contains(secrets::PROVIDER_ALIAS_MARKER_PUBLIC) + { + return Err(secrets::UnresolvedPlaceholderError::unavailable("header")); } Ok(output) @@ -3812,9 +4717,16 @@ fn complete_chunked_body_prefix_len(bytes: &[u8]) -> Option { struct ForwardRelayOptions<'a> { generation_guard: &'a PolicyGenerationGuard, + credential_generation: Option>, websocket_extensions: crate::l7::rest::WebSocketExtensionMode, secret_resolver: Option<&'a SecretResolver>, request_body_credential_rewrite: bool, + deny_uninspected_credentials: bool, + credential_signing: crate::l7::CredentialSigning, + signing_service: &'a str, + signing_region: &'a str, + host: &'a str, + port: u16, } async fn relay_rewritten_forward_request( @@ -3850,14 +4762,16 @@ where upstream, crate::l7::rest::RelayRequestOptions { resolver: options.secret_resolver, + credential_generation: options.credential_generation, generation_guard: Some(options.generation_guard), websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", - host: "", - port: 0, + deny_uninspected_credentials: options.deny_uninspected_credentials, + credential_signing: options.credential_signing, + signing_service: options.signing_service, + signing_region: options.signing_region, + host: options.host, + port: options.port, }, ) .await @@ -3911,6 +4825,7 @@ async fn handle_forward_proxy( policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, + provider_credentials: Option, secret_resolver: Option>, dynamic_credentials: Option< Arc< @@ -3922,24 +4837,18 @@ async fn handle_forward_proxy( denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, ) -> Result<()> { - // 1. Parse the absolute-form URI. `path` is marked `mut` so that, when an - // L7 config applies, the canonicalized form produced below replaces it - // in-place — keeping OPA evaluation and the bytes written onto the wire - // in sync. See the L7 block below. - let (scheme, host, port, mut path) = match parse_proxy_uri(target_uri) { - Ok(parsed) => parsed, - Err(e) => { - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("FORWARD parse error for {target_uri}: {e}")) - .build(); - ocsf_emit!(event); - respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; - return Ok(()); - } + let mut telemetry_path = forward_telemetry_path(target_uri); + // 1. Parse the absolute-form URI. Every external forward target is + // canonicalized below before credential binding, policy-path evaluation, + // upstream bytes, or telemetry consume it. + let Ok((scheme, host, port, mut path)) = parse_proxy_uri(target_uri) else { + ocsf_emit!(build_forward_parse_error_ocsf_event(&telemetry_path)); + respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; + return Ok(()); }; + + let raw_host = host; + let host = normalize_host(&raw_host); let host_lc = host.to_ascii_lowercase(); if host_lc == POLICY_LOCAL_HOST { @@ -4071,7 +4980,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -4094,7 +5003,7 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; @@ -4108,7 +5017,7 @@ async fn handle_forward_proxy( binary = %binary_str, binary_pid = %pid_str, matched_policy = %policy_str, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), action = ?decision.action, "Forward proxy L4 policy decision" @@ -4116,14 +5025,14 @@ async fn handle_forward_proxy( let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); let forward_generation_guard = match relay::pin_policy_generation( &opa_engine, - decision.l4_policy_generation, + decision.policy_generation, ) { Ok(guard) => guard, Err(e) => { warn!( host = %host_lc, port, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), error = %e, "Forward proxy rejected request because policy generation changed after L4 decision" @@ -4136,14 +5045,13 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } }; - let mut upstream_target = path.clone(); let mut websocket_extensions = crate::l7::rest::WebSocketExtensionMode::Preserve; let mut forward_tunnel_engine: Option = None; // L7 endpoint config and evaluated request info, carried past the L7 @@ -4157,13 +5065,7 @@ async fn handle_forward_proxy( let mut forward_websocket_request = crate::l7::rest::request_is_websocket_upgrade(&forward_request_bytes); let mut request_body_credential_rewrite = false; - let l7_ctx = relay::http_context( - &decision, - secret_resolver.clone(), - activity_tx.cloned(), - dynamic_credentials.clone(), - agent_proposals, - ); + let mut deny_uninspected_credentials = false; let mut l7_activity_pending = false; // 4b. If the endpoint has L7 config, evaluate the request against @@ -4171,7 +5073,70 @@ async fn handle_forward_proxy( // connection, so a single evaluation suffices. The shared HTTP relay // strips hop-by-hop `Connection` headers and drops the upstream after // the response instead of asking the upstream to close it. - hydrate_l7_route(&opa_engine, &mut decision); + hydrate_l7_route(&mut decision); + let canonicalize_options = crate::l7::path::CanonicalizeOptions { + allow_encoded_slash: decision.endpoint.l7_route.as_ref().is_some_and(|route| { + route + .configs + .iter() + .any(|snapshot| snapshot.config.allow_encoded_slash) + }), + ..Default::default() + }; + let prepared_target = match prepare_forward_target(&path, canonicalize_options) { + Ok(prepared) => prepared, + Err(error) => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .message(format!( + "FORWARD rejecting non-canonical request-target: {error}" + )) + .build(); + ocsf_emit!(event); + emit_activity_simple(activity_tx, true, "forward_parse_rejection"); + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_request_target", + "request-target must be canonical", + ), + ) + .await?; + return Ok(()); + } + }; + path = prepared_target.canonical_path; + telemetry_path = prepared_target.telemetry_path; + let upstream_target = prepared_target.upstream_target; + let query_params = prepared_target + .raw_query + .as_deref() + .map_or_else(std::collections::HashMap::new, |query| { + crate::l7::rest::parse_query_params(query).unwrap_or_default() + }); + let workspace = policy_local_ctx + .as_ref() + .map(|ctx| ctx.workspace()) + .unwrap_or_default(); + let mut l7_ctx = relay::http_context( + &decision, + provider_credentials, + secret_resolver.clone(), + activity_tx.cloned(), + dynamic_credentials.clone(), + agent_proposals, + workspace, + ); + l7_ctx.request_default_port = match scheme.as_str() { + "http" => Some(80), + "https" => Some(443), + _ => None, + }; if let Some(route) = decision .endpoint .l7_route @@ -4182,7 +5147,7 @@ async fn handle_forward_proxy( warn!( host = %host_lc, port, - l4_policy_generation = decision.l4_policy_generation, + policy_generation = decision.policy_generation, l4_guard_generation = forward_generation_guard.captured_generation(), l7_policy_generation = route.l7_policy_generation, current_generation = opa_engine.current_generation(), @@ -4204,7 +5169,7 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; @@ -4229,7 +5194,9 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!( + "{method} {host_lc}:{port}{telemetry_path} not permitted by policy" + ), ), ) .await?; @@ -4237,63 +5204,20 @@ async fn handle_forward_proxy( } }; - // Canonicalize the request-target. The canonical form is fed to OPA - // AND reassigned to the outer `path` variable so the later call to - // `rewrite_forward_request` writes canonical bytes to the upstream. - // This closes the policy/upstream parser-differential at this site; - // without this reassignment, OPA would evaluate the canonical form - // while the upstream re-normalizes the raw input and dispatches on a - // potentially different path. - let canonicalize_options = crate::l7::path::CanonicalizeOptions { - allow_encoded_slash: route - .configs - .iter() - .any(|snapshot| snapshot.config.allow_encoded_slash), - ..Default::default() + let Ok(redacted_path) = secrets::redact_target_for_policy(&path) else { + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_credential_placeholder", + "request-target contains an invalid credential placeholder", + ), + ) + .await?; + return Ok(()); }; - let query_params = - match crate::l7::path::canonicalize_request_target(&path, &canonicalize_options) { - Ok((canon, query)) => { - upstream_target = match query.as_deref() { - Some(raw_query) if !raw_query.is_empty() => { - format!("{}?{raw_query}", canon.path) - } - _ => canon.path.clone(), - }; - let params = query - .as_deref() - .map_or_else(std::collections::HashMap::new, |q| { - crate::l7::rest::parse_query_params(q).unwrap_or_default() - }); - path = canon.path; - params - } - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!( - "FORWARD_L7 rejecting non-canonical request-target: {e}" - )) - .build(); - ocsf_emit!(event); - emit_activity_simple(activity_tx, true, "l7_parse_rejection"); - respond( - client, - &build_json_error_response( - 400, - "Bad Request", - "invalid_request_target", - "request-target must be canonical", - ), - ) - .await?; - return Ok(()); - } - }; - let Some(l7_config) = select_l7_config_for_path(&route.configs, &path) else { + let Some(l7_config) = select_l7_config_for_path(&route.configs, &redacted_path) else { emit_activity_simple(activity_tx, true, "l7_policy"); respond( client, @@ -4301,32 +5225,70 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} did not match an L7 endpoint path"), + &format!( + "{method} {host_lc}:{port}{telemetry_path} did not match an L7 endpoint path" + ), ), ) .await?; return Ok(()); }; - if crate::l7::rest::request_is_h2c_upgrade(&forward_request_bytes) { - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .http_request(HttpRequest::new( - method, - OcsfUrl::new("http", &host_lc, &path, port), - )) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) + // `canonicalize_options` was built before the matching config was + // known, so `allow_encoded_slash` was taken permissively across every + // config on this route. Re-check it against the config that actually + // matched: the opt-in is per-endpoint, and one endpoint enabling it + // must not loosen parsing for the others. Rejecting here yields the + // same response the parser would have produced had the option been + // scoped correctly from the start. + if !l7_config.config.allow_encoded_slash + && crate::l7::path::canonical_path_has_encoded_slash(&path) + { + ocsf_emit!(build_forward_l7_parse_rejection_ocsf_event( + workload_addr, + method, + &host_lc, + port, + &telemetry_path, + &binary_str, + &pid_str, + &ancestors_str, + &cmdline_str, + policy_str, + FORWARD_ENCODED_SLASH_REJECTION_DETAIL, + )); + emit_activity_simple(activity_tx, true, "forward_parse_rejection"); + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_request_target", + "request-target must be canonical", + ), + ) + .await?; + return Ok(()); + } + if crate::l7::rest::request_is_h2c_upgrade(&forward_request_bytes) { + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("http", &host_lc, &telemetry_path, port), + )) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), + ) .firewall_rule(policy_str, "l7") .message(format!( - "FORWARD_L7 denied unsupported h2c upgrade for {method} {host_lc}:{port}{path}" + "FORWARD_L7 denied unsupported h2c upgrade for {method} {host_lc}:{port}{telemetry_path}" )) .status_detail(crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL) .build(); @@ -4355,9 +5317,12 @@ async fn handle_forward_proxy( } forward_websocket_request = crate::l7::rest::request_is_websocket_upgrade(&forward_request_bytes); - websocket_extensions = crate::l7::relay::websocket_extension_mode(&l7_config.config); + websocket_extensions = crate::l7::relay::websocket_extension_mode(&l7_config.config, false); request_body_credential_rewrite = l7_config.config.protocol == crate::l7::L7Protocol::Rest && l7_config.config.request_body_credential_rewrite; + deny_uninspected_credentials = l7_config + .config + .deny_uninspected_body_credentials(secret_resolver.is_some()); forward_upgrade_config = Some(l7_config.config.clone()); forward_upgrade_target = path.clone(); forward_upgrade_query_params = query_params.clone(); @@ -4473,7 +5438,7 @@ async fn handle_forward_proxy( }; let request_info = crate::l7::L7RequestInfo { action: method.to_string(), - target: path.clone(), + target: redacted_path, query_params, graphql, jsonrpc, @@ -4536,11 +5501,11 @@ async fn handle_forward_proxy( "FORWARD_L7" }; format!( - "{message_prefix} {decision_str} {method} {host_lc}:{port}{path} reason={reason}" + "{message_prefix} {decision_str} {method} {host_lc}:{port}{telemetry_path} reason={reason}" ) }, |jsonrpc_info| { - let endpoint = format!("{host_lc}:{port}{path}"); + let endpoint = format!("{host_lc}:{port}{telemetry_path}"); crate::l7::relay::jsonrpc_log_message( decision_str, method, @@ -4558,7 +5523,7 @@ async fn handle_forward_proxy( .severity(severity) .http_request(HttpRequest::new( method, - OcsfUrl::new("http", &host_lc, &path, port), + OcsfUrl::new("http", &host_lc, &telemetry_path, port), )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) @@ -4592,7 +5557,9 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} denied by L7 policy: {reason}"), + &format!( + "{method} {host_lc}:{port}{telemetry_path} denied by L7 policy: {reason}" + ), ), ) .await?; @@ -4613,7 +5580,7 @@ async fn handle_forward_proxy( // - Otherwise: reject internal IPs, allow public IPs through. // When the policy host is already a literal IP address, treat it as // implicitly allowed — the user explicitly declared the destination. - match hydrate_destination_plan(&opa_engine, &mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_forward_destination( @@ -4623,7 +5590,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -4644,7 +5611,7 @@ async fn handle_forward_proxy( .expect("destination plan hydrated"); let connector = match validate_destination(DestinationRequest { - host: &host, + host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, @@ -4660,7 +5627,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -4692,53 +5659,13 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } - // 6. Connect upstream. Plain-HTTP requests always dial the destination - // directly: only TLS (CONNECT) tunnels chain through the corporate - // proxy, since plain-HTTP forwarding would need absolute-form requests - // rather than a CONNECT tunnel. - let mut upstream = match connector.connect().await { - Ok(s) => s, - Err(e) => { - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .http_request(HttpRequest::new( - method, - OcsfUrl::new("http", &host_lc, &path, port), - )) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) - .message(format!( - "FORWARD upstream connect failed for {host_lc}:{port}: {e}" - )) - .build(); - ocsf_emit!(event); - respond( - client, - &build_json_error_response( - 502, - "Bad Gateway", - "upstream_unreachable", - &format!("connection to {host_lc}:{port} failed"), - ), - ) - .await?; - return Ok(()); - } - }; - let middleware_path = path.split_once('?').map_or(path.as_str(), |(path, _)| path); let middleware_input = crate::opa::NetworkInput { host: host_lc.clone(), @@ -4766,12 +5693,13 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } + let websocket_chain = forward_websocket_request.then(|| chain.clone()); if !chain.is_empty() { let middleware_runner = opa_engine.middleware_runner()?; let request = crate::l7::rest::request_from_buffered_http( @@ -4806,7 +5734,62 @@ async fn handle_forward_proxy( respond(client, &response).await?; return Ok(()); } + crate::l7::middleware::MiddlewareApplyResult::AdmissionExhausted => { + emit_activity_simple(activity_tx, true, "middleware"); + let response = build_middleware_unavailable_response(&l7_ctx.policy_name); + respond(client, &response).await?; + return Ok(()); + } + }; + } + let mut middleware_session = if let Some(chain) = websocket_chain.as_deref() { + let request = crate::l7::rest::request_from_buffered_http( + method, + middleware_path, + &upstream_target, + forward_request_bytes.clone(), + )?; + let middleware_runner = opa_engine.middleware_runner()?; + let preflight = crate::l7::relay::websocket_middleware_preflight( + &request, + chain, + &middleware_runner, + &l7_ctx, + "ws", + ) + .await; + let preflight = match preflight { + Ok(preflight) => preflight, + Err(error) => { + warn!(error = %error, "Plaintext WebSocket middleware preflight failed"); + respond( + client, + &build_json_error_response( + 502, + "Bad Gateway", + "middleware_failed", + "WebSocket middleware preflight failed", + ), + ) + .await?; + return Ok(()); + } }; + crate::l7::middleware::emit_websocket_preflight_events(&l7_ctx, &preflight); + if preflight.terminal_reason.is_some() { + let response = preflight.denial.as_ref().map_or_else( + || build_middleware_failure_response(&l7_ctx.policy_name), + |denial| build_middleware_deny_response(&l7_ctx.policy_name, denial), + ); + respond(client, &response).await?; + return Ok(()); + } + preflight.session + } else { + None + }; + if middleware_session.is_some() { + websocket_extensions = crate::l7::rest::WebSocketExtensionMode::PermessageDeflate; } forward_request_bytes = match inject_token_grant_for_forward_request( method, @@ -4824,6 +5807,11 @@ async fn handle_forward_proxy( error = %e, "token grant failed in forward proxy" ); + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .await; + } respond( client, &build_json_error_response( @@ -4837,6 +5825,30 @@ async fn handle_forward_proxy( return Ok(()); } }; + // Static credentials are intentionally acquired only after every + // asynchronous admission step. Holding an endpoint-scoped resolver across + // middleware or token-grant awaits would let a revoked generation reach + // the upstream. + let endpoint_credentials = endpoint_credentials_for_request( + l7_ctx.provider_credentials.as_ref(), + l7_ctx.secret_resolver.clone(), + &host_lc, + port, + &path, + ); + let secret_resolver = endpoint_credentials.resolver; + let credential_generation = match ( + l7_ctx.provider_credentials.as_ref(), + endpoint_credentials.revision, + ) { + (Some(state), Some(revision)) => Some(crate::l7::rest::CredentialGenerationGuard::new( + state, revision, + )), + _ => None, + }; + if let Some(guard) = credential_generation { + guard.ensure_current()?; + } // 9. Rewrite request and forward to upstream let rewritten = match rewrite_forward_request( @@ -4855,13 +5867,106 @@ async fn handle_forward_proxy( error = %e, "credential injection failed in forward proxy" ); + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .await; + } + if e.is_endpoint_mismatch() { + emit_credential_endpoint_mismatch(&host_lc, port, policy_str); + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "credential_endpoint_mismatch", + "credential is not authorized for this request endpoint", + ), + ) + .await?; + } else { + respond( + client, + &build_json_error_response( + 500, + "Internal Server Error", + "credential_injection_failed", + "unresolved credential placeholder in request", + ), + ) + .await?; + } + return Ok(()); + } + }; + + if let Err(e) = forward_generation_guard.ensure_current() { + warn!( + host = %host_lc, + port, + captured_generation = forward_generation_guard.captured_generation(), + current_generation = forward_generation_guard.current_generation(), + error = %e, + "Forward proxy rejected request because policy changed before relay" + ); + emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .await; + } + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "policy_denied", + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), + ), + ) + .await?; + return Ok(()); + } + // Plain-HTTP requests dial the destination directly: only TLS (CONNECT) + // tunnels chain through the corporate proxy, since plain-HTTP forwarding + // would need absolute-form requests rather than a CONNECT tunnel. Dial + // only after every local authorization and transformation step so a + // rejected WebSocket preflight cannot contact the destination. + let dial_result = connector.connect().await; + let mut upstream = match dial_result { + Ok(s) => s, + Err(e) => { + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("http", &host_lc, &path, port), + )) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), + ) + .message(format!( + "FORWARD upstream connect failed for {host_lc}:{port}: {e}" + )) + .build(); + ocsf_emit!(event); + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .await; + } respond( client, &build_json_error_response( - 500, - "Internal Server Error", - "credential_injection_failed", - "unresolved credential placeholder in request", + 502, + "Bad Gateway", + "upstream_unreachable", + &format!("connection to {host_lc}:{port} failed"), ), ) .await?; @@ -4876,9 +5981,14 @@ async fn handle_forward_proxy( captured_generation = forward_generation_guard.captured_generation(), current_generation = forward_generation_guard.current_generation(), error = %e, - "Forward proxy rejected request because policy changed before relay" + "Forward proxy rejected request because policy changed during upstream connect" ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::PolicyReload) + .await; + } respond( client, &build_json_error_response( @@ -4891,19 +6001,62 @@ async fn handle_forward_proxy( .await?; return Ok(()); } - let outcome = relay_rewritten_forward_request( + + let credential_signing = forward_upgrade_config + .as_ref() + .map_or(crate::l7::CredentialSigning::None, |config| { + config.credential_signing + }); + let signing_service = forward_upgrade_config + .as_ref() + .map_or("", |config| config.signing_service.as_str()); + let signing_region = forward_upgrade_config + .as_ref() + .map_or("", |config| config.signing_region.as_str()); + let outcome_result = relay_rewritten_forward_request( method, - &path, + &upstream_target, rewritten, client, &mut upstream, ForwardRelayOptions { generation_guard: &forward_generation_guard, + credential_generation, websocket_extensions, secret_resolver: secret_resolver.as_deref(), request_body_credential_rewrite, + deny_uninspected_credentials, + credential_signing, + signing_service, + signing_region, + host: &host_lc, + port, }, ) + .await; + let outcome_result = match outcome_result { + Err(report) => { + if let Some(error) = report.downcast_ref::() { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::Cancellation) + .await; + } + crate::l7::relay::reject_credential_resolution(client, &l7_ctx, error).await?; + return Ok(()); + } + Err(report) + } + outcome => outcome, + }; + let outcome = crate::l7::relay::finalize_websocket_pre_upgrade( + &mut middleware_session, + &forward_generation_guard, + &host_lc, + port, + policy_str, + outcome_result, + ) .await?; // The request has now survived middleware, token grant, credential @@ -4914,7 +6067,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -4923,39 +6076,55 @@ async fn handle_forward_proxy( )); emit_forward_success_activity(activity_tx, l7_activity_pending); - if let crate::l7::provider::RelayOutcome::Upgraded { - overflow, - websocket_permessage_deflate, - } = outcome - { - let mut upgrade_options = if let (Some(config), Some(engine)) = ( - forward_upgrade_config.as_ref(), - forward_tunnel_engine.as_ref(), - ) { - crate::l7::relay::upgrade_options( - config, - &l7_ctx, - forward_websocket_request, - &forward_upgrade_target, - &forward_upgrade_query_params, - Some(engine), - ) - } else { - crate::l7::relay::UpgradeRelayOptions { - websocket_request: forward_websocket_request, - ..Default::default() + match outcome { + crate::l7::provider::RelayOutcome::Reusable + | crate::l7::provider::RelayOutcome::Consumed => { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::WebSocketSessionEndReason::UpstreamRejected) + .await; } - }; - upgrade_options.websocket.permessage_deflate = websocket_permessage_deflate; - crate::l7::relay::handle_upgrade( - client, - &mut upstream, + } + crate::l7::provider::RelayOutcome::Upgraded { overflow, - &host_lc, - port, - upgrade_options, - ) - .await?; + websocket_permessage_deflate, + websocket_subprotocol, + } => { + let mut upgrade_options = if let (Some(config), Some(engine)) = ( + forward_upgrade_config.as_ref(), + forward_tunnel_engine.as_ref(), + ) { + crate::l7::relay::upgrade_options( + config, + &l7_ctx, + forward_websocket_request, + &forward_upgrade_target, + &forward_upgrade_query_params, + Some(engine), + ) + } else { + crate::l7::relay::UpgradeRelayOptions { + websocket_request: forward_websocket_request, + ctx: Some(&l7_ctx), + policy_name: l7_ctx.policy_name.clone(), + ..Default::default() + } + }; + upgrade_options.generation_guard = Some(&forward_generation_guard); + upgrade_options.assembly_budget = Some(opa_engine.websocket_assembly_budget()); + upgrade_options.websocket.permessage_deflate = websocket_permessage_deflate; + upgrade_options.middleware_session = middleware_session.take(); + upgrade_options.selected_subprotocol = websocket_subprotocol; + crate::l7::relay::handle_upgrade( + client, + &mut upstream, + overflow, + &host_lc, + port, + upgrade_options, + ) + .await?; + } } Ok(()) @@ -4987,6 +6156,10 @@ fn parse_target(target: &str) -> Result<(String, u16)> { Ok((host.to_string(), port)) } +fn normalize_host(raw_host: &str) -> &str { + raw_host.strip_suffix('.').unwrap_or(raw_host) +} + async fn respond(client: &mut TcpStream, bytes: &[u8]) -> Result<()> { client.write_all(bytes).await.into_diagnostic()?; Ok(()) @@ -5077,6 +6250,14 @@ fn build_middleware_deny_response( } fn build_middleware_failure_response(policy_name: &str) -> Vec { + build_middleware_platform_response(policy_name, "403 Forbidden") +} + +fn build_middleware_unavailable_response(policy_name: &str) -> Vec { + build_middleware_platform_response(policy_name, "503 Service Unavailable") +} + +fn build_middleware_platform_response(policy_name: &str, status: &str) -> Vec { let body = serde_json::json!({ "error": "middleware_failed", "detail": "Request could not be processed by configured middleware", @@ -5084,7 +6265,7 @@ fn build_middleware_failure_response(policy_name: &str) -> Vec { }); let body_str = body.to_string(); format!( - "HTTP/1.1 403 Forbidden\r\n\ + "HTTP/1.1 {status}\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\ @@ -5167,12 +6348,155 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { mod tests { use super::*; use openshell_core::proposals::AgentProposals; + use std::collections::HashMap as TestHashMap; use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + struct DenyWebSocketPreflight; + + #[tonic::async_trait] + impl openshell_core::middleware::SupervisorMiddlewareEndpoint for DenyWebSocketPreflight { + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::MiddlewareManifest { + name: "test/deny-websocket".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + operation: + openshell_core::proto::SupervisorMiddlewareOperation::WebsocketMessage + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials + as i32, + max_payload_bytes: 1024, + timeout: "1s".into(), + }], + expected_audience: String::new(), + }, + )) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Err(tonic::Status::unimplemented("WebSocket-only test service")) + } + + async fn open_websocket_session( + &self, + mut receiver: mpsc::Receiver, + ) -> std::result::Result + { + let (responses, response_stream) = mpsc::channel(1); + tokio::spawn(async move { + while let Some(event) = receiver.recv().await { + if matches!( + event.event, + Some(openshell_core::proto::web_socket_session_event::Event::Preflight(_)) + ) { + let _ = responses + .send(Ok(openshell_core::proto::WebSocketSessionEventResult { + result: Some( + openshell_core::proto::web_socket_session_event_result::Result::PreflightDecision( + openshell_core::proto::WebSocketPreflightDecision { + action: openshell_core::proto::WebSocketPreflightAction::Deny as i32, + reason: "test denial".into(), + reason_code: "test_denial".into(), + ..Default::default() + }, + ), + ), + })) + .await; + break; + } + } + }); + Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new( + response_stream, + ))) + } + } + + struct BlockingForwardMiddleware { + entered: Arc, + release: Arc, + } + + #[tonic::async_trait] + impl openshell_core::middleware::InProcessMiddleware for BlockingForwardMiddleware { + async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { + openshell_core::proto::MiddlewareManifest { + name: "test/blocking-forward".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 8192, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: openshell_core::middleware::HttpRequestView<'_>, + ) -> Result { + self.entered.notify_one(); + self.release.notified().await; + Ok(openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + ..Default::default() + }) + } + } + + fn non_loopback_test_ipv4() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("192.0.2.1:9").ok()?; + match socket.local_addr().ok()?.ip() { + IpAddr::V4(ip) if !ip.is_loopback() && !ip.is_unspecified() => Some(ip), + _ => None, + } + } + async fn drive_raw_request_through_handler(raw: Vec) -> Vec { let policy = include_str!("../data/sandbox-policy.rego"); let data = r#" @@ -5210,6 +6534,7 @@ network_policies: {} None, None, None, + None, )) .await .expect("malformed request should be handled"); @@ -5231,6 +6556,254 @@ network_policies: {} } } + #[tokio::test] + async fn plaintext_websocket_preflight_denial_does_not_connect_upstream() { + if !cfg!(target_os = "linux") { + eprintln!("skipping: handler identity binding requires /proc (Linux)"); + return; + } + let Some(upstream_ip) = non_loopback_test_ipv4() else { + eprintln!("skipping: no routable non-loopback IPv4 test address"); + return; + }; + + let upstream_listener = TcpListener::bind((upstream_ip, 0)) + .await + .expect("bind upstream listener"); + let upstream_port = upstream_listener.local_addr().unwrap().port(); + let executable = std::env::current_exe().expect("current executable"); + let data = format!( + r#" +network_middlewares: + deny-upgrade: + middleware: test/deny-websocket + on_error: fail_closed + endpoints: + include: ["{upstream_ip}"] +network_policies: + allow-upstream: + name: allow-upstream + endpoints: + - host: "{upstream_ip}" + port: {upstream_port} + binaries: + - {{ path: "{executable}" }} +"#, + executable = executable.display(), + ); + let engine = Arc::new( + OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) + .expect("load policy"), + ); + let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( + vec![openshell_supervisor_middleware::in_process_endpoint( + Arc::new(DenyWebSocketPreflight), + )], + Vec::new(), + ) + .await + .expect("connect test middleware"); + engine + .replace_middleware_registry(registry) + .expect("install test middleware"); + + let proxy_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind proxy listener"); + let proxy_address = proxy_listener.local_addr().unwrap(); + let target = format!("http://{upstream_ip}:{upstream_port}/ws"); + let request = format!( + "GET {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" + ); + let client = tokio::spawn(async move { + let mut socket = TcpStream::connect(proxy_address) + .await + .expect("connect proxy"); + let mut response = Vec::new(); + socket + .read_to_end(&mut response) + .await + .expect("read proxy response"); + response + }); + let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); + + tokio::time::timeout( + std::time::Duration::from_secs(30), + handle_forward_proxy( + "GET", + &target, + request.as_bytes(), + request.len(), + &mut proxy_connection, + engine, + Arc::new(BinaryIdentityCache::new()), + Arc::new(AtomicU32::new(std::process::id())), + None, + AgentProposals::default(), + Arc::new(None), + None, + None, + None, + None, + None, + ), + ) + .await + .expect("denied preflight must complete without an upstream response") + .expect("handle denied plaintext WebSocket upgrade"); + drop(proxy_connection); + + let response = String::from_utf8(client.await.unwrap()).expect("UTF-8 response"); + assert!( + response.contains("\"error\":\"middleware_denied\""), + "preflight must deny the upgrade: {response}" + ); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream_listener.accept() + ) + .await + .is_err(), + "denied preflight must not establish an upstream connection" + ); + } + + #[tokio::test] + async fn plaintext_websocket_middleware_inspects_compressed_ws_messages() { + if !cfg!(target_os = "linux") { + eprintln!("skipping: handler identity binding requires /proc (Linux)"); + return; + } + let Some(upstream_ip) = non_loopback_test_ipv4() else { + eprintln!("skipping: no routable non-loopback IPv4 test address"); + return; + }; + + let upstream_listener = TcpListener::bind((upstream_ip, 0)) + .await + .expect("bind upstream listener"); + let upstream_port = upstream_listener.local_addr().unwrap().port(); + let executable = std::env::current_exe().expect("current executable"); + let data = format!( + r#" +network_middlewares: + redact: + middleware: openshell/regex + on_error: fail_closed + endpoints: + include: ["{upstream_ip}"] +network_policies: + allow-upstream: + name: allow-upstream + endpoints: + - host: "{upstream_ip}" + port: {upstream_port} + binaries: + - {{ path: "{executable}" }} +"#, + executable = executable.display(), + ); + let engine = Arc::new( + OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) + .expect("load policy"), + ); + let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("connect built-in middleware"); + engine + .replace_middleware_registry(registry) + .expect("install built-in middleware"); + + let upstream = tokio::spawn(async move { + let (mut socket, _) = upstream_listener.accept().await.unwrap(); + let request = read_http_headers_unbounded(&mut socket).await; + let request = String::from_utf8_lossy(&request); + assert!(request.starts_with("GET /ws HTTP/1.1\r\n")); + assert!(request.contains( + "Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n" + )); + socket + .write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nSec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n\r\n", + ) + .await + .unwrap(); + let frame = crate::l7::websocket::read_frame_for_test(&mut socket).await; + crate::l7::websocket::decode_compressed_masked_text_frame_for_test(&frame) + }); + + let proxy_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind proxy listener"); + let proxy_address = proxy_listener.local_addr().unwrap(); + let target = format!("http://{upstream_ip}:{upstream_port}/ws"); + let request = format!( + "GET {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n\r\n" + ); + let client = tokio::spawn(async move { + let mut socket = TcpStream::connect(proxy_address) + .await + .expect("connect proxy"); + let response = read_http_headers_unbounded(&mut socket).await; + assert!(String::from_utf8_lossy(&response).contains("101 Switching Protocols")); + socket + .write_all( + &crate::l7::websocket::compressed_masked_text_frame_for_test( + br#"{"token":"sk-1234567890abcdef"}"#, + ), + ) + .await + .unwrap(); + }); + let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); + + let handler = tokio::spawn(async move { + handle_forward_proxy( + "GET", + &target, + request.as_bytes(), + request.len(), + &mut proxy_connection, + engine, + Arc::new(BinaryIdentityCache::new()), + Arc::new(AtomicU32::new(std::process::id())), + None, + AgentProposals::default(), + Arc::new(None), + None, + None, + None, + None, + None, + ) + .await + }); + let scenario = tokio::time::timeout(std::time::Duration::from_secs(60), async { + let (client, upstream) = tokio::join!(client, upstream); + client.expect("join plaintext WebSocket client"); + assert_eq!( + upstream.expect("join plaintext WebSocket upstream"), + r#"{"token":"[REDACTED]"}"# + ); + }) + .await; + if handler.is_finished() { + handler + .await + .expect("join plaintext WebSocket handler") + .expect("handle compressed plaintext WebSocket upgrade"); + } else { + handler.abort(); + let _ = handler.await; + } + scenario.expect("compressed plaintext WebSocket scenario should complete"); + } + #[tokio::test] async fn malformed_request_lines_are_rejected_before_connect_or_forward_dispatch() { for host in ["api.example.com", "unmatched.example.com"] { @@ -5278,6 +6851,49 @@ network_policies: {} } } + #[tokio::test] + async fn dial_upstream_preserves_trailing_dot_in_hostname_connect() { + // Fake upstream proxy: capture the request line, then 200. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0_u8; 1024_usize]; + let n = sock.read(&mut buf).await.unwrap(); + sock.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + String::from_utf8_lossy(&buf[..n]).into_owned() + }); + + // Operator config: proxy set + connect-by-hostname opt-in. + let cfg = UpstreamProxyConfig::from_args(&upstream_proxy::UpstreamProxyArgs { + https_proxy: Some(format!("http://{proxy_addr}")), + proxy_connect_by_hostname: true, + ..Default::default() + }) + .unwrap(); + + // host_lc = normalized (undotted), raw_host_lc = absolute (dotted). + let stream = dial_upstream( + &cfg, + "api.example.com", + "api.example.com.", + 443, + &[], // addrs unused in the hostname branch + ) + .await + .unwrap(); + + drop(stream); + + let request = handle.await.unwrap(); + assert!( + request.starts_with("CONNECT api.example.com.:443 HTTP/1.1\r\n"), + "proxy must receive the absolute FQDN: {request}" + ); + } + #[test] fn middleware_failure_response_uses_platform_text_without_policy_guidance() { let response = build_middleware_failure_response("api-policy"); @@ -5296,6 +6912,28 @@ network_policies: {} assert!(body.get("agent_guidance").is_none()); } + #[test] + fn middleware_unavailable_response_is_complete_and_has_no_retry_hint() { + let response = build_middleware_unavailable_response("api-policy"); + let response = String::from_utf8(response).expect("UTF-8 error response"); + assert!(response.starts_with("HTTP/1.1 503 Service Unavailable\r\n")); + assert!(!response.to_ascii_lowercase().contains("retry-after")); + let (headers, body) = response.split_once("\r\n\r\n").expect("HTTP response"); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length: ") + .and_then(|value| value.parse::().ok()) + }) + .expect("Content-Length"); + assert_eq!(content_length, body.len()); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + assert_eq!(body["error"], "middleware_failed"); + assert_eq!(body["policy"], "api-policy"); + assert!(body.get("middleware").is_none()); + assert!(body.get("reason_code").is_none()); + } + #[test] fn policy_deny_response_includes_reason() { let response = build_json_error_response_with_reason( @@ -5321,43 +6959,293 @@ network_policies: {} ); } - #[test] - fn policy_deny_response_omits_empty_reason() { - let response = build_json_error_response_with_reason( - 403, - "Forbidden", - "policy_denied", - "CONNECT api.example.com:443 not permitted by policy", - "", - ); - let response = String::from_utf8(response).expect("UTF-8 error response"); - let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); - let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + #[test] + fn policy_deny_response_omits_empty_reason() { + let response = build_json_error_response_with_reason( + 403, + "Forbidden", + "policy_denied", + "CONNECT api.example.com:443 not permitted by policy", + "", + ); + let response = String::from_utf8(response).expect("UTF-8 error response"); + let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); + + assert_eq!(body["error"], "policy_denied"); + assert!(body.get("reason").is_none()); + } + + #[test] + fn forward_policy_denial_ocsf_includes_validation_rationale() { + let reason = "policy validation failed; fail-closed quarantine is active; candidate version 7 rejected: conflicting tls metadata"; + let event = build_forward_policy_deny_ocsf_event( + "127.0.0.1:45123".parse().unwrap(), + "GET", + "api.example.com", + 80, + "/v1/models", + "/usr/bin/curl", + "42", + "/usr/bin/bash", + "curl http://api.example.com/v1/models", + reason, + ); + let json = event.to_json().unwrap(); + + assert_eq!(json["status_detail"], reason); + assert_eq!(json["action"], "Denied"); + assert_eq!(json["disposition"], "Blocked"); + } + + #[test] + fn forward_l7_parse_rejection_ocsf_includes_denial_context() { + let event = build_forward_l7_parse_rejection_ocsf_event( + "127.0.0.1:45123".parse().unwrap(), + "GET", + "api.example.com", + 80, + "/admin/x%2Fy", + "/usr/bin/curl", + "42", + "/usr/bin/bash", + "curl http://api.example.com/admin/x%2Fy", + "allow_api", + FORWARD_ENCODED_SLASH_REJECTION_DETAIL, + ); + let json = event.to_json().unwrap(); + + assert_eq!(json["class_name"], "HTTP Activity"); + assert_eq!(json["activity_name"], "Other"); + assert_eq!(json["action"], "Denied"); + assert_eq!(json["disposition"], "Blocked"); + assert_eq!(json["severity"], "Medium"); + assert_eq!(json["status"], "Failure"); + assert_eq!(json["http_request"]["http_method"], "GET"); + assert_eq!(json["http_request"]["url"]["path"], "/admin/x%2Fy"); + assert_eq!(json["dst_endpoint"]["domain"], "api.example.com"); + assert_eq!(json["dst_endpoint"]["port"], 80); + assert_eq!(json["actor"]["process"]["name"], "/usr/bin/curl"); + assert_eq!(json["firewall_rule"]["name"], "allow_api"); + assert_eq!(json["firewall_rule"]["type"], "l7"); + assert_eq!( + json["status_detail"], + FORWARD_ENCODED_SLASH_REJECTION_DETAIL + ); + } + + #[test] + fn transparent_tcp_allow_ocsf_exposes_correlated_dns_and_dial_chain() { + let mapping_id = uuid::Uuid::new_v4(); + let event = build_transparent_tcp_allow_ocsf_event(TransparentTcpAllowAudit { + workload: "127.0.0.1:45123".parse().unwrap(), + synthetic_destination: "198.18.0.7:6379".parse().unwrap(), + normalized_domain: "redis.openshell.demo", + approved_real_ip_candidates: &[ + "172.18.0.4:6379".parse().unwrap(), + "172.18.0.5:6379".parse().unwrap(), + ], + connected_real_destination: Some("172.18.0.5:6379".parse().unwrap()), + upstream_socket_peer: "172.18.0.5:6379".parse().unwrap(), + dial_mode: "direct", + mapping_id, + mapping_generation: 4, + mapping_policy_generation: 7, + authorization_policy_generation: 7, + binary: "/sandbox/.venv/bin/python3", + pid: "42", + policy_name: "redis", + }); + let json = event.to_json().unwrap(); + + assert_eq!(json["actor"]["process"]["pid"], 42); + assert_eq!( + json["actor"]["process"]["name"], + "/sandbox/.venv/bin/python3" + ); + assert!(json["actor"]["process"].get("parent_process").is_none()); + assert_eq!(json["dst_endpoint"]["domain"], "redis.openshell.demo"); + assert_eq!(json["firewall_rule"]["name"], "redis"); + assert_eq!(json["unmapped"]["synthetic_destination"], "198.18.0.7:6379"); + assert_eq!( + json["unmapped"]["connected_real_destination"], + "172.18.0.5:6379" + ); + assert_eq!( + json["unmapped"]["approved_real_ip_candidates"], + serde_json::json!(["172.18.0.4:6379", "172.18.0.5:6379"]) + ); + assert_eq!(json["unmapped"]["mapping_id"], mapping_id.to_string()); + assert_eq!(json["unmapped"]["mapping_generation"], 4); + assert_eq!(json["unmapped"]["policy_generation"], 7); + assert_eq!(json["unmapped"]["mapping_policy_generation"], 7); + assert_eq!(json["unmapped"]["matched_policy"], "redis"); + assert_eq!(json["unmapped"]["dial_mode"], "direct"); + + let shorthand = event.format_shorthand(); + assert!(shorthand.contains("/sandbox/.venv/bin/python3(42)")); + assert!(shorthand.contains("redis.openshell.demo:6379")); + assert!(shorthand.contains("synthetic=198.18.0.7:6379")); + assert!(shorthand.contains("real=172.18.0.5:6379")); + assert!(shorthand.contains(&format!("mapping_id={mapping_id}"))); + } + + #[test] + fn transparent_tcp_proxy_audit_reports_validated_connect_target() { + let event = build_transparent_tcp_allow_ocsf_event(TransparentTcpAllowAudit { + workload: "127.0.0.1:45123".parse().unwrap(), + synthetic_destination: "198.18.0.7:6379".parse().unwrap(), + normalized_domain: "redis.openshell.demo", + approved_real_ip_candidates: &["172.18.0.4:6379".parse().unwrap()], + connected_real_destination: Some("172.18.0.4:6379".parse().unwrap()), + upstream_socket_peer: "192.0.2.20:3128".parse().unwrap(), + dial_mode: "upstream_proxy_validated_ip", + mapping_id: uuid::Uuid::new_v4(), + mapping_generation: 4, + mapping_policy_generation: 7, + authorization_policy_generation: 7, + binary: "/usr/bin/redis-cli", + pid: "43", + policy_name: "redis", + }); + let json = event.to_json().unwrap(); + + assert_eq!( + json["unmapped"]["connected_real_destination"], + "172.18.0.4:6379" + ); + assert_eq!(json["unmapped"]["upstream_socket_peer"], "192.0.2.20:3128"); + assert_eq!(json["unmapped"]["dial_mode"], "upstream_proxy_validated_ip"); + assert!(event.format_shorthand().contains("real=172.18.0.4:6379")); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn transparent_tcp_ignores_proxy_hostname_mode_and_connects_to_validated_ip() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = listener.local_addr().unwrap(); + let (request_tx, request_rx) = tokio::sync::oneshot::channel(); + let proxy = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut byte = [0_u8; 1]; + stream.read_exact(&mut byte).await.unwrap(); + request.push(byte[0]); + if request.ends_with(b"\r\n\r\n") { + break; + } + } + request_tx.send(request).unwrap(); + stream + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .unwrap(); + }); + let config = UpstreamProxyConfig::from_args(&upstream_proxy::UpstreamProxyArgs { + https_proxy: Some(format!("http://{proxy_addr}")), + proxy_connect_by_hostname: true, + ..Default::default() + }) + .unwrap() + .unwrap(); + let approved = "203.0.113.27:6379".parse().unwrap(); + + let stream = + dial_transparent_upstream(&Some(config), "redis.openshell.demo", 6379, &[approved]) + .await + .unwrap(); + let request = String::from_utf8(request_rx.await.unwrap()).unwrap(); - assert_eq!(body["error"], "policy_denied"); - assert!(body.get("reason").is_none()); + assert!(request.starts_with("CONNECT 203.0.113.27:6379 HTTP/1.1\r\n")); + assert!(!request.contains("CONNECT redis.openshell.demo:6379")); + assert!(matches!( + stream.connect_target(), + Some(upstream_proxy::ConnectTarget::Ip(ip)) if ip == approved.ip() + )); + proxy.await.unwrap(); } #[test] - fn forward_policy_denial_ocsf_includes_validation_rationale() { - let reason = "policy validation failed; fail-closed quarantine is active; candidate version 7 rejected: conflicting tls metadata"; - let event = build_forward_policy_deny_ocsf_event( - "127.0.0.1:45123".parse().unwrap(), + fn forward_ocsf_events_omit_queries_and_credential_key_names() { + let peer = "127.0.0.1:45123".parse().unwrap(); + let path = forward_telemetry_path( + "http://api.example.com/v1/openshell:resolve:env:API_TOKEN?token=real-secret", + ); + assert_eq!(path, "/v1/[CREDENTIAL]"); + + let allowed = build_forward_allow_ocsf_event( + peer, "GET", "api.example.com", 80, - "/v1/models", + &path, "/usr/bin/curl", "42", "/usr/bin/bash", - "curl http://api.example.com/v1/models", - reason, - ); - let json = event.to_json().unwrap(); + "curl", + "allow_api", + ) + .to_json() + .unwrap(); + let denied = build_forward_policy_deny_ocsf_event( + peer, + "GET", + "api.example.com", + 80, + &path, + "/usr/bin/curl", + "42", + "/usr/bin/bash", + "curl", + "policy denied", + ) + .to_json() + .unwrap(); + for event in [&allowed, &denied] { + assert_eq!(event["http_request"]["url"]["path"], "/v1/[CREDENTIAL]"); + let serialized = event.to_string(); + assert!(!serialized.contains("API_TOKEN"), "{serialized}"); + assert!(!serialized.contains("real-secret"), "{serialized}"); + assert!(!serialized.contains("?token="), "{serialized}"); + } - assert_eq!(json["status_detail"], reason); - assert_eq!(json["action"], "Denied"); - assert_eq!(json["disposition"], "Blocked"); + let target = "http://api.example.com?token=real-secret"; + let (_, host, port, path) = parse_proxy_uri(target).expect("absolute URI without a path"); + assert_eq!(host, "api.example.com"); + assert_eq!(path, "/?token=real-secret"); + let no_path_query = build_forward_allow_ocsf_event( + peer, + "GET", + &host, + port, + &forward_telemetry_path(target), + "/usr/bin/curl", + "42", + "/usr/bin/bash", + "curl", + "allow_api", + ) + .to_json() + .unwrap(); + assert_eq!(no_path_query["dst_endpoint"]["domain"], "api.example.com"); + assert_eq!(no_path_query["http_request"]["url"]["path"], "/"); + let serialized = no_path_query.to_string(); + assert!(!serialized.contains("real-secret"), "{serialized}"); + assert!(!serialized.contains("?token="), "{serialized}"); + + let malformed = build_forward_parse_error_ocsf_event(&forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + )) + .to_json() + .unwrap(); + assert_eq!( + malformed["message"], + "FORWARD parse error for /[INVALID_REQUEST_TARGET]" + ); + let serialized = malformed.to_string(); + assert!(!serialized.contains("API_TOKEN"), "{serialized}"); + assert!(!serialized.contains("real-secret"), "{serialized}"); } #[test] @@ -5421,6 +7309,8 @@ network_policies: allow_encoded_slash: false, websocket_credential_rewrite, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), @@ -5581,6 +7471,29 @@ network_policies: ); } + #[test] + fn revision_scoped_dynamic_credentials_preserves_endpoint_selector_and_adds_revision() { + let mut dynamic_credentials = std::collections::HashMap::new(); + dynamic_credentials.insert( + "api.example.test\t443\t/v1/**\tprovider:access_token".to_string(), + openshell_core::proto::ProviderProfileCredential { + name: "access_token".to_string(), + ..Default::default() + }, + ); + let snapshot = ProviderCredentialSnapshot { + revision: 42, + child_env: std::collections::HashMap::new(), + dynamic_credentials, + }; + + let scoped = revision_scoped_dynamic_credentials(&snapshot); + + assert!( + scoped.contains_key("api.example.test\t443\t/v1/**\trev:42\tprovider:access_token") + ); + } + #[test] fn connect_activity_is_skipped_when_l7_will_count_the_request() { let (tx, mut rx) = mpsc::channel(4); @@ -5736,6 +7649,7 @@ network_policies: let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 80, + request_default_port: Some(80), policy_name: "jsonrpc_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -5781,9 +7695,137 @@ network_policies: crate::l7::middleware::MiddlewareApplyResult::Allowed(_) => { panic!("policy-invalid transformed request must be denied") } + crate::l7::middleware::MiddlewareApplyResult::AdmissionExhausted => { + panic!("test middleware work admission must be available") + } } } + #[tokio::test] + async fn forward_reacquires_static_credentials_after_blocked_middleware() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + let policy = include_str!("../data/sandbox-policy.rego"); + let engine = OpaEngine::from_strings(policy, "network_policies: {}\n").unwrap(); + let guard = engine + .generation_guard(engine.current_generation()) + .expect("generation guard"); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new( + BlockingForwardMiddleware { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }, + )); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "real-secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let ctx = crate::l7::relay::L7EvalContext { + host: "api.example.test".into(), + port: 80, + request_default_port: Some(80), + policy_name: "forward".into(), + binary_path: "/usr/bin/node".into(), + provider_credentials: Some(state.clone()), + secret_resolver: state.resolver(), + ..Default::default() + }; + let raw = b"GET http://api.example.test/allowed/../outside HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\n\r\n"; + let prepared = prepare_forward_target( + "/allowed/../outside", + crate::l7::path::CanonicalizeOptions::default(), + ) + .expect("canonical target"); + assert_eq!(prepared.canonical_path, "/outside"); + let request = crate::l7::rest::request_from_buffered_http( + "GET", + &prepared.canonical_path, + &prepared.upstream_target, + canonicalize_forward_host_header(raw, "api.example.test").unwrap(), + ) + .unwrap(); + let pipeline = ForwardMiddlewarePipeline { + ctx: &ctx, + scheme: "http", + runner: &runner, + generation_guard: &guard, + l7_reevaluation: None, + }; + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "blocker".into(), + implementation: "test/blocking-forward".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let (_app, mut client) = tokio::io::duplex(8192); + let revoke = async { + entered.notified().await; + state.revoke_static_provider_environment(2); + release.notify_one(); + }; + let (outcome, ()) = tokio::join!(pipeline.apply(request, &mut client, chain), revoke); + let request = match outcome.expect("middleware pipeline") { + crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request, + crate::l7::middleware::MiddlewareApplyResult::Denied { .. } => { + panic!("blocking middleware should allow after release") + } + crate::l7::middleware::MiddlewareApplyResult::AdmissionExhausted => { + panic!("blocking middleware should already hold admission") + } + }; + + let credentials = endpoint_credentials_for_request( + ctx.provider_credentials.as_ref(), + ctx.secret_resolver.clone(), + &ctx.host, + ctx.port, + &prepared.canonical_path, + ); + assert!( + credentials.resolver.is_none(), + "revoked live state must supersede the connection-open resolver" + ); + let rewrite = rewrite_forward_request( + &request.raw_header, + request.raw_header.len(), + &prepared.upstream_target, + "api.example.test", + credentials.resolver.as_deref(), + false, + ); + assert!( + rewrite.is_err(), + "revoked credential placeholder must fail before upstream relay" + ); + + let (proxy_upstream, mut upstream) = tokio::io::duplex(8192); + drop(proxy_upstream); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "revoked forward credential request must not reach upstream" + ); + } + #[test] fn forward_l7_allowed_activity_is_deferred_until_after_ssrf() { let (tx, mut rx) = mpsc::channel(4); @@ -5927,9 +7969,16 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: resolver, request_body_credential_rewrite, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await?; @@ -5946,23 +7995,11 @@ network_policies: crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, ) { let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; - let fixture = match resolver_response { - Ok(token) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( - provider_key, - token, - ) - } - Err(error) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( - provider_key, - error, - ) - } - }; + let fixture = forward_token_grant_fixture(provider_key, resolver_response, false); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 8080, + request_default_port: Some(8080), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], @@ -5976,6 +8013,54 @@ network_policies: (ctx, fixture) } + fn forward_token_exchange_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + crate::l7::relay::L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (mut ctx, _) = forward_token_grant_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = forward_token_grant_fixture(provider_key, resolver_response, true); + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (ctx, fixture) + } + + fn forward_token_grant_fixture( + provider_key: &str, + resolver_response: std::result::Result<&str, &str>, + token_exchange: bool, + ) -> crate::l7::token_grant_injection::test_support::TokenGrantTestFixture { + match (resolver_response, token_exchange) { + (Ok(token), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( + provider_key, + token, + ) + } + (Ok(token), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + (Err(error), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( + provider_key, + error, + ) + } + (Err(error), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + } + } + fn authorization_header_count(headers: &str) -> usize { headers .lines() @@ -5999,21 +8084,28 @@ network_policies: ) { let policy = include_str!("../data/sandbox-policy.rego"); let engine = OpaEngine::from_strings(policy, data).unwrap(); + let authorization = engine + .authorize_egress(&crate::opa::NetworkInput { + host: host.to_string(), + port, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }) + .expect("authorize egress"); let decision = EgressDecision { intent: EgressIntent::forward_http(host.to_string(), port), - action: NetworkAction::Allow { - matched_policy: Some(policy_name.to_string()), - }, - l4_policy_generation: engine.current_generation(), + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(PathBuf::from("/usr/bin/node")), binary_pid: None, ancestors: vec![], cmdline_paths: vec![], }; - let route = - query_l7_route_snapshot(&engine, &decision, host, port).expect("L7 route should match"); + let route = query_l7_route_snapshot(&decision, host, port).expect("L7 route should match"); let config = select_l7_config_for_path(&route.configs, path) .expect("path-specific L7 config should match") .config @@ -6024,6 +8116,7 @@ network_policies: let ctx = crate::l7::relay::L7EvalContext { host: host.to_string(), port, + request_default_port: Some(port), policy_name: policy_name.to_string(), binary_path: "/usr/bin/node".to_string(), ancestors: vec![], @@ -6035,14 +8128,36 @@ network_policies: } async fn read_http_headers(reader: &mut R) -> Vec { + read_http_headers_with_timeout(reader, std::time::Duration::from_secs(1)).await + } + + async fn read_http_headers_with_timeout( + reader: &mut R, + timeout: std::time::Duration, + ) -> Vec { let mut bytes = Vec::new(); let mut chunk = [0u8; 256]; loop { - let n = - tokio::time::timeout(std::time::Duration::from_secs(1), reader.read(&mut chunk)) - .await - .expect("HTTP headers should arrive") - .expect("header read should succeed"); + let n = tokio::time::timeout(timeout, reader.read(&mut chunk)) + .await + .expect("HTTP headers should arrive") + .expect("header read should succeed"); + assert!(n > 0, "stream closed before HTTP headers"); + bytes.extend_from_slice(&chunk[..n]); + if bytes.windows(4).any(|w| w == b"\r\n\r\n") { + return bytes; + } + } + } + + async fn read_http_headers_unbounded(reader: &mut R) -> Vec { + let mut bytes = Vec::new(); + let mut chunk = [0u8; 256]; + loop { + let n = reader + .read(&mut chunk) + .await + .expect("header read should succeed"); assert!(n > 0, "stream closed before HTTP headers"); bytes.extend_from_slice(&chunk[..n]); if bytes.windows(4).any(|w| w == b"\r\n\r\n") { @@ -6069,6 +8184,14 @@ network_policies: frame } + fn masked_close_code(frame: &[u8]) -> u16 { + assert_eq!(frame[0] & 0x0f, 0x08, "expected a close frame"); + assert_eq!(frame[1] & 0x7f, 2, "expected a two-byte close code"); + assert_ne!(frame[1] & 0x80, 0, "client-to-upstream close is masked"); + let decoded = [frame[6] ^ frame[2], frame[7] ^ frame[3]]; + u16::from_be_bytes(decoded) + } + async fn forward_websocket_denied_after_upgrade( config: crate::l7::L7EndpointConfig, tunnel_engine: crate::opa::TunnelPolicyEngine, @@ -6095,7 +8218,7 @@ network_policies: false, ) .expect("forward websocket request should rewrite to origin form"); - let websocket_extensions = crate::l7::relay::websocket_extension_mode(&config); + let websocket_extensions = crate::l7::relay::websocket_extension_mode(&config, false); let target = path.to_string(); let query_params = std::collections::HashMap::new(); let (mut proxy_to_upstream, mut upstream) = tokio::io::duplex(8192); @@ -6111,15 +8234,23 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: guard, + credential_generation: None, websocket_extensions, secret_resolver: None, request_body_credential_rewrite: false, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await?; if let crate::l7::provider::RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, + .. } = outcome { let mut options = crate::l7::relay::upgrade_options( @@ -6197,6 +8328,7 @@ network_policies: let ctx = crate::l7::relay::L7EvalContext { host: "gateway.example.test".to_string(), port: 80, + request_default_port: Some(80), policy_name: "ws_api".to_string(), binary_path: "/usr/bin/node".to_string(), ancestors: vec![], @@ -6206,10 +8338,10 @@ network_policies: }; let query_params = std::collections::HashMap::new(); - let extensions = crate::l7::relay::websocket_extension_mode(&websocket_l7_config( - crate::l7::L7Protocol::Websocket, - true, - )); + let extensions = crate::l7::relay::websocket_extension_mode( + &websocket_l7_config(crate::l7::L7Protocol::Websocket, true), + false, + ); let options = crate::l7::relay::upgrade_options( &websocket_l7_config(crate::l7::L7Protocol::Websocket, true), &ctx, @@ -6225,6 +8357,7 @@ network_policies: ); assert!(options.websocket.credential_rewrite); assert!(options.secret_resolver.is_some()); + assert!(options.generation_guard.is_some()); assert!(options.engine.is_some()); assert!(options.ctx.is_some()); assert!(matches!( @@ -6238,6 +8371,7 @@ network_policies: let ctx = crate::l7::relay::L7EvalContext { host: "gateway.example.test".to_string(), port: 80, + request_default_port: Some(80), policy_name: "rest_api".to_string(), binary_path: "/usr/bin/node".to_string(), ancestors: vec![], @@ -6247,7 +8381,7 @@ network_policies: }; let query_params = std::collections::HashMap::new(); let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false); - let extensions = crate::l7::relay::websocket_extension_mode(&config); + let extensions = crate::l7::relay::websocket_extension_mode(&config, false); let options = crate::l7::relay::upgrade_options(&config, &ctx, true, "/ws", &query_params, None); @@ -6265,6 +8399,41 @@ network_policies: )); } + #[test] + fn rest_websocket_upgrade_carries_guard_without_message_inspector() { + let engine = OpaEngine::from_strings( + include_str!("../data/sandbox-policy.rego"), + "network_policies: {}\n", + ) + .expect("test policy"); + let tunnel_engine = engine + .clone_engine_for_tunnel(engine.current_generation()) + .expect("tunnel engine"); + let ctx = crate::l7::relay::L7EvalContext { + host: "gateway.example.test".into(), + port: 443, + policy_name: "rest_api".into(), + binary_path: "/usr/bin/node".into(), + ..Default::default() + }; + let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false); + let options = crate::l7::relay::upgrade_options( + &config, + &ctx, + true, + "/ws", + &std::collections::HashMap::new(), + Some(&tunnel_engine), + ); + + assert!(matches!( + options.websocket.message_policy, + crate::l7::relay::WebSocketMessagePolicy::None + )); + assert!(options.generation_guard.is_some()); + assert!(options.engine.is_some()); + } + #[tokio::test] async fn forward_websocket_upgrade_blocks_text_frame_by_policy() { let data = r#" @@ -6303,9 +8472,10 @@ network_policies: .await; assert!(err.to_string().contains("websocket text message denied")); - assert!( - leaked.is_empty(), - "denied forward-proxy WebSocket text frames must not reach upstream" + assert_eq!( + masked_close_code(&leaked), + 1008, + "only a policy close, not the denied text frame, may reach upstream" ); } @@ -6356,9 +8526,10 @@ network_policies: .await; assert!(err.to_string().contains("websocket GraphQL message denied")); - assert!( - leaked.is_empty(), - "denied forward-proxy GraphQL WebSocket operations must not reach upstream" + assert_eq!( + masked_close_code(&leaked), + 1008, + "only a policy close, not the denied GraphQL operation, may reach upstream" ); } @@ -6377,6 +8548,8 @@ network_policies: allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), @@ -6395,6 +8568,8 @@ network_policies: allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), @@ -8288,9 +10463,161 @@ network_policies: } #[test] - fn test_parse_proxy_uri_with_query() { - let (_, _, _, path) = parse_proxy_uri("http://host:80/api?key=val&foo=bar").unwrap(); - assert_eq!(path, "/api?key=val&foo=bar"); + fn test_parse_proxy_uri_with_query() { + let (_, _, _, path) = parse_proxy_uri("http://host:80/api?key=val&foo=bar").unwrap(); + assert_eq!(path, "/api?key=val&foo=bar"); + } + + #[test] + fn test_parse_proxy_uri_with_query_and_no_path() { + let (_, host, port, path) = parse_proxy_uri("http://host:8080?key=val&foo=bar").unwrap(); + assert_eq!(host, "host"); + assert_eq!(port, 8080); + assert_eq!(path, "/?key=val&foo=bar"); + } + + #[test] + fn forward_telemetry_path_omits_queries_and_redacts_credential_syntax() { + let target = "http://host:80/v1/openshell:resolve:env:API_TOKEN?token=real-secret"; + let redacted = forward_telemetry_path(target); + assert_eq!(redacted, "/v1/[CREDENTIAL]"); + assert!(!redacted.contains("API_TOKEN")); + assert!(!redacted.contains("real-secret")); + + let malformed = forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ); + assert_eq!(malformed, "/[INVALID_REQUEST_TARGET]"); + assert!(!malformed.contains("API_TOKEN")); + assert!(!malformed.contains("real-secret")); + } + + #[test] + fn forward_credentials_capture_endpoint_resolver_and_revision_together() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + + let state = ProviderCredentialState::from_bound_environment( + 42, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + + let credentials = endpoint_credentials_for_request( + Some(&state), + None, + "api.example.com", + 80, + "/allowed/v1", + ); + assert_eq!(credentials.revision, Some(42)); + assert_eq!( + credentials + .resolver + .expect("endpoint resolver") + .resolve_placeholder("openshell:resolve:env:v42_API_TOKEN"), + Some("secret") + ); + } + + #[test] + fn forward_binding_uses_canonical_path_for_dot_segment_traversal() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = "openshell:resolve:env:v1_API_TOKEN"; + + for raw_path in ["/allowed/../outside", "/allowed/%2e%2e/outside"] { + let prepared = + prepare_forward_target(raw_path, crate::l7::path::CanonicalizeOptions::default()) + .expect("prepared target"); + assert_eq!(prepared.canonical_path, "/outside"); + let resolver = endpoint_secret_resolver( + Some(&state), + state.resolver(), + "api.example.com", + 80, + &prepared.canonical_path, + ) + .expect("scoped resolver"); + let error = resolver + .rewrite_header_value(placeholder) + .expect_err("canonical endpoint must deny traversal"); + assert!(error.is_endpoint_mismatch()); + } + } + + #[test] + fn live_forward_state_is_authoritative_after_revocation() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 80, + path: "/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let connection_open_resolver = state.resolver(); + state.revoke_static_provider_environment(2); + + assert!( + endpoint_secret_resolver( + Some(&state), + connection_open_resolver, + "api.example.com", + 80, + "/v1", + ) + .is_none(), + "live revocation must not fall back to the connection-open resolver" + ); } #[test] @@ -8309,6 +10636,20 @@ network_policies: assert_eq!(path, "/path"); } + #[test] + fn test_parse_proxy_uri_ipv6_with_query_and_no_path() { + let (_, host, port, path) = parse_proxy_uri("http://[fe80::1]:8080?key=val").unwrap(); + assert_eq!(host, "fe80::1"); + assert_eq!(port, 8080); + assert_eq!(path, "/?key=val"); + } + + #[test] + fn test_parse_proxy_uri_rejects_fragment() { + assert!(parse_proxy_uri("http://example.com#secret").is_err()); + assert!(parse_proxy_uri("http://[fe80::1]#secret").is_err()); + } + #[test] fn test_parse_proxy_uri_missing_scheme() { let result = parse_proxy_uri("example.com/path"); @@ -8330,6 +10671,16 @@ network_policies: assert_eq!(port, 443); } + #[test] + fn test_normalize_host_strips_single_trailing_dot() { + assert_eq!(normalize_host("api.example.com."), "api.example.com"); + } + + #[test] + fn test_normalize_host_remains_the_same() { + assert_eq!(normalize_host("api.example.com"), "api.example.com"); + } + #[test] fn test_parse_target_preserves_case() { let (host, port) = parse_target("EXAMPLE.COM:443").unwrap(); @@ -8489,6 +10840,14 @@ network_policies: // -- parse_proxy_uri: hostname parser regression tests -- + #[test] + fn test_parse_proxy_uri_trailing_dot_host() { + let (_, host, port, _) = parse_proxy_uri("http://api.example.com.:80/path").unwrap(); + let host = normalize_host(&host); + assert_eq!(host, "api.example.com"); + assert_eq!(port, 80_u16); + } + #[test] fn test_parse_proxy_uri_nul_byte_in_host() { let (_, host, port, _) = parse_proxy_uri("http://evil.com\0.safe.com:80/path").unwrap(); @@ -8557,6 +10916,34 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn forward_proxy_injects_token_exchange_before_rewriting_request() { + let (ctx, fixture) = forward_token_exchange_context(Ok("grant-token")); + let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec(); + + let with_token = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) + .await + .expect("forward token exchange should inject"); + let rewritten = rewrite_forward_request( + &with_token, + with_token.len(), + "/v1/projects", + "api.example.test:8080", + None, + false, + ) + .expect("forward request should rewrite"); + let rewritten = String::from_utf8_lossy(&rewritten); + + assert!(rewritten.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); + assert!(!rewritten.contains("stale-token")); + assert_eq!(authorization_header_count(&rewritten), 1); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn forward_proxy_token_grant_failure_returns_error_before_rewrite() { let (ctx, fixture) = forward_token_grant_context(Err("oauth unavailable")); @@ -8571,6 +10958,22 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn forward_proxy_token_exchange_failure_returns_error_before_rewrite() { + let (ctx, fixture) = forward_token_exchange_context(Err("oauth unavailable")); + let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nConnection: close\r\n\r\n".to_vec(); + + let err = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) + .await + .expect_err("forward token exchange failure should stop request rewriting"); + + assert!(err.to_string().contains("Token grant failed")); + assert!(err.to_string().contains("oauth unavailable")); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[test] fn test_rewrite_get_request() { let raw = @@ -8663,6 +11066,7 @@ network_policies: let ctx = crate::l7::relay::L7EvalContext { host: authority.into(), port: 80, + request_default_port: Some(80), policy_name: "test".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], @@ -8931,19 +11335,35 @@ network_policies: } #[tokio::test] - async fn forward_relay_unresolved_body_placeholder_fails_before_upstream_write() { - let (_, resolver) = SecretResolver::from_provider_env( - [("API_TOKEN".to_string(), "provider-real-token".to_string())] - .into_iter() - .collect(), - ); - let resolver = resolver.expect("resolver"); - let alias = "provider-OPENSHELL-RESOLVE-ENV-API_TOKEN"; - let body = "token=provider-OPENSHELL-RESOLVE-ENV-MISSING_TOKEN"; + async fn forward_relay_body_endpoint_mismatch_is_typed_before_upstream_write() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "provider-real-token".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let resolver = state + .resolver_for_endpoint("api.example.com", 80, "/api/messages") + .expect("endpoint-scoped resolver"); + let body = "token=openshell:resolve:env:v1_API_TOKEN"; let raw = format!( "POST http://api.example.com/api/messages HTTP/1.1\r\n\ Host: api.example.com\r\n\ - Authorization: Bearer {alias}\r\n\ Content-Type: application/x-www-form-urlencoded\r\n\ Content-Length: {}\r\n\r\n{}", body.len(), @@ -8970,16 +11390,27 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: Some(&resolver), request_body_credential_rewrite: true, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await .expect_err("unresolved body placeholder should fail closed"); + let credential_error = err + .downcast_ref::() + .expect("body mismatch must retain its typed error"); + assert!(credential_error.is_endpoint_mismatch()); assert!(!err.to_string().contains("provider-real-token")); - assert!(!err.to_string().contains("MISSING_TOKEN")); + assert!(!err.to_string().contains("API_TOKEN")); drop(proxy_to_upstream); let mut forwarded = Vec::new(); upstream_side.read_to_end(&mut forwarded).await.unwrap(); @@ -8989,6 +11420,85 @@ network_policies: ); } + #[tokio::test] + async fn forward_relay_sigv4_endpoint_mismatch_is_typed_before_upstream_write() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + let values = TestHashMap::from([ + ("AWS_ACCESS_KEY_ID".to_string(), "access".to_string()), + ("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string()), + ("AWS_SESSION_TOKEN".to_string(), "session".to_string()), + ]); + let bindings = values + .keys() + .map(|key| { + ( + key.clone(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: format!("provider-a:{key}"), + workload_credential_handle: String::new(), + }, + ) + }) + .collect(); + let state = ProviderCredentialState::from_bound_environment( + 1, + values, + TestHashMap::new(), + TestHashMap::new(), + bindings, + Vec::new(), + ) + .expect("bound provider state"); + let resolver = state + .resolver_for_endpoint("api.example.com", 80, "/api") + .expect("endpoint-scoped resolver"); + let guard = forward_test_guard(); + let rewritten = + b"GET /api HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 0\r\n\r\n".to_vec(); + let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); + let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); + + let err = relay_rewritten_forward_request( + "GET", + "/api", + rewritten, + &mut proxy_to_client, + &mut proxy_to_upstream, + ForwardRelayOptions { + generation_guard: &guard, + credential_generation: None, + websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, + secret_resolver: Some(&resolver), + request_body_credential_rewrite: false, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::SigV4NoBody, + signing_service: "execute-api", + signing_region: "us-west-2", + host: "api.example.com", + port: 80, + }, + ) + .await + .expect_err("SigV4 endpoint mismatch should fail closed"); + + let credential_error = err + .downcast_ref::() + .expect("SigV4 mismatch must retain its typed error"); + assert!(credential_error.is_endpoint_mismatch()); + drop(proxy_to_upstream); + let mut forwarded = Vec::new(); + upstream_side.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "failed SigV4 credential lookup must not reach upstream" + ); + } + #[test] fn test_forward_rewrite_preserves_websocket_upgrade_connection_header() { let raw = "GET http://gateway.example.test/ws HTTP/1.1\r\n\ @@ -9050,9 +11560,16 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await; @@ -9093,9 +11610,16 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await; @@ -9576,6 +12100,7 @@ network_policies: AgentProposals::default(), // agent_proposals Arc::new(None), // trusted_host_gateway Arc::new(None), // upstream_proxy + None, // provider_credentials None, // secret_resolver None, // dynamic_credentials Some(denial_tx), // denial_tx — positive allow/deny signal @@ -9644,6 +12169,7 @@ network_policies: Arc::new(None), None, None, + None, Some(denial_tx), None, )) @@ -9822,10 +12348,8 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], }; - let (action, generation) = engine - .evaluate_network_action_with_generation(&input) - .expect("evaluate"); - match &action { + let authorization = engine.authorize_egress(&input).expect("evaluate"); + match &authorization.action { NetworkAction::Allow { matched_policy } => { assert!(matched_policy.is_some(), "allow must carry the policy name"); } @@ -9835,16 +12359,16 @@ network_policies: } let decision = EgressDecision { intent: EgressIntent::connect("203.0.113.10".to_string(), 443), - action, - l4_policy_generation: generation, + action: authorization.action.clone(), + policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, - endpoint: EndpointDecision::default(), + endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(input.binary_path), binary_pid: Some(1), ancestors: vec![], cmdline_paths: vec![], }; - query_tls_mode(&engine, &decision, "203.0.113.10", 443) + query_tls_mode(&decision, "203.0.113.10", 443) }; assert_eq!( @@ -10175,335 +12699,318 @@ network_policies: } } - #[test] - fn accept_backoff_exponential_progression() { - let ms = |n| accept_backoff(n).as_millis(); - assert_eq!(ms(1), 100); - assert_eq!(ms(2), 200); - assert_eq!(ms(3), 400); - assert_eq!(ms(4), 800); - assert_eq!(ms(5), 1_600); - assert_eq!(ms(6), 3_200); - assert_eq!(ms(7), 5_000); // 6400 capped to 5000 - assert_eq!(ms(8), 5_000); // stays at cap - } - - #[test] - fn accept_backoff_zero_consecutive_errors() { - assert_eq!(accept_backoff(0).as_millis(), 100); + #[tokio::test] + async fn test_exit_receiver_fires_when_task_exits() { + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + let _guard = exited_tx; + }); + handle.await.unwrap(); + // The sender was dropped when the task completed, so the receiver + // should resolve immediately with an Err (sender dropped). + assert!(exited_rx.await.is_err()); } - #[test] - fn accept_backoff_saturates_at_cap() { - assert_eq!(accept_backoff(100).as_millis(), 5_000); - assert_eq!(accept_backoff(u32::MAX).as_millis(), 5_000); + #[tokio::test] + async fn test_exit_receiver_fires_when_task_is_aborted() { + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + let _guard = exited_tx; + std::future::pending::<()>().await; + }); + handle.abort(); + // Abort drops the task's locals, including the sender guard. + assert!(exited_rx.await.is_err()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_detects_emfile() { - let err = std::io::Error::from_raw_os_error(libc::EMFILE); - assert!(is_resource_pressure_error(&err)); + #[tokio::test] + async fn test_take_exit_receiver_returns_real_receiver() { + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let join = tokio::spawn(std::future::pending::<()>()); + let mut handle = ProxyHandle { + http_addr: None, + join, + exited_rx: Some(rx), + }; + let mut taken = handle + .take_exit_receiver() + .expect("first take should return Some"); + assert!(taken.try_recv().is_err()); + drop(tx); + assert!(taken.await.is_err()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_detects_enfile() { - let err = std::io::Error::from_raw_os_error(libc::ENFILE); - assert!(is_resource_pressure_error(&err)); + #[tokio::test] + async fn test_take_exit_receiver_second_call_returns_none() { + let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); + let join = tokio::spawn(std::future::pending::<()>()); + let mut handle = ProxyHandle { + http_addr: None, + join, + exited_rx: Some(rx), + }; + let _first = handle.take_exit_receiver(); + assert!(handle.take_exit_receiver().is_none()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_detects_memory_pressure() { - assert!(is_resource_pressure_error( - &std::io::Error::from_raw_os_error(libc::ENOBUFS) - )); - assert!(is_resource_pressure_error( - &std::io::Error::from_raw_os_error(libc::ENOMEM) - )); - assert!(is_resource_pressure_error( - &std::io::Error::from_raw_os_error(libc::ENOSR) - )); + #[tokio::test] + async fn test_proxy_handle_drop_fires_exit_receiver() { + let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); + let join = tokio::spawn(async move { + let _guard = exited_tx; + std::future::pending::<()>().await; + }); + let mut handle = ProxyHandle { + http_addr: None, + join, + exited_rx: Some(exited_rx), + }; + let rx = handle.take_exit_receiver().expect("should return Some"); + drop(handle); + assert!(rx.await.is_err()); } - #[cfg(unix)] - #[test] - fn is_resource_pressure_rejects_other_errors() { - let err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); - assert!(!is_resource_pressure_error(&err)); - } + // --- classify_accept_error tests --- #[cfg(unix)] #[test] - fn classify_accept_error_fd_exhaustion_is_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EMFILE)), - AcceptErrorClass::Transient, - ); + fn test_classify_terminal_error_ebadf() { + let err = std::io::Error::from_raw_os_error(libc::EBADF); + let mut fd = 0; + let mut unk = 0; assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENFILE)), - AcceptErrorClass::Transient, + classify_accept_error(&err, &mut fd, &mut unk), + AcceptAction::Terminal ); } #[cfg(unix)] #[test] - fn classify_accept_error_connection_errors_are_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ECONNABORTED)), - AcceptErrorClass::Transient, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ECONNRESET)), - AcceptErrorClass::Transient, - ); + fn test_classify_terminal_error_einval() { + let err = std::io::Error::from_raw_os_error(libc::EINVAL); + let mut fd = 0; + let mut unk = 0; assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EINTR)), - AcceptErrorClass::Transient, + classify_accept_error(&err, &mut fd, &mut unk), + AcceptAction::Terminal ); } #[cfg(unix)] #[test] - fn classify_accept_error_broken_listener_is_terminal() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EBADF)), - AcceptErrorClass::Terminal, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EINVAL)), - AcceptErrorClass::Terminal, - ); + fn test_classify_terminal_error_enotsock() { + let err = std::io::Error::from_raw_os_error(libc::ENOTSOCK); + let mut fd = 0; + let mut unk = 0; assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOTSOCK)), - AcceptErrorClass::Terminal, + classify_accept_error(&err, &mut fd, &mut unk), + AcceptAction::Terminal ); } #[cfg(unix)] #[test] - fn classify_accept_error_unrecognized_errno_is_unknown() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::EPERM)), - AcceptErrorClass::Unknown, + fn test_classify_fd_exhaustion_returns_retry_medium() { + let err = std::io::Error::from_raw_os_error(libc::EMFILE); + let mut fd = 0; + let mut unk = 0; + let action = classify_accept_error(&err, &mut fd, &mut unk); + assert!( + matches!( + action, + AcceptAction::Retry { + severity: SeverityId::Medium, + .. + } + ), + "expected Retry/Medium for EMFILE, got {action:?}", ); } #[cfg(unix)] #[test] - fn handle_accept_error_terminal_exits_immediately() { - let mut res = 0; + fn test_classify_unknown_error_returns_retry_low() { + let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); + let mut fd = 0; let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EBADF); - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!(outcome.backoff.is_none()); - assert_eq!(outcome.severity, SeverityId::High); + let action = classify_accept_error(&err, &mut fd, &mut unk); + assert!( + matches!( + action, + AcceptAction::Retry { + severity: SeverityId::Low, + .. + } + ), + "expected Retry/Low for unknown error, got {action:?}", + ); } #[cfg(unix)] #[test] - fn handle_accept_error_transient_retries_indefinitely() { - let mut res = 0; + fn test_classify_fd_exhaustion_backoff_increases_and_caps() { + let mut fd = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::EMFILE); - for i in 1..=20 { - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!(outcome.backoff.is_some(), "should retry on attempt {i}"); - assert_eq!(outcome.severity, SeverityId::Medium); + + let mut prev_backoff = std::time::Duration::ZERO; + for _ in 0..6 { + match classify_accept_error(&err, &mut fd, &mut unk) { + AcceptAction::Retry { backoff, .. } => { + assert!( + backoff > prev_backoff, + "backoff should increase: {backoff:?} <= {prev_backoff:?}", + ); + prev_backoff = backoff; + } + AcceptAction::Terminal => panic!("expected Retry, got Terminal"), + } + } + + // After enough consecutive errors the backoff should hit the 5s cap. + for _ in 6..12 { + classify_accept_error(&err, &mut fd, &mut unk); + } + match classify_accept_error(&err, &mut fd, &mut unk) { + AcceptAction::Retry { backoff, .. } => { + assert_eq!( + backoff, + std::time::Duration::from_secs(5), + "backoff should cap at 5000ms", + ); + } + AcceptAction::Terminal => panic!("expected Retry, got Terminal"), } - assert_eq!(res, 20); } #[cfg(unix)] #[test] - fn handle_accept_error_unknown_exits_after_limit() { - let mut res = 0; + fn test_classify_unknown_errors_exit_after_threshold() { + let mut fd = 0; let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EPERM); - for i in 1..=MAX_CONSECUTIVE_UNKNOWN_ERRORS { - let outcome = handle_accept_error(&err, &mut res, &mut unk); + let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); + + for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + let action = classify_accept_error(&err, &mut fd, &mut unk); assert!( - outcome.backoff.is_some(), - "should retry on attempt {i}/{MAX_CONSECUTIVE_UNKNOWN_ERRORS}", + matches!(action, AcceptAction::Retry { .. }), + "call {i} should be Retry, got {action:?}", ); - assert_eq!(outcome.severity, SeverityId::Medium); } - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!( - outcome.backoff.is_none(), - "should exit after limit exceeded" + let final_action = classify_accept_error(&err, &mut fd, &mut unk); + assert_eq!( + final_action, + AcceptAction::Terminal, + "call {MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS} should be Terminal", ); - assert_eq!(outcome.severity, SeverityId::High); } #[cfg(unix)] #[test] - fn handle_accept_error_transient_resets_unknown_counter() { - let mut res = 0; + fn test_classify_success_resets_counters() { + let mut fd = 0; let mut unk = 0; - let unknown_err = std::io::Error::from_raw_os_error(libc::EPERM); - let transient_err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); + let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); - // Accumulate unknowns up to the limit. - for _ in 1..=MAX_CONSECUTIVE_UNKNOWN_ERRORS { - handle_accept_error(&unknown_err, &mut res, &mut unk); + for _ in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + classify_accept_error(&err, &mut fd, &mut unk); } - assert_eq!(unk, MAX_CONSECUTIVE_UNKNOWN_ERRORS); - // A transient error resets the unknown counter. - let outcome = handle_accept_error(&transient_err, &mut res, &mut unk); - assert!(outcome.backoff.is_some()); - assert_eq!(unk, 0); + fd = 0; + unk = 0; - // Unknown errors can retry again from zero. - let outcome = handle_accept_error(&unknown_err, &mut res, &mut unk); - assert!(outcome.backoff.is_some()); - assert_eq!(unk, 1); + for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + let action = classify_accept_error(&err, &mut fd, &mut unk); + assert!( + matches!(action, AcceptAction::Retry { .. }), + "after reset, call {i} should be Retry, got {action:?}", + ); + } } #[cfg(unix)] #[test] - fn handle_accept_error_fd_exhaustion_uses_exponential_backoff() { - let mut res = 0; + fn test_classify_fd_error_resets_unknown_counter() { + let mut fd = 0; let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EMFILE); + let unknown_err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); + let fd_err = std::io::Error::from_raw_os_error(libc::EMFILE); - let b1 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b2 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b3 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); + for _ in 0..5 { + classify_accept_error(&unknown_err, &mut fd, &mut unk); + } + + classify_accept_error(&fd_err, &mut fd, &mut unk); - assert_eq!(b1.as_millis(), 100); - assert_eq!(b2.as_millis(), 200); - assert_eq!(b3.as_millis(), 400); + for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { + let action = classify_accept_error(&unknown_err, &mut fd, &mut unk); + assert!( + matches!(action, AcceptAction::Retry { .. }), + "after FD reset, call {i} should be Retry, got {action:?}", + ); + } } #[cfg(unix)] #[test] - fn classify_accept_error_network_errors_are_transient() { + fn test_classify_transient_and_terminal_are_disjoint() { + let mut res = 0; + let mut unk = 0; + for errno in [ + libc::EMFILE, + libc::ENFILE, + libc::ENOBUFS, + libc::ENOMEM, + libc::ECONNABORTED, + libc::ECONNRESET, + libc::EINTR, libc::ENETDOWN, - libc::EPROTO, - libc::ENOPROTOOPT, libc::EHOSTDOWN, libc::EHOSTUNREACH, libc::EOPNOTSUPP, libc::ENETUNREACH, - libc::ESOCKTNOSUPPORT, - libc::EPROTONOSUPPORT, + libc::ENOSR, libc::ETIMEDOUT, ] { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(errno)), - AcceptErrorClass::Transient, - "errno {errno} should be transient", + let err = std::io::Error::from_raw_os_error(errno); + assert!( + matches!( + classify_accept_error(&err, &mut res, &mut unk), + AcceptAction::Retry { .. } + ), + "errno {errno} should be Retry", ); + res = 0; + unk = 0; } - } - - #[cfg(target_os = "linux")] - #[test] - fn classify_accept_error_enonet_is_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENONET)), - AcceptErrorClass::Transient, - ); - } - #[cfg(unix)] - #[test] - fn classify_accept_error_resource_pressure_is_transient() { - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOBUFS)), - AcceptErrorClass::Transient, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOMEM)), - AcceptErrorClass::Transient, - ); - assert_eq!( - classify_accept_error(&std::io::Error::from_raw_os_error(libc::ENOSR)), - AcceptErrorClass::Transient, - ); + for errno in [libc::EBADF, libc::EINVAL, libc::ENOTSOCK] { + let err = std::io::Error::from_raw_os_error(errno); + assert_eq!( + classify_accept_error(&err, &mut res, &mut unk), + AcceptAction::Terminal, + "errno {errno} should be Terminal", + ); + } } #[cfg(unix)] #[test] - fn handle_accept_error_non_resource_transient_uses_fixed_backoff() { + fn test_transient_errors_never_hit_unknown_budget() { let mut res = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); - let o1 = handle_accept_error(&err, &mut res, &mut unk); - let o2 = handle_accept_error(&err, &mut res, &mut unk); - - assert_eq!(o1.severity, SeverityId::Low); - assert_eq!(o1.backoff.unwrap().as_millis(), 100); - assert_eq!(o2.backoff.unwrap().as_millis(), 100); - assert_eq!(res, 0); - } - - #[cfg(unix)] - #[test] - fn handle_accept_error_unknown_uses_exponential_backoff() { - let mut res = 0; - let mut unk = 0; - let err = std::io::Error::from_raw_os_error(libc::EPERM); - - let b1 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b2 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - let b3 = handle_accept_error(&err, &mut res, &mut unk) - .backoff - .unwrap(); - - assert_eq!(b1.as_millis(), 100); - assert_eq!(b2.as_millis(), 200); - assert_eq!(b3.as_millis(), 400); - } - - #[cfg(unix)] - #[test] - fn handle_accept_error_resource_counter_persists_across_mixed_transient() { - let mut res = 0; - let mut unk = 0; - let resource_err = std::io::Error::from_raw_os_error(libc::EMFILE); - let transient_err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); - - let o1 = handle_accept_error(&resource_err, &mut res, &mut unk); - assert_eq!(res, 1); - assert_eq!(o1.backoff.unwrap().as_millis(), 100); - - let o2 = handle_accept_error(&transient_err, &mut res, &mut unk); - assert_eq!(res, 1); - assert_eq!(o2.backoff.unwrap().as_millis(), 100); - - let o3 = handle_accept_error(&resource_err, &mut res, &mut unk); - assert_eq!(res, 2); - assert_eq!(o3.backoff.unwrap().as_millis(), 200); + for i in 0..(MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS + 5) { + let action = classify_accept_error(&err, &mut res, &mut unk); + assert!( + matches!(action, AcceptAction::Retry { .. }), + "transient error on call {i} should always Retry, got {action:?}", + ); + } } - #[cfg(unix)] - #[test] - fn handle_accept_error_terminal_leaves_counters_unchanged() { - let mut res = 3; - let mut unk = 2; - let err = std::io::Error::from_raw_os_error(libc::EBADF); - - let outcome = handle_accept_error(&err, &mut res, &mut unk); - assert!(outcome.backoff.is_none()); - assert_eq!(res, 3); - assert_eq!(unk, 2); - } #[path = "compatibility.rs"] mod compatibility; } diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index 532e2e995f..1ce514133a 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -1,39 +1,53 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![allow( + clippy::redundant_pub_crate, + reason = "the destination primitives intentionally remain internal to the proxy crate" +)] + //! Shared external destination validation and upstream dial boundary. use super::{ - implicit_allowed_ips_for_ip_host, is_host_gateway_alias, parse_allowed_ips, - resolve_and_check_allowed_ips, resolve_and_check_declared_endpoint, - resolve_and_check_trusted_gateway, resolve_and_reject_internal, + BLOCKED_CONTROL_PLANE_PORTS, implicit_allowed_ips_for_ip_host, is_cloud_metadata_ip, + is_host_gateway_alias, is_link_local_ip, parse_allowed_ips, resolve_and_check_allowed_ips, + resolve_and_check_declared_endpoint, resolve_and_check_trusted_gateway, + resolve_and_reject_internal, }; use ipnet::IpNet; +use openshell_core::net::{connect_tcp_nodelay_best_effort, is_always_blocked_ip, is_internal_ip}; use std::net::{IpAddr, SocketAddr}; use tokio::net::TcpStream; /// Address-validation mode selected from the current endpoint configuration. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum AddressAuthorization { +pub(crate) enum AddressAuthorization { DefaultPublicOnly, ExplicitAllowedIps(Vec), ExactDeclaredHost, ImplicitIpLiteral(IpAddr), - TrustedGatewayAlias { expected_ip: IpAddr }, + TrustedGatewayAlias { + expected_ip: IpAddr, + }, + /// Addresses already resolved and authorized by policy DNS. This mode must + /// never resolve `DestinationRequest::host` again before constructing the + /// unopened connector. + #[allow(dead_code, reason = "used when the policy DNS adapter lands")] + PinnedResolved(Vec), } /// Fully materialized input to shared destination validation. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct DestinationValidationPlan { - pub(super) address_authorization: AddressAuthorization, +pub(crate) struct DestinationValidationPlan { + pub(crate) address_authorization: AddressAuthorization, } /// Inputs needed to apply the current SSRF and endpoint destination policy. -pub(super) struct DestinationRequest<'a> { - pub(super) host: &'a str, - pub(super) port: u16, - pub(super) sandbox_entrypoint_pid: u32, - pub(super) plan: &'a DestinationValidationPlan, +pub(crate) struct DestinationRequest<'a> { + pub(crate) host: &'a str, + pub(crate) port: u16, + pub(crate) sandbox_entrypoint_pid: u32, + pub(crate) plan: &'a DestinationValidationPlan, } /// Destination-validation branch that rejected an egress request. @@ -41,7 +55,7 @@ pub(super) struct DestinationRequest<'a> { /// Adapters use this classification to preserve their existing HTTP response /// and OCSF message shapes while sharing the underlying validation logic. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum DestinationDenialKind { +pub(crate) enum DestinationDenialKind { TrustedGateway, InvalidAllowedIps, AllowedIps, @@ -50,9 +64,9 @@ pub(super) enum DestinationDenialKind { } #[derive(Debug)] -pub(super) struct DestinationDenial { - pub(super) kind: DestinationDenialKind, - pub(super) reason: String, +pub(crate) struct DestinationDenial { + pub(crate) kind: DestinationDenialKind, + pub(crate) reason: String, } impl DestinationDenial { @@ -62,7 +76,7 @@ impl DestinationDenial { } /// Select one current destination-validation mode without changing precedence. -pub(super) fn build_validation_plan( +pub(crate) fn build_validation_plan( host: &str, normalized_host: &str, trusted_host_gateway: Option, @@ -93,32 +107,168 @@ pub(super) fn build_validation_plan( }) } +/// Build the destination mode used by policy DNS after it has validated and +/// pinned a non-empty answer set for an endpoint. +#[allow(dead_code, reason = "used by the policy DNS adapter")] +pub(crate) fn build_pinned_validation_plan( + addresses: Vec, +) -> Result { + if addresses.is_empty() { + return Err(DestinationDenial::new( + DestinationDenialKind::InvalidAllowedIps, + "policy DNS produced an empty pinned address set".to_string(), + )); + } + + Ok(DestinationValidationPlan { + address_authorization: AddressAuthorization::PinnedResolved(addresses), + }) +} + +/// Filter resolver-provided addresses through a materialized destination plan. +/// +/// This is the address-only policy-DNS boundary: it never reads a hosts file, +/// invokes a system lookup, or otherwise resolves `host`. Unlike CONNECT's +/// all-or-nothing validation, prohibited answers are removed so a trusted DNS +/// response containing both usable and unusable addresses can retain only the +/// usable subset. +#[allow(dead_code, reason = "used by the policy DNS adapter")] +pub(crate) fn filter_resolved_addresses( + plan: &DestinationValidationPlan, + host: &str, + port: u16, + resolved_ips: &[IpAddr], +) -> Result, DestinationDenial> { + let (kind, control_plane_blocked) = match &plan.address_authorization { + AddressAuthorization::TrustedGatewayAlias { .. } => { + (DestinationDenialKind::TrustedGateway, true) + } + AddressAuthorization::ExplicitAllowedIps(_) + | AddressAuthorization::ImplicitIpLiteral(_) => (DestinationDenialKind::AllowedIps, true), + AddressAuthorization::ExactDeclaredHost => (DestinationDenialKind::DeclaredEndpoint, true), + AddressAuthorization::DefaultPublicOnly => (DestinationDenialKind::InternalAddress, false), + AddressAuthorization::PinnedResolved(_) => (DestinationDenialKind::AllowedIps, false), + }; + + if control_plane_blocked && BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { + return Err(DestinationDenial::new( + kind, + format!("port {port} is a blocked control-plane port, connection rejected"), + )); + } + + let mut allowed = Vec::new(); + let mut first_rejection = None; + for &ip in resolved_ips { + let rejection = match &plan.address_authorization { + AddressAuthorization::DefaultPublicOnly if is_internal_ip(ip) => Some(format!( + "{host} resolves to internal address {ip}, connection rejected" + )), + AddressAuthorization::ExplicitAllowedIps(networks) => { + if is_always_blocked_ip(ip) { + Some(format!( + "{host} resolves to always-blocked address {ip}, connection rejected" + )) + } else if !networks.iter().any(|network| network.contains(&ip)) { + Some(format!( + "{host} resolves to {ip} which is not in allowed_ips, connection rejected" + )) + } else { + None + } + } + AddressAuthorization::ImplicitIpLiteral(expected_ip) => { + if is_always_blocked_ip(ip) { + Some(format!( + "{host} resolves to always-blocked address {ip}, connection rejected" + )) + } else if ip != *expected_ip { + Some(format!( + "{host} resolves to {ip} which is not in allowed_ips, connection rejected" + )) + } else { + None + } + } + AddressAuthorization::ExactDeclaredHost if is_always_blocked_ip(ip) => Some(format!( + "{host} resolves to always-blocked address {ip}, connection rejected" + )), + AddressAuthorization::TrustedGatewayAlias { expected_ip } => { + if is_cloud_metadata_ip(ip) { + Some(format!( + "{host} resolves to cloud metadata address {ip}, connection rejected" + )) + } else if ip != *expected_ip { + Some(format!( + "{host} resolves to {ip} which does not match trusted host gateway \ + {expected_ip}, connection rejected" + )) + } else if !is_link_local_ip(ip) { + Some(format!( + "{host} resolves to non-link-local address {ip}, connection rejected" + )) + } else { + None + } + } + AddressAuthorization::PinnedResolved(pinned) if !pinned.contains(&ip) => Some(format!( + "{host} resolves to unpinned address {ip}, connection rejected" + )), + AddressAuthorization::DefaultPublicOnly + | AddressAuthorization::ExactDeclaredHost + | AddressAuthorization::PinnedResolved(_) => None, + }; + if let Some(reason) = rejection { + first_rejection.get_or_insert(reason); + } else if !allowed.contains(&ip) { + allowed.push(ip); + } + } + + if allowed.is_empty() { + return Err(DestinationDenial::new( + kind, + first_rejection.unwrap_or_else(|| { + format!( + "DNS resolution returned no addresses for {}", + super::normalize_host_lookup_key(host) + ) + }), + )); + } + + Ok(allowed) +} + /// Validated, but not yet opened, upstream destination. /// /// The explicit proxy adapter controls when `connect` is called so CONNECT and /// forward HTTP retain their current upstream-dial timing during the refactor. -pub(super) struct UpstreamConnector { +pub(crate) struct UpstreamConnector { host: String, port: u16, addrs: Vec, } impl UpstreamConnector { - pub(super) fn addrs(&self) -> &[SocketAddr] { + pub(crate) fn addrs(&self) -> &[SocketAddr] { &self.addrs } - pub(super) async fn connect(&self) -> std::io::Result { + /// Opens the connection with `TCP_NODELAY` set: this is the upstream dial + /// boundary for latency-sensitive proxied request/response traffic, where + /// Nagle would stall sub-MSS writes on delayed ACKs. + pub(crate) async fn connect(&self) -> std::io::Result { tracing::debug!( host = %self.host, port = self.port, address_count = self.addrs.len(), "Opening validated upstream connection" ); - TcpStream::connect(self.addrs.as_slice()).await + connect_tcp_nodelay_best_effort(self.addrs.as_slice()).await } - fn new(host: &str, port: u16, addrs: Vec) -> Self { + pub(crate) fn new(host: &str, port: u16, addrs: Vec) -> Self { Self { host: host.to_string(), port, @@ -128,7 +278,7 @@ impl UpstreamConnector { } /// Resolve and validate a destination using the existing proxy security rules. -pub(super) async fn validate_destination( +pub(crate) async fn validate_destination( request: DestinationRequest<'_>, ) -> Result { let DestinationRequest { @@ -175,6 +325,11 @@ pub(super) async fn validate_destination( DestinationDenial::new(DestinationDenialKind::InternalAddress, reason) })? } + AddressAuthorization::PinnedResolved(addresses) => addresses + .iter() + .copied() + .map(|address| SocketAddr::new(address, port)) + .collect(), }; Ok(UpstreamConnector::new(host, port, addrs)) @@ -194,6 +349,19 @@ mod tests { } } + /// Regression test: the shared upstream dial boundary sets `TCP_NODELAY`. + #[tokio::test] + async fn upstream_connector_sets_tcp_nodelay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let addr = listener.local_addr().expect("local addr"); + + let connector = UpstreamConnector::new("127.0.0.1", addr.port(), vec![addr]); + let stream = connector.connect().await.expect("connect"); + assert!(stream.nodelay().expect("query TCP_NODELAY")); + } + #[tokio::test] async fn default_mode_classifies_loopback_as_internal_address() { let plan = DestinationValidationPlan { @@ -249,6 +417,103 @@ mod tests { assert_eq!(denial.kind, DestinationDenialKind::TrustedGateway); } + #[tokio::test] + async fn pinned_addresses_construct_connector_without_resolving_host() { + let pinned_ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + let plan = build_pinned_validation_plan(vec![pinned_ip]).unwrap(); + + let connector = validate_destination(request("does-not-resolve.invalid", &plan)) + .await + .expect("pinned mode must not resolve the hostname"); + + assert_eq!(connector.addrs(), &[SocketAddr::new(pinned_ip, 80)]); + } + + #[test] + fn pinned_addresses_must_not_be_empty() { + let denial = build_pinned_validation_plan(Vec::new()) + .expect_err("an empty pinned answer set must be rejected"); + + assert_eq!(denial.kind, DestinationDenialKind::InvalidAllowedIps); + assert!(denial.reason.contains("empty pinned address set")); + } + + #[test] + fn address_filter_retains_public_answer_from_mixed_set() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::DefaultPublicOnly, + }; + let public: IpAddr = "8.8.8.8".parse().unwrap(); + let private: IpAddr = "10.1.2.3".parse().unwrap(); + + let allowed = + filter_resolved_addresses(&plan, "mixed.example", 443, &[private, public]).unwrap(); + + assert_eq!(allowed, vec![public]); + } + + #[test] + fn address_filter_exact_host_allows_private_but_not_always_blocked() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }; + let private: IpAddr = "10.1.2.3".parse().unwrap(); + let loopback: IpAddr = "127.0.0.1".parse().unwrap(); + + let allowed = + filter_resolved_addresses(&plan, "private.example", 443, &[loopback, private]).unwrap(); + + assert_eq!(allowed, vec![private]); + } + + #[test] + fn address_filter_enforces_allowed_ips() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExplicitAllowedIps(vec![ + "10.2.0.0/16".parse().unwrap(), + ]), + }; + let included: IpAddr = "10.2.3.4".parse().unwrap(); + let excluded: IpAddr = "10.3.4.5".parse().unwrap(); + + let allowed = + filter_resolved_addresses(&plan, "allowlisted.example", 443, &[excluded, included]) + .unwrap(); + + assert_eq!(allowed, vec![included]); + } + + #[test] + fn address_filter_rejects_always_blocked_only_answer() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }; + + let denial = filter_resolved_addresses( + &plan, + "loopback.example", + 443, + &["127.0.0.1".parse().unwrap()], + ) + .expect_err("loopback must not survive filtering"); + + assert_eq!(denial.kind, DestinationDenialKind::DeclaredEndpoint); + } + + #[test] + fn address_filter_rejects_control_plane_port() { + let plan = DestinationValidationPlan { + address_authorization: AddressAuthorization::ExactDeclaredHost, + }; + + let denial = + filter_resolved_addresses(&plan, "api.example", 6443, &["8.8.8.8".parse().unwrap()]) + .expect_err("control-plane port must remain blocked"); + + assert_eq!(denial.kind, DestinationDenialKind::DeclaredEndpoint); + assert!(denial.reason.contains("blocked control-plane port")); + } + #[test] fn validation_mode_precedence_is_explicit_and_stable() { let trusted_ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index f059175cfa..314596b048 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -27,14 +27,21 @@ pub(super) struct L7RouteSnapshot { /// Endpoint metadata materialized for an allowed egress decision. /// -/// The migration hydrates these fields at the same points the legacy handlers -/// queried them so policy-reload and upstream-connect timing remain unchanged. +/// Adapters materialize these fields from the authoritative policy snapshot at +/// their existing timing boundaries so upstream-connect behavior stays stable. #[derive(Debug, Clone)] pub(super) struct EndpointDecision { pub(super) tls_mode: crate::l7::TlsMode, pub(super) l7_route: Option, - /// Destination authorization selected at the legacy hydration point. + /// Destination authorization selected from the captured endpoint metadata. pub(super) destination: Option, + /// Raw endpoint configs returned with the authoritative egress decision. + pub(super) policy_configs: Vec, + /// Full endpoint identities and metadata captured in the same generation. + #[allow(dead_code, reason = "consumed when the policy DNS adapter lands")] + pub(super) matched_endpoints: Vec, + /// Whether policy matched the requested hostname exactly (not by glob). + pub(super) exact_declared_host: bool, } impl Default for EndpointDecision { @@ -43,6 +50,20 @@ impl Default for EndpointDecision { tls_mode: crate::l7::TlsMode::Auto, l7_route: None, destination: None, + policy_configs: Vec::new(), + matched_endpoints: Vec::new(), + exact_declared_host: false, + } + } +} + +impl EndpointDecision { + pub(super) fn from_authorization(authorization: &crate::opa::EgressAuthorization) -> Self { + Self { + policy_configs: authorization.endpoint_configs.clone(), + matched_endpoints: authorization.matched_endpoints.clone(), + exact_declared_host: authorization.exact_declared_endpoint_host, + ..Self::default() } } } @@ -52,6 +73,9 @@ impl Default for EndpointDecision { pub(super) enum EgressTransport { Connect, ForwardHttp, + /// Transparent TCP adapter fed by the policy DNS registry. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + TransparentTcp, } /// Destination requested by an explicit proxy adapter. @@ -77,6 +101,14 @@ impl EgressIntent { Self::new(EgressTransport::ForwardHttp, host, port) } + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub(super) fn transparent_tcp(host: String, port: u16) -> Self { + Self { + transport: EgressTransport::TransparentTcp, + destination: RequestedDestination { host, port }, + } + } + fn new(transport: EgressTransport, host: String, port: u16) -> Self { Self { transport, @@ -105,15 +137,13 @@ pub(super) enum ProcessIdentityEvidence { /// Result of authorizing a normalized egress intent. /// -/// The identity fields intentionally mirror the former CONNECT-specific -/// decision during the compatibility migration. Endpoint configuration is -/// hydrated at the legacy query points without changing lookup precedence or -/// failure defaults. +/// The policy action and endpoint metadata are one atomic snapshot. Adapters +/// may parse that metadata later, but they never query a second generation. pub(super) struct EgressDecision { pub(super) intent: EgressIntent, pub(super) action: NetworkAction, - /// Policy generation used for the L4 network decision. - pub(super) l4_policy_generation: u64, + /// Policy generation used for the complete authorization snapshot. + pub(super) policy_generation: u64, /// Whether process identity evidence was available to policy evaluation. pub(super) identity: ProcessIdentityEvidence, /// Endpoint behavior hydrated for destination validation and relays. @@ -142,5 +172,10 @@ mod tests { assert_eq!(connect.destination.port, 443); assert_eq!(forward.transport, EgressTransport::ForwardHttp); assert_eq!(forward.destination.port, 80); + + let transparent = EgressIntent::transparent_tcp("db.example.com".to_string(), 5432); + assert_eq!(transparent.transport, EgressTransport::TransparentTcp); + assert_eq!(transparent.destination.host, "db.example.com"); + assert_eq!(transparent.destination.port, 5432); } } diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 5eada877a1..9e00cfa254 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -40,11 +40,20 @@ pub(super) struct RelayContext<'a> { /// Build the request-processing context shared by CONNECT and forward HTTP. pub(super) fn http_context( decision: &EgressDecision, + provider_credentials: Option, secret_resolver: Option>, activity_tx: Option, dynamic_credentials: Option, agent_proposals: openshell_core::proposals::AgentProposals, + workspace: String, ) -> L7EvalContext { + // Provider-backed credentials must be acquired from the live state for + // each request after middleware/token-grant awaits. Keep only the legacy + // resolver fallback when no live provider state exists. + let secret_resolver = provider_credentials + .is_none() + .then_some(secret_resolver) + .flatten(); let policy_name = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.clone().unwrap_or_default(), NetworkAction::Deny { .. } => String::new(), @@ -53,6 +62,7 @@ pub(super) fn http_context( L7EvalContext { host: decision.intent.destination.host.clone(), port: decision.intent.destination.port, + request_default_port: None, policy_name, binary_path: decision .binary @@ -70,12 +80,15 @@ pub(super) fn http_context( .map(|path| path.to_string_lossy().into_owned()) .collect(), secret_resolver, + provider_credentials, + provider_credential_revision: None, activity_tx, dynamic_credentials: dynamic_credentials.clone(), token_grant_resolver: dynamic_credentials .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), agent_proposals, + workspace, } } @@ -123,7 +136,7 @@ pub(super) fn prepare_http_relay<'a>( decision: &EgressDecision, request: &'a L7EvalContext, ) -> Option> { - if let Err(error) = validate_route_generation(route, decision.l4_policy_generation) { + if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( &decision.intent.destination.host, decision.intent.destination.port, @@ -133,7 +146,7 @@ pub(super) fn prepare_http_relay<'a>( } let policy = if let Some(route) = route.filter(|route| !route.configs.is_empty()) { - let evaluator = match pin_l7_evaluator(opa_engine, decision.l4_policy_generation) { + let evaluator = match pin_l7_evaluator(opa_engine, decision.policy_generation) { Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -154,18 +167,17 @@ pub(super) fn prepare_http_relay<'a>( evaluator: Box::new(evaluator), } } else { - let generation_guard = - match pin_policy_generation(opa_engine, decision.l4_policy_generation) { - Ok(guard) => guard, - Err(error) => { - emit_l7_tunnel_close_after_policy_change( - &decision.intent.destination.host, - decision.intent.destination.port, - error, - ); - return None; - } - }; + let generation_guard = match pin_policy_generation(opa_engine, decision.policy_generation) { + Ok(guard) => guard, + Err(error) => { + emit_l7_tunnel_close_after_policy_change( + &decision.intent.destination.host, + decision.intent.destination.port, + error, + ); + return None; + } + }; PreparedHttpPolicy::Passthrough { generation_guard } }; @@ -184,7 +196,7 @@ pub(super) fn prepare_raw_relay( opa_engine: &OpaEngine, decision: &EgressDecision, ) -> Option { - if let Err(error) = validate_route_generation(route, decision.l4_policy_generation) { + if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( &decision.intent.destination.host, decision.intent.destination.port, @@ -193,7 +205,7 @@ pub(super) fn prepare_raw_relay( return None; } - match pin_policy_generation(opa_engine, decision.l4_policy_generation) { + match pin_policy_generation(opa_engine, decision.policy_generation) { Ok(guard) => Some(guard), Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -313,13 +325,13 @@ mod tests { const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; - fn decision(l4_policy_generation: u64) -> EgressDecision { + fn decision(policy_generation: u64) -> EgressDecision { EgressDecision { intent: EgressIntent::connect("example.com".to_string(), 80), action: NetworkAction::Allow { matched_policy: Some("test".to_string()), }, - l4_policy_generation, + policy_generation, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: None, @@ -333,15 +345,19 @@ mod tests { L7EvalContext { host: "example.com".to_string(), port: 80, + request_default_port: Some(80), policy_name: "test".to_string(), binary_path: String::new(), ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, + provider_credentials: None, + provider_credential_revision: None, activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, agent_proposals: openshell_core::proposals::AgentProposals::default(), + workspace: String::new(), } } @@ -359,7 +375,7 @@ mod tests { assert_eq!( generation_guard.captured_generation(), - decision.l4_policy_generation + decision.policy_generation ); } @@ -396,6 +412,8 @@ mod tests { allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, + allow_uninspected_credentials: false, + provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 331454c468..186d156086 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -13,7 +13,7 @@ fn allowed_decision(intent: EgressIntent) -> EgressDecision { action: NetworkAction::Allow { matched_policy: Some("proxy_compatibility".to_string()), }, - l4_policy_generation: 0, + policy_generation: 0, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::default(), binary: Some(PathBuf::from("/usr/bin/curl")), @@ -279,65 +279,65 @@ fn representative_adapter_allows_preserve_ocsf_fields() { ); } -fn poisoned_engine() -> OpaEngine { - let engine = OpaEngine::from_strings( - include_str!("../../../data/sandbox-policy.rego"), - r#" -network_policies: - proxy_compatibility: - name: proxy_compatibility - endpoints: - - host: target.example - port: 443 - protocol: rest - enforcement: enforce - tls: skip - allowed_ips: ["10.0.0.0/8"] - rules: - - allow: { method: GET, path: "/**" } - binaries: - - path: /usr/bin/curl -"#, - ) - .unwrap(); - engine.poison_lock_for_test(); - engine -} - #[test] -fn l7_query_failure_preserves_l4_only_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_l7_metadata_preserves_l4_only_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(query_l7_route_snapshot(&engine, &decision, "target.example", 443).is_none()); + assert!(query_l7_route_snapshot(&decision, "target.example", 443).is_none()); } #[test] -fn tls_query_failure_preserves_auto_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_tls_metadata_preserves_auto_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); assert_eq!( - query_tls_mode(&engine, &decision, "target.example", 443), + query_tls_mode(&decision, "target.example", 443), crate::l7::TlsMode::Auto ); } #[test] -fn allowed_ips_query_failure_preserves_empty_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_allowed_ips_preserves_empty_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(query_allowed_ips(&engine, &decision, "target.example", 443).is_empty()); + assert!(query_allowed_ips(&decision).is_empty()); } #[test] -fn exact_host_query_failure_preserves_false_fallback() { - let engine = poisoned_engine(); +fn missing_authorized_exact_host_preserves_false_fallback() { let decision = allowed_decision(EgressIntent::connect("target.example".to_string(), 443)); - assert!(!query_exact_declared_endpoint_host( + assert!(!decision.endpoint.exact_declared_host); +} + +#[test] +fn authoritative_evaluation_error_denies_without_metadata_fallback() { + let engine = OpaEngine::from_strings( + include_str!("../../../data/sandbox-policy.rego"), + r#" +network_policies: + proxy_compatibility: + name: proxy_compatibility + endpoints: + - host: "*.example.com" + port: 443 + binaries: + - path: /** +"#, + ) + .unwrap(); + + // Regorus rejects the NUL byte used internally by its glob matcher. The + // combined authorization query must deny rather than preserve the old + // multi-query behavior that could fall back to an L4-only allow. + let decision = evaluate_endpoint_only_opa( &engine, - &decision, - "target.example", - 443 - )); + EgressIntent::connect("sub\0.example.com".to_string(), 443), + ); + + let NetworkAction::Deny { reason } = decision.action else { + panic!("evaluation errors must deny the request"); + }; + assert!(reason.starts_with("policy evaluation error:")); + assert!(decision.endpoint.policy_configs.is_empty()); + assert!(decision.endpoint.matched_endpoints.is_empty()); + assert!(decision.endpoint.destination.is_none()); } #[test] @@ -553,6 +553,7 @@ network_policies: None, None, None, + None, )) .await .unwrap(); diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 5047ad7bdb..2a71702b4b 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -38,6 +38,108 @@ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::ProxyHandle; +#[cfg(target_os = "linux")] +pub struct TransparentRuntimeSetup { + pub listeners: Vec, + pub dns_udp: tokio::net::UdpSocket, + pub dns_tcp: tokio::net::TcpListener, + config: crate::policy_dns::PolicyDnsRuntimeConfig, +} + +#[cfg(target_os = "linux")] +impl TransparentRuntimeSetup { + /// Build one boot-scoped synthetic allocation epoch. The epoch advances + /// before workload execution, so addresses cached across a supervisor + /// restart fall outside the newly installed capture ranges. + /// + /// # Errors + /// + /// Returns an error when the epoch cannot be read or atomically persisted, + /// or when the derived synthetic pools are invalid. + pub fn new( + listeners: Vec, + dns_udp: tokio::net::UdpSocket, + dns_tcp: tokio::net::TcpListener, + sandbox_id: Option<&str>, + ) -> Result { + let epoch = advance_allocation_epoch( + std::path::Path::new("/run/openshell/policy-dns-epoch"), + sandbox_id, + )?; + Ok(Self { + listeners, + dns_udp, + dns_tcp, + config: crate::policy_dns::PolicyDnsRuntimeConfig::for_epoch(epoch)?, + }) + } + + #[must_use] + pub fn synthetic_cidrs(&self) -> (String, String) { + ( + self.config.ipv4_cidr.to_string(), + self.config.ipv6_cidr.to_string(), + ) + } +} + +#[cfg(target_os = "linux")] +fn advance_allocation_epoch(path: &std::path::Path, sandbox_id: Option<&str>) -> Result { + use miette::{IntoDiagnostic, WrapErr}; + use std::io::Write as _; + + let seed = sandbox_id.map_or(0, |value| { + value + .as_bytes() + .iter() + .fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) + }); + let previous = match std::fs::read_to_string(path) { + Ok(value) => value + .trim() + .parse::() + .into_diagnostic() + .wrap_err("policy DNS allocation epoch is invalid")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => seed, + Err(error) => { + return Err(error) + .into_diagnostic() + .wrap_err("failed to read policy DNS allocation epoch"); + } + }; + let epoch = previous.wrapping_add(1); + let parent = path + .parent() + .ok_or_else(|| miette::miette!("policy DNS allocation epoch has no parent directory"))?; + std::fs::create_dir_all(parent) + .into_diagnostic() + .wrap_err("failed to create policy DNS runtime directory")?; + let temporary = parent.join(format!( + ".policy-dns-epoch-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let result = (|| -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + writeln!(file, "{epoch}")?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + std::fs::File::open(parent)?.sync_all() + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result + .into_diagnostic() + .wrap_err("failed to atomically persist policy DNS allocation epoch")?; + Ok(epoch) +} + /// Handles and values produced by [`run_networking`] that the rest of /// `run_sandbox` consumes. /// @@ -53,6 +155,10 @@ pub struct Networking { /// loop so it can publish updated `SandboxPolicy` snapshots that the /// `policy.local` route handler returns to the workload. pub policy_local_ctx: Arc, + #[cfg(target_os = "linux")] + _policy_dns: Option, + #[cfg(target_os = "linux")] + _transparent_tcp: Option, } /// Set up the networking stack: ephemeral CA + TLS state, proxy server, @@ -90,6 +196,7 @@ pub async fn run_networking( agent_proposals: AgentProposals, workspace_rx: tokio::sync::watch::Receiver, upstream_proxy_args: &crate::upstream_proxy::UpstreamProxyArgs, + #[cfg(target_os = "linux")] transparent_runtime: Option, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll // loop also holds an `Arc` clone (via `Networking::policy_local_ctx`) so @@ -100,7 +207,7 @@ pub async fn run_networking( sandbox_name .map(str::to_string) .or_else(|| sandbox_id.map(str::to_string)), - agent_proposals, + agent_proposals.clone(), workspace_rx, )); @@ -110,6 +217,10 @@ pub async fn run_networking( // the race where an in-flight request observes a generation transition // during the OPA engine reload. let (engine_ready_tx, engine_ready_rx) = tokio::sync::watch::channel(false); + #[cfg(target_os = "linux")] + let transparent_engine_ready_rx = engine_ready_rx.clone(); + #[cfg(target_os = "linux")] + let policy_dns_engine_ready_rx = engine_ready_rx.clone(); // Spawn a task to resolve policy binary symlinks once the workload's mount // namespace becomes accessible via /proc//root/. The task starts @@ -208,16 +319,36 @@ pub async fn run_networking( match SandboxCa::generate() { Ok(ca) => { let tls_dir = std::env::var(openshell_core::sandbox_env::PROXY_TLS_DIR) - .unwrap_or_else(|_| "/etc/openshell-tls".to_string()); + .unwrap_or_else(|_| openshell_core::container_paths::TLS_ROOT.to_string()); let tls_dir = std::path::Path::new(&tls_dir); - let system_ca_bundle = read_system_ca_bundle(); + let mut system_ca_bundle = read_system_ca_bundle(); + // A TLS-intercepting corporate proxy (issue #1792) re-signs + // tunneled server certificates with the corporate CA, so the + // operator-provided bundle must be trusted for upstream + // re-encryption (build_upstream_client_config below) and by + // sandbox processes (the combined bundle written by + // write_ca_files) — not only for the TLS handshake with an + // https:// proxy listener. Fail closed on an unreadable or + // certificate-free bundle, matching the rest of the + // operator-owned proxy configuration. + if let Some(path) = upstream_proxy_args.proxy_ca_bundle.as_deref() { + let pem = crate::upstream_proxy::read_proxy_ca_bundle( + path, + crate::upstream_proxy::ARG_PROXY_CA_BUNDLE, + ) + .map_err(|err| miette::miette!("{err}"))?; + if !system_ca_bundle.is_empty() && !system_ca_bundle.ends_with('\n') { + system_ca_bundle.push('\n'); + } + system_ca_bundle.push_str(&pem); + } match write_ca_files(&ca, tls_dir, &system_ca_bundle) { Ok(paths) => { // /etc/openshell-tls is subsumed by the /etc baseline // path injected by enrich_*_baseline_paths(), so no // explicit Landlock entry is needed here. - let upstream_config = build_upstream_client_config(&system_ca_bundle); + let upstream_config = build_upstream_client_config(&system_ca_bundle)?; let cert_cache = CertCache::new(ca); let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config)); ocsf_emit!( @@ -312,8 +443,8 @@ pub async fn run_networking( inference_ctx, Some(provider_credentials.clone()), Some(policy_local_ctx.clone()), - denial_tx, - activity_tx, + denial_tx.clone(), + activity_tx.clone(), engine_ready_rx, upstream_proxy_args, ) @@ -323,9 +454,70 @@ pub async fn run_networking( None }; + #[cfg(target_os = "linux")] + let (policy_dns, transparent_tcp) = if let Some(runtime) = transparent_runtime { + let engine = opa_engine + .cloned() + .ok_or_else(|| miette::miette!("transparent TCP requires an OPA policy engine"))?; + let cache = identity_cache + .clone() + .ok_or_else(|| miette::miette!("transparent TCP requires a process identity cache"))?; + let trusted_gateway = crate::proxy::detect_trusted_host_gateway(); + let dns = crate::policy_dns::PolicyDnsRuntime::start( + engine.clone(), + runtime.dns_udp, + runtime.dns_tcp, + trusted_gateway, + runtime.config, + policy_dns_engine_ready_rx, + )?; + let transparent = crate::proxy::TransparentTcpHandle::start( + runtime.listeners, + dns.store.clone(), + engine, + cache, + entrypoint_pid, + agent_proposals, + denial_tx, + activity_tx, + upstream_proxy_args, + transparent_engine_ready_rx, + )?; + (Some(dns), Some(transparent)) + } else { + (None, None) + }; + Ok(Networking { proxy: proxy_handle, ca_file_paths, policy_local_ctx, + #[cfg(target_os = "linux")] + _policy_dns: policy_dns, + #[cfg(target_os = "linux")] + _transparent_tcp: transparent_tcp, }) } + +#[cfg(all(test, target_os = "linux"))] +mod transparent_runtime_tests { + use super::*; + + #[test] + fn allocation_epoch_advances_across_restart() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("epoch"); + let first = advance_allocation_epoch(&path, Some("sandbox-a")).unwrap(); + let second = advance_allocation_epoch(&path, Some("sandbox-a")).unwrap(); + assert_eq!(second, first + 1); + } + + #[test] + fn invalid_allocation_epoch_fails_closed() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("epoch"); + std::fs::write(&path, "corrupt\n").unwrap(); + let error = advance_allocation_epoch(&path, Some("sandbox-a")).unwrap_err(); + assert!(error.to_string().contains("allocation epoch is invalid")); + } +} diff --git a/crates/openshell-supervisor-network/src/sigv4.rs b/crates/openshell-supervisor-network/src/sigv4.rs index 40cc189dc2..79e4c32e18 100644 --- a/crates/openshell-supervisor-network/src/sigv4.rs +++ b/crates/openshell-supervisor-network/src/sigv4.rs @@ -28,6 +28,13 @@ fn looks_like_region(s: &str) -> bool { /// hostnames. The region is the label immediately before `amazonaws.com` /// (or `amazonaws.com.cn`). pub fn extract_aws_region(host: &str) -> Option { + // The global S3 endpoint and its virtual-hosted bucket form always sign + // in us-east-1. This keeps policy metadata consistent across overlapping + // S3 hostname patterns while preserving correct global endpoint signing. + if host == "s3.amazonaws.com" || host.ends_with(".s3.amazonaws.com") { + return Some("us-east-1".to_string()); + } + let parts: Vec<&str> = host.split('.').collect(); // China partition: *.amazonaws.com.cn if parts.len() >= 5 @@ -386,13 +393,19 @@ mod tests { } #[test] - fn global_endpoint_returns_none() { - assert!(extract_aws_region("s3.amazonaws.com").is_none()); + fn global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); } #[test] - fn virtual_hosted_global_endpoint_returns_none() { - assert!(extract_aws_region("my-bucket.s3.amazonaws.com").is_none()); + fn virtual_hosted_global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("my-bucket.s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); } #[test] diff --git a/crates/openshell-supervisor-network/src/spiffe_endpoint.rs b/crates/openshell-supervisor-network/src/spiffe_endpoint.rs deleted file mode 100644 index 4494626275..0000000000 --- a/crates/openshell-supervisor-network/src/spiffe_endpoint.rs +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::path::Path; - -/// Convert a path to a SPIFFE Workload API endpoint URL. -/// -/// If the path already has a scheme (`unix:` or `tcp:`), use it as-is. -/// Otherwise, assume it is a Unix socket path and prepend `unix:`. -pub fn workload_api_endpoint(path: &Path) -> String { - let path = path.to_string_lossy(); - if path.starts_with("unix:") || path.starts_with("tcp:") { - path.into_owned() - } else { - format!("unix:{path}") - } -} diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 03e9bfb398..15aea6fccf 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -34,13 +34,16 @@ use std::collections::HashMap; use std::future::Future; -use std::net::IpAddr; use std::sync::{Arc, LazyLock, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::oauth::{ + self, ACCESS_TOKEN_TYPE, OAuthTokenResponse, TokenExchangeParams, TokenGrantParams, + effective_client_assertion_type, effective_token_type, +}; +use openshell_core::proto::ProviderCredentialTokenGrantType; use openshell_core::sandbox_env; -use serde::Deserialize; use spiffe::WorkloadApiClient; /// Token cache shared across all provider token grants. @@ -54,32 +57,9 @@ static TOKEN_GRANT_HTTP_CLIENT: LazyLock = LazyLock::new(|| { .build() .expect("token grant HTTP client configuration should be valid") }); -const MAX_OAUTH_ERROR_FIELD_LEN: usize = 256; const DEFAULT_TOKEN_CACHE_TTL_SECONDS: i64 = 300; const TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; const MAX_TOKEN_EXPIRES_IN_SECONDS: i64 = 3600; -const DEFAULT_CLIENT_ASSERTION_TYPE: &str = - "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - -/// `OAuth2` token response from the authorization server. -#[derive(Debug, Clone, Deserialize)] -struct TokenResponse { - access_token: String, - #[serde(default)] - #[allow(dead_code)] - token_type: String, - #[serde(default)] - expires_in: i64, - #[serde(default)] - #[allow(dead_code)] - scope: String, -} - -#[derive(Debug, Deserialize)] -struct OAuthErrorResponse { - error: Option, - error_description: Option, -} /// Cached access token with expiration metadata. #[derive(Debug, Clone)] @@ -153,41 +133,98 @@ impl TokenCache { /// - JWT-SVID fetch fails /// - Token service request fails /// - Token response is invalid -pub async fn obtain_provider_token( - provider_name: &str, - token_endpoint: &str, - jwt_svid_audience: &str, - client_assertion_type: &str, - audience: &str, - scopes: &[String], - cache_ttl_override: i64, -) -> Result { +pub struct ObtainProviderTokenRequest<'a> { + pub provider_name: &'a str, + pub token_endpoint: &'a str, + pub jwt_svid_audience: &'a str, + pub client_assertion_type: &'a str, + pub audience: &'a str, + pub scopes: &'a [String], + pub cache_ttl_override: i64, + pub grant_type: i32, + pub requested_token_type: &'a str, +} + +pub async fn obtain_provider_token(request: ObtainProviderTokenRequest<'_>) -> Result { + let grant_type = + ProviderCredentialTokenGrantType::try_from(request.grant_type).map_err(|_| { + tracing::error!( + grant_type = request.grant_type, + provider = request.provider_name, + "unrecognised token grant_type" + ); + miette::miette!( + "unrecognised token grant_type {} for provider credential", + request.grant_type + ) + })?; obtain_provider_token_with_grant( ObtainProviderTokenInput { cache: &TOKEN_CACHE, - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type, - audience, - scopes, - cache_ttl_override, + provider_name: request.provider_name, + token_endpoint: request.token_endpoint, + jwt_svid_audience: request.jwt_svid_audience, + client_assertion_type: request.client_assertion_type, + audience: request.audience, + scopes: request.scopes, + cache_ttl_override: request.cache_ttl_override, + grant_type, + requested_token_type: request.requested_token_type, }, |jwt_audience| async move { // Fetch JWT-SVID with authorization server as audience // For RFC 7523, the JWT assertion's aud claim identifies the issuer/realm let jwt_svid = fetch_jwt_svid_for_token_grant(&jwt_audience).await?; - // Perform OAuth2 JWT client assertion grant - // The audience parameter in the token request specifies the resource server - perform_token_grant( - token_endpoint, - &jwt_svid, - client_assertion_type, - audience, - scopes, - ) - .await + match grant_type { + ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::Unspecified => { + // Perform OAuth2 JWT client assertion grant. The audience + // parameter in the token request specifies the resource server. + perform_token_grant( + request.token_endpoint, + &jwt_svid, + request.client_assertion_type, + request.audience, + request.scopes, + ) + .await + } + ProviderCredentialTokenGrantType::TokenExchange => { + let (provider, credential_key) = + parse_provider_credential_key(request.provider_name)?; + let endpoint = supervisor_gateway_endpoint_from_env()?; + let sandbox_id = supervisor_sandbox_id_from_env()?; + let intermediate = + openshell_core::grpc_client::exchange_provider_subject_token( + &endpoint, + &sandbox_id, + provider, + credential_key, + &jwt_svid, + ) + .await + .map_err(|err| { + miette::miette!( + "gateway intermediate provider token exchange failed: {err}" + ) + })?; + validate_access_token(&intermediate.access_token)?; + perform_token_exchange( + request.token_endpoint, + &TokenExchangeParams { + client_assertion: &jwt_svid, + client_assertion_type: request.client_assertion_type, + subject_token: &intermediate.access_token, + subject_token_type: ACCESS_TOKEN_TYPE, + audience: request.audience, + scopes: request.scopes, + requested_token_type: request.requested_token_type, + }, + ) + .await + } + } }, ) .await @@ -202,6 +239,8 @@ struct ObtainProviderTokenInput<'a> { audience: &'a str, scopes: &'a [String], cache_ttl_override: i64, + grant_type: ProviderCredentialTokenGrantType, + requested_token_type: &'a str, } async fn obtain_provider_token_with_grant( @@ -210,34 +249,30 @@ async fn obtain_provider_token_with_grant( ) -> Result where F: FnOnce(String) -> Fut, - Fut: Future>, + Fut: Future>, { - // Derive authorization server audience from token endpoint - // For Keycloak: https://auth.example.com/realms/openshell/protocol/openid-connect/token - // -> https://auth.example.com/realms/openshell let jwt_audience = effective_jwt_svid_audience(input.token_endpoint, input.jwt_svid_audience); - let cache_key = token_cache_key( - input.provider_name, - input.token_endpoint, - &jwt_audience, - effective_client_assertion_type(input.client_assertion_type), - input.audience, - input.scopes, - ); + let cache_key = token_cache_key(TokenCacheKeyInput { + provider_name: input.provider_name, + token_endpoint: input.token_endpoint, + jwt_svid_audience: &jwt_audience, + client_assertion_type: effective_client_assertion_type(input.client_assertion_type), + audience: input.audience, + scopes: input.scopes, + grant_type: input.grant_type, + requested_token_type: effective_token_type(input.requested_token_type), + }); - // Check cache first if let Some(cached) = input.cache.get(&cache_key) { return Ok(cached); } let token_response = grant(jwt_audience).await?; - validate_access_token(&token_response.access_token)?; let cache_ttl_seconds = token_cache_ttl_seconds(input.cache_ttl_override, token_response.expires_in); let expires_at_ms = current_time_ms().saturating_add(cache_ttl_seconds.saturating_mul(1000)); - // Cache the token input.cache.set( cache_key, token_response.access_token.clone(), @@ -256,7 +291,7 @@ async fn fetch_jwt_svid_for_token_grant(audience: &str) -> Result { let socket_path = provider_spiffe_workload_api_socket_from_env()?; let endpoint = - crate::spiffe_endpoint::workload_api_endpoint(std::path::Path::new(&socket_path)); + openshell_core::spiffe::workload_api_endpoint(std::path::Path::new(&socket_path)); // Connect to SPIRE agent let client = WorkloadApiClient::connect_to(&endpoint) @@ -287,153 +322,34 @@ fn provider_spiffe_workload_api_socket_from_env() -> Result { }) } -/// Perform `OAuth2` JWT client assertion grant. -/// -/// POSTs to the token endpoint with: -/// - `grant_type=client_credentials` -/// - `client_assertion_type=` -/// - `client_assertion=` (client identity is in the JWT's `sub` claim) -/// - `audience=` (if provided) -/// - `scope=` (if provided) -/// -/// Note: `client_id` is NOT included - the client is identified by the `sub` claim -/// in the JWT-SVID itself. async fn perform_token_grant( token_endpoint: &str, jwt_svid: &str, client_assertion_type: &str, audience: &str, scopes: &[String], -) -> Result { - let token_endpoint_url = parse_token_endpoint_url(token_endpoint)?; - let client_assertion_type = effective_client_assertion_type(client_assertion_type); - let mut form_params = vec![ - ("grant_type", "client_credentials"), - ("client_assertion_type", client_assertion_type), - ("client_assertion", jwt_svid), - ]; - - // Add audience if provided - let audience_param; - if !audience.is_empty() { - audience_param = audience.to_string(); - form_params.push(("audience", &audience_param)); - } - - // Add scopes if provided - let scope_param; - if !scopes.is_empty() { - scope_param = scopes.join(" "); - form_params.push(("scope", &scope_param)); - } - - // POST to token endpoint - let response = TOKEN_GRANT_HTTP_CLIENT - .post(token_endpoint_url) - .form(&form_params) - .send() - .await - .into_diagnostic() - .wrap_err_with(|| format!("failed to POST to token endpoint {token_endpoint}"))?; - - // Check response status - if !response.status().is_success() { - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(miette::miette!( - "{}", - token_grant_failure_message(status, &body) - )); - } - - // Parse token response - let token_response = response - .json::() - .await - .into_diagnostic() - .wrap_err("failed to parse token response as JSON")?; - validate_access_token(&token_response.access_token)?; - Ok(token_response) -} - -fn parse_token_endpoint_url(token_endpoint: &str) -> Result { - let url = reqwest::Url::parse(token_endpoint) - .into_diagnostic() - .wrap_err("token_endpoint must be an absolute URL")?; - if token_endpoint_transport_allowed(&url) { - return Ok(url); - } - - Err(miette::miette!( - "token_endpoint must use https, except http for loopback or in-cluster service hosts" - )) -} - -fn token_endpoint_transport_allowed(url: &reqwest::Url) -> bool { - match url.scheme() { - "https" => true, - "http" => url - .host_str() - .is_some_and(|host| is_loopback_host(host) || is_kubernetes_service_host(host)), - _ => false, - } -} - -fn is_loopback_host(host: &str) -> bool { - let host = host.trim_matches(['[', ']']); - if host.eq_ignore_ascii_case("localhost") { - return true; - } - - match host.parse::() { - Ok(IpAddr::V4(v4)) => v4.is_loopback(), - Ok(IpAddr::V6(v6)) => { - v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) - } - Err(_) => false, - } -} - -fn is_kubernetes_service_host(host: &str) -> bool { - let host = host.trim_end_matches('.').to_ascii_lowercase(); - let labels = host.split('.').collect::>(); - let is_service_name = labels.len() == 3 && labels[2] == "svc"; - let is_cluster_local_service = - labels.len() == 5 && labels[2] == "svc" && labels[3] == "cluster" && labels[4] == "local"; - (is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty()) -} - -pub fn validate_access_token(token: &str) -> Result<()> { - if token.is_empty() || !is_token68(token) { - return Err(miette::miette!( - "token grant returned a malformed access token" - )); - } - Ok(()) +) -> Result { + oauth::post_oauth_token_grant( + &TOKEN_GRANT_HTTP_CLIENT, + token_endpoint, + &TokenGrantParams { + client_assertion: jwt_svid, + client_assertion_type, + audience, + scopes, + }, + ) + .await } -fn is_token68(token: &str) -> bool { - let mut padding_started = false; - let mut saw_value = false; - for byte in token.bytes() { - if byte == b'=' { - padding_started = true; - continue; - } - if padding_started || !is_token68_value_byte(byte) { - return false; - } - saw_value = true; - } - saw_value +async fn perform_token_exchange( + token_endpoint: &str, + params: &TokenExchangeParams<'_>, +) -> Result { + oauth::post_oauth_token_exchange(&TOKEN_GRANT_HTTP_CLIENT, token_endpoint, params).await } -fn is_token68_value_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/') -} +pub use oauth::validate_access_token; fn token_cache_ttl_seconds(cache_ttl_override: i64, expires_in: i64) -> i64 { if cache_ttl_override > 0 { @@ -483,73 +399,52 @@ fn effective_jwt_svid_audience(token_endpoint: &str, jwt_svid_audience: &str) -> } } -fn effective_client_assertion_type(client_assertion_type: &str) -> &str { - if client_assertion_type.trim().is_empty() { - DEFAULT_CLIENT_ASSERTION_TYPE - } else { - client_assertion_type - } +fn supervisor_gateway_endpoint_from_env() -> Result { + std::env::var(sandbox_env::ENDPOINT) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| miette::miette!("{} not set", sandbox_env::ENDPOINT)) } -fn token_cache_key( - provider_name: &str, - token_endpoint: &str, - jwt_svid_audience: &str, - client_assertion_type: &str, - audience: &str, - scopes: &[String], -) -> String { - format!( - "{}\t{}\t{}\t{}\t{}\t{}", - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type, - audience, - scopes.join(" ") - ) +fn supervisor_sandbox_id_from_env() -> Result { + std::env::var(sandbox_env::SANDBOX_ID) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| miette::miette!("{} not set", sandbox_env::SANDBOX_ID)) } -fn token_grant_failure_message(status: reqwest::StatusCode, body: &str) -> String { - let Ok(error_response) = serde_json::from_str::(body) else { - return format!("token grant failed with status {status}"); - }; +fn parse_provider_credential_key(key: &str) -> Result<(&str, &str)> { + let provider_and_credential = key + .rsplit_once('\t') + .map_or(key, |(_, provider_and_credential)| provider_and_credential); + provider_and_credential.split_once(':').ok_or_else(|| { + miette::miette!("dynamic token grant key is missing provider credential identity") + }) +} - let error = error_response - .error - .as_deref() - .map(sanitize_oauth_error_field) - .filter(|value| !value.is_empty()); - let description = error_response - .error_description - .as_deref() - .map(sanitize_oauth_error_field) - .filter(|value| !value.is_empty()); - - match (error, description) { - (Some(error), Some(description)) => { - format!( - "token grant failed with status {status}: error={error}; error_description={description}" - ) - } - (Some(error), None) => { - format!("token grant failed with status {status}: error={error}") - } - (None, Some(description)) => { - format!("token grant failed with status {status}: error_description={description}") - } - (None, None) => format!("token grant failed with status {status}"), - } +struct TokenCacheKeyInput<'a> { + provider_name: &'a str, + token_endpoint: &'a str, + jwt_svid_audience: &'a str, + client_assertion_type: &'a str, + audience: &'a str, + scopes: &'a [String], + grant_type: ProviderCredentialTokenGrantType, + requested_token_type: &'a str, } -fn sanitize_oauth_error_field(value: &str) -> String { - value - .chars() - .map(|ch| if ch.is_control() { ' ' } else { ch }) - .take(MAX_OAUTH_ERROR_FIELD_LEN) - .collect::() - .trim() - .to_string() +fn token_cache_key(input: TokenCacheKeyInput<'_>) -> String { + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + input.provider_name, + input.token_endpoint, + input.jwt_svid_audience, + input.client_assertion_type, + input.audience, + input.scopes.join(" "), + input.grant_type as i32, + input.requested_token_type + ) } /// Get current Unix timestamp in milliseconds. @@ -564,6 +459,7 @@ fn current_time_ms() -> i64 { #[cfg(test)] mod tests { use super::*; + use openshell_core::oauth::{ACCESS_TOKEN_TYPE, DEFAULT_CLIENT_ASSERTION_TYPE}; use std::collections::HashMap; use std::sync::{ Arc, @@ -778,16 +674,17 @@ mod tests { audience: input.audience, scopes: input.scopes, cache_ttl_override: input.cache_ttl_override, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: "", }, move |_| { let grant_calls = input.grant_calls.clone(); async move { let call = grant_calls.fetch_add(1, Ordering::SeqCst) + 1; - Ok(TokenResponse { + Ok(OAuthTokenResponse { access_token: format!("token-{call}"), token_type: "Bearer".to_string(), expires_in: input.expires_in, - scope: input.scopes.join(" "), }) } }, @@ -814,6 +711,8 @@ mod tests { audience, scopes, cache_ttl_override, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: "", }, |_| async { Err(miette::miette!("grant should not be called on cache hit")) }, ) @@ -855,103 +754,98 @@ mod tests { } #[test] - fn validate_access_token_accepts_token68_values() { - for token in [ - "abcXYZ123-._~+/", - "eyJhbGciOiJSUzI1NiJ9.payload.sig", - "token==", - ] { - validate_access_token(token).expect("token68 bearer token should be accepted"); - } - } + fn token_cache_key_varies_by_resource_audience_and_scopes() { + let provider_name = "alpha.default.svc.cluster.local\t80\t\tprovider:access_token"; + let token_endpoint = + "https://auth.example.com/realms/openshell/protocol/openid-connect/token"; + let jwt_svid_audience = "https://auth.example.com/realms/openshell"; + let alpha_scopes = ["alpha".to_string()]; + let delta_scopes = ["delta".to_string()]; + let base = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let different_audience = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "delta", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let different_scopes = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &delta_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let different_assertion_type = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + audience: "alpha", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); - #[test] - fn validate_access_token_rejects_header_injection_and_non_token68_values() { - for token in [ - "", - "token with spaces", - "token\r\nX-Injected: yes", - "token\u{7f}", - "tokené", - "token=continued", - "==", - ] { - let err = validate_access_token(token) - .expect_err("malformed bearer token should be rejected"); - assert_eq!( - err.to_string(), - "token grant returned a malformed access token" - ); - } + assert_ne!(base, different_audience); + assert_ne!(base, different_scopes); + assert_ne!(base, different_assertion_type); } #[test] - fn token_endpoint_url_allows_https_loopback_and_in_cluster_http() { - for endpoint in [ - "https://auth.example.com/token", - "http://127.0.0.1:8080/token", - "http://[::1]:8080/token", - "http://token-issuer.default.svc.cluster.local/token", - "http://token-issuer.default.svc/token", - ] { - parse_token_endpoint_url(endpoint).expect("token endpoint should be allowed"); - } - } + fn token_cache_key_varies_by_provider_env_revision_prefix() { + let token_endpoint = + "https://auth.example.com/realms/openshell/protocol/openid-connect/token"; + let jwt_svid_audience = "https://auth.example.com/realms/openshell"; + let scopes = ["alpha".to_string()]; + let revision_one = token_cache_key(TokenCacheKeyInput { + provider_name: "api.example.test\t443\t/v1/**\trev:1\tprovider:access_token", + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::TokenExchange, + requested_token_type: ACCESS_TOKEN_TYPE, + }); + let revision_two = token_cache_key(TokenCacheKeyInput { + provider_name: "api.example.test\t443\t/v1/**\trev:2\tprovider:access_token", + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::TokenExchange, + requested_token_type: ACCESS_TOKEN_TYPE, + }); - #[test] - fn token_endpoint_url_rejects_plain_http_non_cluster_hosts() { - for endpoint in [ - "http://auth.example.com/token", - "http://keycloak/realms/openshell/protocol/openid-connect/token", - "http://token-issuer.default.svc.evil.com/token", - "ftp://auth.example.com/token", - "/relative/token", - ] { - assert!( - parse_token_endpoint_url(endpoint).is_err(), - "token endpoint should be rejected: {endpoint}" - ); - } + assert_ne!(revision_one, revision_two); } #[test] - fn token_cache_key_varies_by_resource_audience_and_scopes() { - let base = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "alpha", - &["alpha".to_string()], - ); - let different_audience = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "delta", - &["alpha".to_string()], - ); - let different_scopes = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "alpha", - &["delta".to_string()], - ); - let different_assertion_type = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", - "alpha", - &["alpha".to_string()], + fn provider_credential_key_parser_ignores_revision_segment() { + assert_eq!( + parse_provider_credential_key( + "api.example.test\t443\t/v1/**\trev:42\tprovider:access_token" + ) + .expect("parse provider credential key"), + ("provider", "access_token") ); - - assert_ne!(base, different_audience); - assert_ne!(base, different_scopes); - assert_ne!(base, different_assertion_type); } #[test] @@ -1077,14 +971,16 @@ mod tests { let jwt_svid_audience = "https://auth.example.com"; let audience = "api://resource"; - let cache_key = token_cache_key( + let cache_key = token_cache_key(TokenCacheKeyInput { provider_name, token_endpoint, jwt_svid_audience, - DEFAULT_CLIENT_ASSERTION_TYPE, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, audience, - &scopes, - ); + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: ACCESS_TOKEN_TYPE, + }); cache.set( cache_key, "expired-token".to_string(), @@ -1109,57 +1005,6 @@ mod tests { assert_eq!(grant_calls.load(Ordering::SeqCst), 1); } - #[tokio::test] - async fn obtain_provider_token_rejects_malformed_token_before_cache() { - let cache = TokenCache::new(); - let scopes = vec!["read".to_string()]; - let provider_name = "api.example.test\t443\t/v1/**\tprovider:access_token"; - let token_endpoint = "https://auth.example.com/token"; - let jwt_svid_audience = "https://auth.example.com"; - let audience = "api://resource"; - - let err = obtain_provider_token_with_grant( - ObtainProviderTokenInput { - cache: &cache, - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, - audience, - scopes: &scopes, - cache_ttl_override: 0, - }, - |_| async { - Ok(TokenResponse { - access_token: "access-123\r\nX-Injected: yes".to_string(), - token_type: "Bearer".to_string(), - expires_in: 60, - scope: "read".to_string(), - }) - }, - ) - .await - .expect_err("malformed access token should fail before caching"); - - let cache_key = token_cache_key( - provider_name, - token_endpoint, - jwt_svid_audience, - DEFAULT_CLIENT_ASSERTION_TYPE, - audience, - &scopes, - ); - - assert_eq!( - err.to_string(), - "token grant returned a malformed access token" - ); - assert!( - cache.get(&cache_key).is_none(), - "malformed access token must not be cached" - ); - } - #[tokio::test] async fn obtain_provider_token_cache_ttl_override_extends_zero_expires_in() { let cache = TokenCache::new(); @@ -1382,43 +1227,4 @@ mod tests { .contains("failed to parse token response as JSON") ); } - - #[test] - fn token_grant_failure_message_reports_oauth_error_fields() { - let message = token_grant_failure_message( - reqwest::StatusCode::UNAUTHORIZED, - r#"{"error":"invalid_client","error_description":"Invalid client credentials"}"#, - ); - - assert_eq!( - message, - "token grant failed with status 401 Unauthorized: error=invalid_client; error_description=Invalid client credentials" - ); - } - - #[test] - fn token_grant_failure_message_omits_unstructured_response_body() { - let message = token_grant_failure_message( - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - "internal error containing implementation details", - ); - - assert_eq!( - message, - "token grant failed with status 500 Internal Server Error" - ); - } - - #[test] - fn token_grant_failure_message_sanitizes_oauth_error_fields() { - let long_description = "a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 20); - let body = - format!(r#"{{"error":"invalid_client\n","error_description":"{long_description}"}}"#); - let message = token_grant_failure_message(reqwest::StatusCode::UNAUTHORIZED, &body); - - assert!(!message.contains('\n')); - assert!(message.contains("error=invalid_client")); - assert!(message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN))); - assert!(!message.contains(&"a".repeat(MAX_OAUTH_ERROR_FIELD_LEN + 1))); - } } diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 85e57c14c5..f95c5a3e81 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -24,10 +24,20 @@ //! upstream proxy or disable proxying with `NO_PROXY=*`. //! //! Scope and invariants: -//! - Only `http://` proxy URLs are supported. Configuration is fail-closed: -//! any present-but-invalid driver-supplied setting — an empty argument -//! value, an unsupported (`https://`, SOCKS) or malformed proxy URL, an -//! unreadable auth file, a malformed credential, or an auth file without the explicit +//! - `http://` and `https://` proxy URLs are supported; SOCKS proxies are +//! rejected. For an `https://` proxy the supervisor opens a TLS connection +//! to the proxy first and runs the CONNECT handshake inside it, verifying +//! the proxy certificate against the built-in Mozilla roots, the system CA +//! bundle, and an optional operator corporate CA bundle +//! (`--upstream-proxy-ca-bundle`). That same corporate CA is also folded +//! into the sandbox trust bundle and the L7 upstream verification store at +//! startup (see `run.rs`): a TLS-intercepting corporate proxy re-signs +//! tunneled server certificates with its CA, so trusting it only for the +//! proxy-listener handshake would make every intercepted upstream handshake +//! fail. Configuration is fail-closed: any present-but-invalid +//! driver-supplied setting — an empty argument value, an unsupported +//! (SOCKS) or malformed proxy URL, an unreadable auth file or CA bundle, a +//! malformed credential, or an auth file without the explicit //! cleartext-credential acknowledgement — is a fatal startup error rather //! than being silently ignored, so a typo can never quietly downgrade the //! operator's egress boundary to direct dialing or unauthenticated proxy @@ -48,12 +58,17 @@ use std::io::{Error as IoError, ErrorKind}; use std::net::{IpAddr, SocketAddr}; use std::pin::Pin; +use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; use base64::Engine as _; +use openshell_core::net::set_tcp_nodelay_best_effort; +use rustls::ClientConfig; +use rustls::pki_types::ServerName; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::net::TcpStream; +use tokio_rustls::TlsConnector; use tracing::debug; /// Upper bound on the corporate proxy's CONNECT response header block. @@ -71,6 +86,11 @@ pub struct ProxyEndpoint { /// Pre-computed `Basic ` header value from the proxy auth file. /// Never logged. proxy_authorization: Option, + /// TLS client config for an `https://` proxy; `None` for a plain `http://` + /// proxy. When set, the connection to the proxy is wrapped in TLS (the + /// proxy certificate verified against these roots) before the CONNECT + /// handshake. + tls: Option>, } impl ProxyEndpoint { @@ -87,6 +107,7 @@ impl std::fmt::Debug for ProxyEndpoint { .field("host", &self.host) .field("port", &self.port) .field("proxy_authorization", &self.proxy_authorization.is_some()) + .field("tls", &self.tls.is_some()) .finish() } } @@ -310,7 +331,8 @@ pub struct UpstreamProxyConfig { /// / `false` genuinely means "not configured by the operator". #[derive(Debug, Clone, Default)] pub struct UpstreamProxyArgs { - /// `http://host:port` corporate proxy URL, or `None` for direct egress. + /// `http://host:port` or `https://host:port` corporate proxy URL, or + /// `None` for direct egress. pub https_proxy: Option, /// Comma-separated `NO_PROXY` list. pub no_proxy: Option, @@ -321,6 +343,12 @@ pub struct UpstreamProxyArgs { pub proxy_auth_allow_insecure: bool, /// Send the destination hostname in CONNECT instead of a validated IP. pub proxy_connect_by_hostname: bool, + /// Path to a PEM bundle of extra CAs trusted for the corporate proxy: the + /// TLS handshake with an `https://` proxy and, because TLS-intercepting + /// proxies re-sign tunneled server certificates with the same CA, the + /// sandbox trust bundle and upstream verification too (folded in by + /// `run.rs`). + pub proxy_ca_bundle: Option, } // Supervisor CLI flag names for the corporate-proxy settings, used as the @@ -330,6 +358,7 @@ const ARG_NO_PROXY: &str = "--upstream-no-proxy"; const ARG_PROXY_AUTH_FILE: &str = "--upstream-proxy-auth-file"; const ARG_PROXY_AUTH_ALLOW_INSECURE: &str = "--upstream-proxy-auth-allow-insecure"; const ARG_PROXY_CONNECT_BY_HOSTNAME: &str = "--upstream-proxy-connect-by-hostname"; +pub(crate) const ARG_PROXY_CA_BUNDLE: &str = "--upstream-proxy-ca-bundle"; impl UpstreamProxyConfig { /// Build the corporate proxy configuration from the driver-supplied @@ -370,6 +399,8 @@ impl UpstreamProxyConfig { args.proxy_auth_allow_insecure.then(|| "true".to_string()) } else if name == ARG_PROXY_CONNECT_BY_HOSTNAME { args.proxy_connect_by_hostname.then(|| "true".to_string()) + } else if name == ARG_PROXY_CA_BUNDLE { + args.proxy_ca_bundle.clone() } else { None } @@ -397,7 +428,8 @@ impl UpstreamProxyConfig { let auth_allow_insecure = var(ARG_PROXY_AUTH_ALLOW_INSECURE)?; let connect_by_hostname_raw = var(ARG_PROXY_CONNECT_BY_HOSTNAME)?; let no_proxy_list = var(ARG_NO_PROXY)?; - let Some(mut https) = https else { + let ca_bundle = var(ARG_PROXY_CA_BUNDLE)?; + let Some((mut https, secure)) = https else { // Auxiliary proxy settings without a proxy mean the operator // believed a proxy boundary was in effect; refuse rather than // silently running with direct egress. @@ -406,6 +438,7 @@ impl UpstreamProxyConfig { (ARG_PROXY_AUTH_ALLOW_INSECURE, &auth_allow_insecure), (ARG_PROXY_CONNECT_BY_HOSTNAME, &connect_by_hostname_raw), (ARG_NO_PROXY, &no_proxy_list), + (ARG_PROXY_CA_BUNDLE, &ca_bundle), ] { if value.is_some() { return Err(format!("{name} is set but no upstream proxy is configured")); @@ -428,11 +461,10 @@ impl UpstreamProxyConfig { }; // Cleartext-credential acknowledgement. `Proxy-Authorization: Basic` - // travels over plain TCP to the http:// proxy, so credentials are - // only sent when the operator explicitly opted in. The compute driver - // enforces the same pairing at sandbox-create time; enforcing it here - // as well keeps the supervisor fail-closed against a bypassed or - // foreign driver. + // travels over plain TCP to an http:// proxy, so credentials are + // only sent when the operator explicitly opted in. For an https:// + // proxy the credential is inside the verified TLS session, so the + // acknowledgement is unnecessary (but tolerated). let allow_insecure = match auth_allow_insecure.as_deref().map(str::trim) { None => false, Some("true") => true, @@ -447,7 +479,7 @@ impl UpstreamProxyConfig { "{ARG_PROXY_AUTH_ALLOW_INSECURE} is set but no {ARG_PROXY_AUTH_FILE} is configured" )); } - if auth_file.is_some() && !allow_insecure { + if auth_file.is_some() && !allow_insecure && !secure { return Err(format!( "{ARG_PROXY_AUTH_FILE} sends the credential as cleartext Basic auth \ over the plain-TCP proxy connection; refusing without \ @@ -467,6 +499,25 @@ impl UpstreamProxyConfig { https.proxy_authorization = Some(header); } + // Wrap the proxy connection in TLS for an `https://` proxy. The + // corporate CA bundle (also folded into the sandbox trust bundle by + // `run.rs`) is trusted alongside the built-in and system roots; a bare + // `https://` proxy with no bundle verifies against those roots only. + if secure { + let corporate_pem = ca_bundle + .as_deref() + .map(|path| read_proxy_ca_bundle(path, ARG_PROXY_CA_BUNDLE)) + .transpose()?; + https.tls = Some(build_proxy_tls_config(corporate_pem.as_deref())); + } else if let Some(path) = ca_bundle.as_deref() { + // An `http://` proxy is dialed in plaintext, so the CA bundle is + // not used for the proxy connection itself. It is still validated + // fail-closed here (and folded into the sandbox trust bundle by + // `run.rs`) for the TLS-intercepting-proxy case, where a proxy + // reached over plain HTTP re-signs tunneled upstream certificates. + read_proxy_ca_bundle(path, ARG_PROXY_CA_BUNDLE)?; + } + Ok(Some(Self { https, no_proxy: NoProxy::parse(&no_proxy_list.unwrap_or_default()), @@ -517,27 +568,75 @@ impl UpstreamProxyConfig { } } -/// Parse an `http://host:port` proxy URL with the same validation rules the -/// compute driver applies at sandbox-create time +/// Parse an `http://host:port` or `https://host:port` proxy URL with the same +/// validation rules the compute driver applies at sandbox-create time /// ([`parse_upstream_proxy_url`](openshell_core::driver_utils::parse_upstream_proxy_url)). /// +/// Returns the endpoint (with `tls` still unset — the caller attaches the +/// shared TLS config afterward) and whether the URL used the `https://` +/// scheme, so the caller knows to wrap the proxy connection in TLS. +/// /// Credentials are never taken from the URL: they are delivered out of band /// through the root-only auth-file mount (`--upstream-proxy-auth-file`) so /// they never appear in config or container metadata. /// /// # Errors /// -/// Rejects unsupported schemes (TLS or SOCKS proxies), inline `user:pass@` +/// Rejects unsupported schemes (SOCKS proxies), inline `user:pass@` /// credentials, and malformed addresses. The error names `var_name` so the /// operator can locate the offending setting. -fn parse_proxy_url(raw: &str, var_name: &str) -> Result { +fn parse_proxy_url(raw: &str, var_name: &str) -> Result<(ProxyEndpoint, bool), String> { let addr = openshell_core::driver_utils::parse_upstream_proxy_url(raw) .map_err(|err| format!("{var_name} is invalid: {err}"))?; - Ok(ProxyEndpoint { - host: addr.host, - port: addr.port, - proxy_authorization: None, - }) + Ok(( + ProxyEndpoint { + host: addr.host, + port: addr.port, + proxy_authorization: None, + tls: None, + }, + addr.secure, + )) +} + +/// Read and validate the operator corporate proxy CA bundle PEM at `path`. +/// +/// A TLS-intercepting corporate proxy signs both its own listener certificate +/// and the re-signed certificates of tunneled destinations with this CA, so +/// the bundle is trusted for proxy-listener TLS (`https://` proxy URLs), for +/// upstream re-encryption verification, and by sandbox workload processes. +/// +/// Fail-closed to match the rest of the operator-owned proxy configuration: +/// the operator explicitly pointed at this file, so an unreadable file or one +/// with no usable certificate is a fatal startup error rather than a silent +/// fall-back to the built-in roots that would quietly weaken the trust +/// boundary. The error names `var_name` so the operator can locate the setting. +pub(crate) fn read_proxy_ca_bundle(path: &str, var_name: &str) -> Result { + // Shared with the compute driver, which validates the same file on the + // gateway host before staging it, so host acceptance and guest acceptance + // cannot diverge. It also bounds the read: the file arrives from the + // driver, but a bundle the size of the sandbox disk should fail rather + // than be loaded whole. + openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file(path, var_name) +} + +/// Build the TLS client config used to connect to an `https://` corporate +/// proxy and to verify re-signed upstream certificates: built-in Mozilla +/// roots + the system CA bundle + the optional corporate CA bundle PEM. +/// +/// Reuses [`build_upstream_client_config`](crate::l7::tls::build_upstream_client_config), +/// which already folds the built-in and passed-in roots, so the proxy-listener +/// trust store matches the L7 upstream store exactly. +fn build_proxy_tls_config(corporate_ca_pem: Option<&str>) -> Arc { + let mut bundle = crate::l7::tls::read_system_ca_bundle(); + if let Some(pem) = corporate_ca_pem { + if !bundle.is_empty() && !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(pem); + } + crate::l7::tls::build_upstream_client_config(&bundle) + .expect("corporate proxy TLS config must be valid") } /// Build a `Proxy-Authorization: Basic ` header value from a raw @@ -564,7 +663,78 @@ fn basic_auth_header(credential: &str) -> Result { )) } -/// A `TcpStream` that first replays bytes already read from the socket. +/// The connection to the corporate proxy (or a direct dial): either plain +/// TCP or, for an `https://` proxy, a TLS session wrapping the TCP socket. +/// +/// Direct dials and `http://` proxy tunnels are `Plain`; only the transport +/// to the proxy differs, so both variants behave as transparent byte pipes +/// once the CONNECT handshake completes. +#[derive(Debug)] +enum UpstreamStream { + Plain(TcpStream), + Tls(Box>), +} + +impl AsyncRead for UpstreamStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_read(cx, buf), + Self::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for UpstreamStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_write(cx, buf), + Self::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf), + } + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[std::io::IoSlice<'_>], + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_write_vectored(cx, bufs), + Self::Tls(s) => Pin::new(s.as_mut()).poll_write_vectored(cx, bufs), + } + } + + fn is_write_vectored(&self) -> bool { + match self { + Self::Plain(s) => s.is_write_vectored(), + Self::Tls(s) => s.is_write_vectored(), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_flush(cx), + Self::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_shutdown(cx), + Self::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx), + } + } +} + +/// An upstream connection that first replays bytes already read from the +/// socket. /// /// The CONNECT handshake reads from the proxy socket in chunks, so the read /// that completes the response header block may also contain the first @@ -575,28 +745,61 @@ fn basic_auth_header(credential: &str) -> Result { /// straight through. #[derive(Debug)] pub struct PrefixedStream { - inner: TcpStream, + inner: UpstreamStream, /// Bytes read past the CONNECT header terminator, replayed first. prefix: Vec, /// Read offset into `prefix`. pos: usize, + /// CONNECT target selected for a corporate-proxy tunnel. Direct streams + /// leave this unset; their socket peer is the destination itself. + connect_target: Option, } impl PrefixedStream { - /// Wrap `inner`, replaying `prefix` before socket reads. + /// Wrap a directly dialed or `http://`-tunneled `TcpStream`, replaying + /// `prefix` before socket reads. #[must_use] pub fn new(inner: TcpStream, prefix: Vec) -> Self { + Self::over(UpstreamStream::Plain(inner), prefix) + } + + /// Wrap a directly dialed stream with nothing to replay. + #[must_use] + pub fn without_prefix(inner: TcpStream) -> Self { + Self::new(inner, Vec::new()) + } + + /// Wrap an already-established upstream connection (plain or TLS-wrapped + /// proxy), replaying `prefix` before further reads. + fn over(inner: UpstreamStream, prefix: Vec) -> Self { Self { inner, prefix, pos: 0, + connect_target: None, } } - /// Wrap a directly dialed stream with nothing to replay. + fn with_connect_target(mut self, target: ConnectTarget) -> Self { + self.connect_target = Some(target); + self + } + + /// Return the connected socket peer. For a proxied tunnel this is the + /// corporate proxy, not the ultimate destination. + pub fn peer_addr(&self) -> std::io::Result { + match &self.inner { + UpstreamStream::Plain(s) => s.peer_addr(), + UpstreamStream::Tls(s) => s.get_ref().0.peer_addr(), + } + } + + /// Return the HTTP CONNECT target for a proxied tunnel. An IP target is + /// the exact validated destination selected by the fallback loop; a + /// hostname target means the corporate proxy performs resolution. #[must_use] - pub fn without_prefix(inner: TcpStream) -> Self { - Self::new(inner, Vec::new()) + pub fn connect_target(&self) -> Option { + self.connect_target } } @@ -797,9 +1000,38 @@ async fn connect_via_inner( port: u16, target: ConnectTarget, ) -> std::io::Result { - let mut stream = TcpStream::connect((endpoint.host.as_str(), endpoint.port)).await?; + let tcp = TcpStream::connect((endpoint.host.as_str(), endpoint.port)).await?; + set_tcp_nodelay_best_effort(&tcp); + // For an `https://` proxy, wrap the connection in TLS (verifying the proxy + // certificate against the configured roots) before the CONNECT handshake. + let mut stream = match &endpoint.tls { + Some(config) => { + let server_name = ServerName::try_from(endpoint.host.clone()).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!( + "upstream proxy host '{}' is not a valid TLS server name: {err}", + endpoint.host + ), + ) + })?; + let connector = TlsConnector::from(Arc::clone(config)); + let tls = connector.connect(server_name, tcp).await.map_err(|err| { + IoError::new( + err.kind(), + format!( + "TLS handshake with upstream proxy {} failed: {err}", + endpoint.display_addr() + ), + ) + })?; + UpstreamStream::Tls(Box::new(tls)) + } + None => UpstreamStream::Plain(tcp), + }; - let target = match target { + let connect_target = target; + let target = match connect_target { ConnectTarget::Ip(IpAddr::V6(ip)) => format!("[{ip}]:{port}"), ConnectTarget::Ip(ip) => format!("{ip}:{port}"), ConnectTarget::Hostname if host.contains(':') => format!("[{host}]:{port}"), @@ -857,7 +1089,7 @@ async fn connect_via_inner( ); buf.truncate(used); let overflow = buf.split_off(header_end); - Ok(PrefixedStream::new(stream, overflow)) + Ok(PrefixedStream::over(stream, overflow).with_connect_target(connect_target)) } Some(code) => Err(IoError::other(format!( "upstream proxy {} refused CONNECT to {target}: HTTP {code}", @@ -879,7 +1111,7 @@ mod tests { use super::{ ARG_HTTPS_PROXY as HTTPS_PROXY, ARG_NO_PROXY as NO_PROXY, ARG_PROXY_AUTH_ALLOW_INSECURE as PROXY_AUTH_ALLOW_INSECURE, - ARG_PROXY_AUTH_FILE as PROXY_AUTH_FILE, + ARG_PROXY_AUTH_FILE as PROXY_AUTH_FILE, ARG_PROXY_CA_BUNDLE as PROXY_CA_BUNDLE, ARG_PROXY_CONNECT_BY_HOSTNAME as PROXY_CONNECT_BY_HOSTNAME, }; @@ -897,6 +1129,13 @@ mod tests { config_from(pairs).unwrap().unwrap() } + /// Install the process-wide rustls crypto provider once. Building a + /// `ClientConfig` (for an `https://` proxy) requires it; the install is + /// idempotent, so tests that build TLS configs call this first. + fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + #[test] fn no_env_yields_none() { assert!(config_from(&[]).unwrap().is_none()); @@ -1019,14 +1258,116 @@ mod tests { // to direct dialing or unauthenticated proxy access. #[test] - fn tls_and_socks_proxies_are_fatal() { - for url in ["https://proxy:443", "socks5://proxy:1080"] { + fn socks_proxies_are_fatal() { + // http:// and https:// are supported; other schemes are fatal rather + // than silently ignored. + for url in ["socks5://proxy:1080", "ftp://proxy:21"] { let err = config_from(&[(HTTPS_PROXY, url)]).unwrap_err(); assert!(err.contains(HTTPS_PROXY), "{err}"); assert!(err.contains("scheme"), "{err}"); } } + #[test] + fn https_proxy_scheme_enables_tls_and_requires_explicit_port() { + install_crypto_provider(); + // An explicit port is required (no scheme-default fallback), matching + // the http:// grammar. + let err = config_from(&[(HTTPS_PROXY, "https://proxy.corp.com")]).unwrap_err(); + assert!(err.contains("explicit"), "{err}"); + + let cfg = config_ok(&[(HTTPS_PROXY, "https://proxy.corp.com:3130")]); + assert!( + cfg.https.tls.is_some(), + "https:// proxy dials the proxy over TLS" + ); + assert_eq!(cfg.https.display_addr(), "proxy.corp.com:3130"); + } + + #[test] + fn http_proxy_scheme_stays_plaintext() { + let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy.corp.com:8080")]); + assert!( + cfg.https.tls.is_none(), + "http:// proxy is dialed without TLS" + ); + } + + #[test] + fn ca_bundle_without_a_proxy_is_fatal() { + let err = config_from(&[(PROXY_CA_BUNDLE, "/etc/openshell/tls/proxy/ca-bundle.pem")]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + assert!(err.contains("no upstream proxy"), "{err}"); + } + + #[test] + fn empty_ca_bundle_value_is_fatal() { + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, " "), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn unreadable_ca_bundle_is_fatal_for_https_proxy() { + install_crypto_provider(); + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, "/nonexistent/proxy-ca.pem"), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + assert!(err.contains("could not be read"), "{err}"); + } + + #[test] + fn unreadable_ca_bundle_is_fatal_for_http_proxy_too() { + // A CA bundle with an http:// proxy still describes the intercepting + // proxy's re-sign CA and must be validated fail-closed. + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_CA_BUNDLE, "/nonexistent/proxy-ca.pem"), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_CA_BUNDLE), "{err}"); + } + + #[test] + fn ca_bundle_with_no_certificates_is_fatal() { + install_crypto_provider(); + let bundle = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(bundle.path(), "not a certificate\n").unwrap(); + let path = bundle.path().to_string_lossy().into_owned(); + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, path.as_str()), + ]) + .unwrap_err(); + assert!(err.contains("no PEM certificate blocks"), "{err}"); + } + + #[test] + fn ca_bundle_with_invalid_der_certificates_is_fatal() { + install_crypto_provider(); + let bundle = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + bundle.path(), + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + ) + .unwrap(); + let path = bundle.path().to_string_lossy().into_owned(); + let err = config_from(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_CA_BUNDLE, path.as_str()), + ]) + .unwrap_err(); + assert!(err.contains("no usable trust anchors"), "{err}"); + } + #[test] fn url_userinfo_is_fatal_not_used_as_credentials() { // Inline credentials in the URL must never become the proxy auth; @@ -1084,7 +1425,7 @@ mod tests { } #[test] - fn auth_file_without_insecure_acknowledgement_is_fatal() { + fn auth_file_without_insecure_acknowledgement_is_fatal_for_http_proxy() { // Basic auth over the plain-TCP proxy connection is readable on the // network path; sending it requires the explicit opt-in. let err = config_from(&[ @@ -1096,6 +1437,23 @@ mod tests { assert!(err.contains("cleartext"), "{err}"); } + #[test] + fn auth_file_without_insecure_acknowledgement_is_allowed_for_https_proxy() { + install_crypto_provider(); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "user:secret\n").unwrap(); + let path = file.path().to_str().unwrap().to_string(); + let cfg = config_ok(&[ + (HTTPS_PROXY, "https://proxy.corp.com:3130"), + (PROXY_AUTH_FILE, &path), + ]); + let ep = proxy_endpoint(&cfg, "example.com").unwrap(); + assert!( + ep.proxy_authorization.is_some(), + "https:// proxy must accept auth without the insecure acknowledgement" + ); + } + #[test] fn invalid_insecure_acknowledgement_value_is_fatal() { for value in ["false", "yes", "1", "TRUE"] { @@ -1477,6 +1835,7 @@ mod tests { host: addr.ip().to_string(), port: addr.port(), proxy_authorization: auth.map(str::to_string), + tls: None, } } @@ -1551,6 +1910,11 @@ mod tests { let stream = connect_via_validated(&endpoint, "api.example.com", 443, &addrs) .await .unwrap(); + assert!(matches!( + stream.connect_target(), + Some(ConnectTarget::Ip(ip)) if ip == addrs[1].ip() + )); + assert_eq!(stream.peer_addr().unwrap(), addr); let (first, second, _accepted) = handle.await.unwrap(); drop(stream); assert!( @@ -1654,10 +2018,10 @@ mod tests { // Trusted CA; the client config trusts it, and the fake upstream // server presents a leaf for SERVER_HOSTNAME signed by it. let ca = tls::SandboxCa::generate().unwrap(); - let client_config = tls::build_upstream_client_config(ca.cert_pem()); + let client_config = tls::build_upstream_client_config(ca.cert_pem()).unwrap(); let tls_state = Arc::new(tls::ProxyTlsState::new( tls::CertCache::new(ca), - tls::build_upstream_client_config(""), + tls::build_upstream_client_config("").unwrap(), )); // Fake upstream TLS server: accepts tunneled connections and completes @@ -1874,4 +2238,94 @@ mod tests { assert!(err.contains(PROXY_CONNECT_BY_HOSTNAME), "{err}"); assert!(err.contains("no upstream proxy"), "{err}"); } + + // -- TLS (https://) proxies -- + + /// A fake `https://` proxy: a TLS server with a self-signed cert for + /// 127.0.0.1 that answers CONNECT with 200. Returns the listen address, + /// the server task (yielding the received CONNECT request), and the + /// server certificate PEM to use as the corporate CA bundle. + async fn fake_tls_proxy() -> (SocketAddr, tokio::task::JoinHandle, String) { + install_crypto_provider(); + let key = rcgen::KeyPair::generate().unwrap(); + let cert = rcgen::CertificateParams::new(vec!["127.0.0.1".to_string()]) + .unwrap() + .self_signed(&key) + .unwrap(); + let cert_pem = cert.pem(); + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![cert.der().clone()], + rustls::pki_types::PrivateKeyDer::try_from(key.serialize_der()).unwrap(), + ) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut tls = acceptor.accept(socket).await.unwrap(); + let mut buf = vec![0u8; 4096]; + let mut used = 0; + loop { + let n = tls.read(&mut buf[used..]).await.unwrap(); + used += n; + if n == 0 || buf[..used].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + tls.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + tls.flush().await.unwrap(); + String::from_utf8_lossy(&buf[..used]).into_owned() + }); + (addr, handle, cert_pem) + } + + #[tokio::test] + async fn connect_via_https_proxy_with_corporate_ca_bundle() { + let (addr, handle, cert_pem) = fake_tls_proxy().await; + let ca_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(ca_file.path(), cert_pem).unwrap(); + + let proxy_url = format!("https://{addr}"); + let ca_path = ca_file.path().to_string_lossy().into_owned(); + let cfg = config_ok(&[ + (HTTPS_PROXY, proxy_url.as_str()), + (PROXY_CA_BUNDLE, ca_path.as_str()), + ]); + let endpoint = &cfg.https; + assert!(endpoint.tls.is_some()); + + let stream = connect_via(endpoint, "api.example.com", 443, ConnectTarget::Hostname) + .await + .unwrap(); + assert!(matches!(stream.inner, UpstreamStream::Tls(_))); + drop(stream); + let request = handle.await.unwrap(); + assert!(request.starts_with("CONNECT api.example.com:443 HTTP/1.1\r\n")); + } + + #[tokio::test] + async fn connect_via_https_proxy_rejects_untrusted_cert() { + // No corporate CA bundle: the self-signed proxy cert must not verify + // against the built-in / system roots, so the handshake fails closed. + let (addr, _handle, _cert_pem) = fake_tls_proxy().await; + let proxy_url = format!("https://{addr}"); + let cfg = config_ok(&[(HTTPS_PROXY, proxy_url.as_str())]); + let endpoint = &cfg.https; + + let err = connect_via(endpoint, "api.example.com", 443, ConnectTarget::Hostname) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("TLS handshake with upstream proxy"), + "{err}" + ); + } } diff --git a/crates/openshell-supervisor-process/BUILD.bazel b/crates/openshell-supervisor-process/BUILD.bazel deleted file mode 100644 index 0cd162a7b6..0000000000 --- a/crates/openshell-supervisor-process/BUILD.bazel +++ /dev/null @@ -1,28 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-supervisor-process", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - compile_data = glob(["src/skills/**/*.md"]), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-supervisor-process_test", - crate = ":openshell-supervisor-process", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-supervisor-process", - ":openshell-supervisor-process_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-supervisor-process/Cargo.toml b/crates/openshell-supervisor-process/Cargo.toml index 575923d4c4..2e2120f1d0 100644 --- a/crates/openshell-supervisor-process/Cargo.toml +++ b/crates/openshell-supervisor-process/Cargo.toml @@ -17,11 +17,13 @@ openshell-policy = { path = "../openshell-policy" } anyhow = { workspace = true } base64 = { workspace = true } +bytes = { workspace = true } hex = "0.4" +ipnet = "2" miette = { workspace = true } nix = { workspace = true } rand = "0.10" -russh = "0.61" +russh = "0.62" serde_json = { workspace = true } sha2 = { workspace = true } tokio = { workspace = true } @@ -39,6 +41,7 @@ rustix = { workspace = true } capctl = "0.2.4" landlock = "0.4" seccompiler = "0.5" +socket2 = { workspace = true } tempfile = "3" [dev-dependencies] diff --git a/crates/openshell-supervisor-process/src/debug_rpc.rs b/crates/openshell-supervisor-process/src/debug_rpc.rs index f583d54dcd..6f885a69db 100644 --- a/crates/openshell-supervisor-process/src/debug_rpc.rs +++ b/crates/openshell-supervisor-process/src/debug_rpc.rs @@ -105,7 +105,9 @@ async fn run_get_sandbox_config(args: &[String]) -> Result { async fn run_refresh() -> Result { let mut client = open_client().await?; let resp = client - .refresh_sandbox_token(RefreshSandboxTokenRequest {}) + .refresh_sandbox_token(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }) .await; match resp { Ok(r) => { diff --git a/crates/openshell-supervisor-process/src/identity.rs b/crates/openshell-supervisor-process/src/identity.rs index 6a9a785542..df79a4137d 100644 --- a/crates/openshell-supervisor-process/src/identity.rs +++ b/crates/openshell-supervisor-process/src/identity.rs @@ -332,6 +332,68 @@ fn find_group_by_name(path: &Path, name: &str) -> Result> { }) } +/// Resolve supplementary groups declared for an OCI named user without +/// consulting NSS. Numeric OCI users have no trustworthy group-membership +/// name and therefore receive no supplementary groups. +pub fn resolve_oci_supplementary_gids(declaration: &str, primary_gid: u32) -> Result> { + resolve_oci_supplementary_gids_at(declaration, primary_gid, Path::new(GROUP_PATH)) +} + +fn resolve_oci_supplementary_gids_at( + declaration: &str, + primary_gid: u32, + group_path: &Path, +) -> Result> { + let (user, _) = split_oci_declaration(declaration); + validate_component(user, "OCI user")?; + if user.parse::().is_ok() { + return Ok(Vec::new()); + } + + let content = read_account_file(group_path)?; + let mut gids = vec![primary_gid]; + for line in content.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.len() > MAX_ACCOUNT_LINE_SIZE { + return Err(miette::miette!( + "account file '{}' contains an oversized line", + group_path.display() + )); + } + let fields = line.split(':').collect::>(); + if fields.len() != 4 + || fields + .iter() + .any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE) + { + return Err(miette::miette!( + "group membership entry in '{}' is malformed", + group_path.display() + )); + } + if !fields[3].split(',').any(|member| member == user) { + continue; + } + let gid = fields[2].parse::().map_err(|_| { + miette::miette!( + "group membership GID in '{}' is malformed", + group_path.display() + ) + })?; + if gid == 0 { + return Err(miette::miette!( + "OCI user '{user}' is a member of prohibited GID 0" + )); + } + gids.push(gid); + } + gids.sort_unstable(); + gids.dedup(); + Ok(gids) +} + fn find_unique( path: &Path, mut select: impl FnMut(&[&str]) -> Option>, @@ -600,6 +662,10 @@ mod tests { gid: 1235 } ); + assert_eq!( + DriverIdentity::from_values(None, Some("500".into()), Some("30".into())).unwrap(), + DriverIdentity::Resolved { uid: 500, gid: 30 } + ); assert_eq!( DriverIdentity::from_values( Some(String::new()), @@ -665,6 +731,35 @@ mod tests { ); } + #[test] + fn named_oci_user_resolves_bounded_supplementary_groups() { + let (_dir, _passwd, group) = account_files( + "", + "primary:x:1235:\nvideo:x:44:app,other\naudio:x:63:other\nrender:x:107:app\n", + ); + + let gids = resolve_oci_supplementary_gids_at("app:primary", 1235, &group).unwrap(); + assert_eq!(gids, vec![44, 107, 1235]); + } + + #[test] + fn numeric_oci_user_has_no_named_supplementary_groups() { + let dir = tempdir().unwrap(); + let missing_group = dir.path().join("missing-group"); + + let gids = resolve_oci_supplementary_gids_at("1234:1235", 1235, &missing_group).unwrap(); + assert!(gids.is_empty()); + } + + #[test] + fn oci_supplementary_membership_rejects_root_group() { + let (_dir, _passwd, group) = account_files("", "root:x:0:app\n"); + + let error = + resolve_oci_supplementary_gids_at("app", 1235, &group).expect_err("GID 0 must fail"); + assert!(error.to_string().contains("prohibited GID 0")); + } + #[test] fn missing_unknown_ambiguous_and_root_identities_fail() { let (_dir, passwd, group) = account_files( diff --git a/crates/openshell-supervisor-process/src/lib.rs b/crates/openshell-supervisor-process/src/lib.rs index ca93230929..743942faa4 100644 --- a/crates/openshell-supervisor-process/src/lib.rs +++ b/crates/openshell-supervisor-process/src/lib.rs @@ -13,6 +13,7 @@ pub mod debug_rpc; #[cfg(unix)] pub mod identity; pub mod log_push; +pub mod main_session; pub mod managed_children; pub mod process; pub mod run; diff --git a/crates/openshell-supervisor-process/src/log_push.rs b/crates/openshell-supervisor-process/src/log_push.rs index 01e373ba8e..256fbff4a4 100644 --- a/crates/openshell-supervisor-process/src/log_push.rs +++ b/crates/openshell-supervisor-process/src/log_push.rs @@ -27,10 +27,7 @@ pub struct LogPushLayer { impl LogPushLayer { pub fn new(sandbox_id: String, tx: mpsc::Sender) -> Self { - let max_level = std::env::var("OPENSHELL_LOG_PUSH_LEVEL") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(tracing::Level::INFO); + let max_level = parse_max_level(std::env::var("OPENSHELL_LOG_PUSH_LEVEL").ok().as_deref()); Self { sandbox_id, tx, @@ -39,6 +36,12 @@ impl LogPushLayer { } } +/// Resolve the push level filter, defaulting to `INFO` when unset or unparseable. +fn parse_max_level(raw: Option<&str>) -> tracing::Level { + raw.and_then(|s| s.parse().ok()) + .unwrap_or(tracing::Level::INFO) +} + impl Layer for LogPushLayer { fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { let meta = event.metadata(); @@ -309,3 +312,209 @@ impl tracing::field::Visit for LogVisitor { } } } + +#[cfg(test)] +mod tests { + use super::*; + use openshell_ocsf::{ + ActionId, ActivityId, DispositionId, Endpoint, NetworkActivityBuilder, SandboxContext, + SeverityId, StatusId, ocsf_emit, + }; + use tracing_subscriber::layer::SubscriberExt; + + fn ocsf_ctx() -> SandboxContext { + SandboxContext { + sandbox_id: "sb-test".to_string(), + sandbox_name: "test-sandbox".to_string(), + container_image: "openshell/sandbox:test".to_string(), + hostname: "test-host".to_string(), + product_version: "0.0.0".to_string(), + proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + proxy_port: 8888, + } + } + + /// Capture lines emitted by `f` with an `INFO` level filter. + fn capture(capacity: usize, f: impl FnOnce()) -> Vec { + let (tx, mut rx) = mpsc::channel::(capacity); + let layer = LogPushLayer { + sandbox_id: "sb-test".to_string(), + tx, + max_level: tracing::Level::INFO, + }; + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, f); + + let mut out = Vec::new(); + while let Ok(line) = rx.try_recv() { + out.push(line); + } + out + } + + #[test] + fn ocsf_events_push_shorthand_with_ocsf_level_and_no_fields() { + let event = NetworkActivityBuilder::new(&ocsf_ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain("blocked.example.com", 443)) + .message("CONNECT denied blocked.example.com:443".to_string()) + .build(); + let expected_shorthand = event.format_shorthand(); + + let lines = capture(16, || ocsf_emit!(event)); + + assert_eq!(lines.len(), 1); + let line = &lines[0]; + assert_eq!(line.level, "OCSF"); + assert_eq!(line.target, openshell_ocsf::OCSF_TARGET); + assert_eq!(line.source, "sandbox"); + assert_eq!(line.sandbox_id, "sb-test"); + assert_eq!(line.message, expected_shorthand); + assert!(line.fields.is_empty()); + assert!(line.timestamp_ms > 0); + } + + #[test] + fn non_ocsf_events_use_visitor_extraction() { + let lines = capture(16, || { + tracing::info!(target: "test_target", answer = 42, name = "widget", "hello"); + }); + + assert_eq!(lines.len(), 1); + let line = &lines[0]; + assert_eq!(line.level, "INFO"); + assert_eq!(line.target, "test_target"); + assert_eq!(line.source, "sandbox"); + assert_eq!(line.message, "hello"); + assert_eq!(line.fields.get("name").map(String::as_str), Some("widget")); + assert_eq!(line.fields.get("answer").map(String::as_str), Some("42")); + assert!(!line.fields.contains_key("message")); + } + + #[test] + fn events_without_a_message_field_fall_back_to_the_event_name() { + let lines = capture(16, || { + tracing::info!(target: "test_target", answer = 1); + }); + + assert_eq!(lines.len(), 1); + assert!( + lines[0].message.starts_with("event "), + "expected event-name fallback, got {:?}", + lines[0].message + ); + } + + #[test] + fn events_below_the_max_level_are_filtered() { + let lines = capture(16, || { + tracing::debug!(target: "test_target", "debug line"); + tracing::trace!(target: "test_target", "trace line"); + tracing::info!(target: "test_target", "info line"); + tracing::warn!(target: "test_target", "warn line"); + }); + + let messages: Vec<_> = lines.iter().map(|l| l.message.as_str()).collect(); + assert_eq!(messages, vec!["info line", "warn line"]); + } + + #[test] + fn lines_are_dropped_when_the_channel_is_full() { + let lines = capture(2, || { + for i in 0..3 { + tracing::info!(target: "test_target", "line {i}"); + } + }); + + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].message, "line 0"); + assert_eq!(lines[1].message, "line 1"); + } + + #[test] + fn parse_max_level_defaults_to_info() { + assert_eq!(parse_max_level(None), tracing::Level::INFO); + assert_eq!(parse_max_level(Some("not-a-level")), tracing::Level::INFO); + assert_eq!(parse_max_level(Some("debug")), tracing::Level::DEBUG); + assert_eq!(parse_max_level(Some("TRACE")), tracing::Level::TRACE); + assert_eq!(parse_max_level(Some("warn")), tracing::Level::WARN); + } + + fn test_line(message: &str) -> SandboxLogLine { + SandboxLogLine { + sandbox_id: "sb-test".to_string(), + timestamp_ms: 1, + level: "INFO".to_string(), + target: "t".to_string(), + message: message.to_string(), + source: "sandbox".to_string(), + fields: std::collections::HashMap::new(), + } + } + + #[tokio::test] + async fn drain_during_backoff_buffers_up_to_the_cap_and_drops_the_rest() { + let (tx, mut rx) = mpsc::channel::(1024); + for i in 0..250 { + tx.try_send(test_line(&format!("line {i}"))).unwrap(); + } + drop(tx); + + let mut batch = Vec::new(); + drain_during_backoff(&mut rx, &mut batch, tokio::time::Duration::from_secs(30)).await; + + assert_eq!(batch.len(), 200); + assert_eq!(batch[0].message, "line 0"); + assert_eq!(batch[199].message, "line 199"); + } + + #[tokio::test] + async fn drain_during_backoff_preserves_an_existing_batch() { + let (tx, mut rx) = mpsc::channel::(16); + tx.try_send(test_line("new")).unwrap(); + drop(tx); + + let mut batch = vec![test_line("buffered")]; + drain_during_backoff(&mut rx, &mut batch, tokio::time::Duration::from_secs(30)).await; + + assert_eq!(batch.len(), 2); + assert_eq!(batch[0].message, "buffered"); + assert_eq!(batch[1].message, "new"); + } + + #[tokio::test] + async fn drain_during_backoff_returns_early_when_the_channel_closes() { + let (tx, mut rx) = mpsc::channel::(16); + tx.try_send(test_line("last")).unwrap(); + drop(tx); + + let mut batch = Vec::new(); + tokio::time::timeout( + tokio::time::Duration::from_secs(5), + drain_during_backoff(&mut rx, &mut batch, tokio::time::Duration::from_secs(30)), + ) + .await + .expect("closed channel should end the backoff drain"); + + assert_eq!(batch.len(), 1); + } + + #[test] + fn log_visitor_into_parts_uses_the_fallback_only_without_a_message() { + let visitor = LogVisitor { + message: Some("explicit".to_string()), + fields: vec![("k".to_string(), "v".to_string())], + }; + let (msg, fields) = visitor.into_parts("fallback"); + assert_eq!(msg, "explicit"); + assert_eq!(fields.get("k").map(String::as_str), Some("v")); + + let (msg, fields) = LogVisitor::default().into_parts("fallback"); + assert_eq!(msg, "fallback"); + assert!(fields.is_empty()); + } +} diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs new file mode 100644 index 0000000000..00dd2ea53c --- /dev/null +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -0,0 +1,731 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Retained I/O multiplexer for the canonical sandbox process. + +use std::collections::VecDeque; +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use nix::fcntl::{FcntlArg, OFlag, fcntl}; +use nix::pty::Winsize; +use tokio::io::unix::AsyncFd; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Notify; +use tokio::sync::watch; + +use crate::process::ProcessIo; + +const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; + +#[derive(Clone, Debug)] +pub enum MainOutput { + Stdout(Bytes), + Stderr(Bytes), + Exit(i32), +} + +impl MainOutput { + fn len(&self) -> usize { + match self { + Self::Stdout(data) | Self::Stderr(data) => data.len(), + Self::Exit(_) => 0, + } + } +} + +#[derive(Clone, Debug)] +struct SequencedOutput { + sequence: u64, + event: MainOutput, +} + +#[derive(Debug)] +struct OutputLogState { + events: VecDeque, + retained_bytes: usize, + next_sequence: u64, +} + +#[derive(Debug)] +struct OutputLog { + state: Mutex, + version: watch::Sender, + terminal_reported: std::sync::atomic::AtomicBool, + terminal_reported_notify: Notify, +} + +impl OutputLog { + fn new() -> Arc { + let (version, _) = watch::channel(0); + Arc::new(Self { + state: Mutex::new(OutputLogState { + events: VecDeque::new(), + retained_bytes: 0, + next_sequence: 0, + }), + version, + terminal_reported: std::sync::atomic::AtomicBool::new(false), + terminal_reported_notify: Notify::new(), + }) + } + + fn publish(&self, event: MainOutput) { + let version = { + let mut state = self.state.lock().expect("main output log lock poisoned"); + let sequence = state.next_sequence; + state.next_sequence = state + .next_sequence + .checked_add(1) + .expect("main output sequence exhausted"); + state.retained_bytes += event.len(); + state.events.push_back(SequencedOutput { sequence, event }); + while state.retained_bytes > OUTPUT_BUFFER_BYTES { + let Some(removed) = state.events.pop_front() else { + break; + }; + state.retained_bytes = state.retained_bytes.saturating_sub(removed.event.len()); + } + state.next_sequence + }; + self.version.send_replace(version); + } + + fn subscribe(self: &Arc) -> MainOutputCursor { + let version = self.version.subscribe(); + let state = self.state.lock().expect("main output log lock poisoned"); + let next_sequence = state + .events + .front() + .map_or(state.next_sequence, |retained| retained.sequence); + drop(state); + MainOutputCursor { + output: Arc::clone(self), + next_sequence, + version, + } + } +} + +#[derive(Debug)] +struct TerminalAttachmentState { + active: usize, + process_finished: bool, + expectation: AttachmentExpectation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AttachmentExpectation { + None, + Pending, + Satisfied, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MainOutputLagged { + pub skipped: u64, +} + +pub struct MainOutputCursor { + output: Arc, + next_sequence: u64, + version: watch::Receiver, +} + +impl MainOutputCursor { + pub async fn recv(&mut self) -> Result { + loop { + let next = { + let state = self + .output + .state + .lock() + .expect("main output log lock poisoned"); + let oldest = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + if self.next_sequence < oldest { + let skipped = oldest - self.next_sequence; + self.next_sequence = oldest; + return Err(MainOutputLagged { skipped }); + } + if self.next_sequence >= state.next_sequence { + None + } else { + let offset = usize::try_from(self.next_sequence - oldest) + .expect("main output cursor offset exceeds usize"); + let event = state + .events + .get(offset) + .expect("main output cursor references retained event") + .event + .clone(); + self.next_sequence += 1; + Some(event) + } + }; + if let Some(event) = next { + return Ok(event); + } + // The log owns a sender for the cursor lifetime, so closure is not + // expected. A changed version means there is another event to read. + let _ = self.version.changed().await; + } + } +} + +pub struct MainSession { + pid: u32, + terminal: bool, + input: tokio::sync::mpsc::Sender>, + output: Arc, + input_owner: Mutex>, + next_owner: AtomicU64, + pty_master: Option>, + readers_remaining: AtomicUsize, + readers_done: Notify, + finished: std::sync::atomic::AtomicBool, + terminal_attachments: Mutex, + terminal_attachments_done: Notify, +} + +impl MainSession { + #[cfg(test)] + pub fn inert() -> Arc { + let (input, _input_rx) = tokio::sync::mpsc::channel(64); + Arc::new(Self { + pid: 1, + terminal: false, + input, + output: OutputLog::new(), + input_owner: Mutex::new(None), + next_owner: AtomicU64::new(1), + pty_master: None, + readers_remaining: AtomicUsize::new(0), + readers_done: Notify::new(), + finished: std::sync::atomic::AtomicBool::new(false), + terminal_attachments: Mutex::new(TerminalAttachmentState { + active: 0, + process_finished: false, + expectation: AttachmentExpectation::None, + }), + terminal_attachments_done: Notify::new(), + }) + } + + #[cfg(test)] + pub fn terminal_for_test() -> (Arc, std::fs::File) { + let pty = nix::pty::openpty(None, None).expect("open test PTY"); + let slave = std::fs::File::from(pty.slave); + ( + Self::new(ProcessIo::Pty(std::fs::File::from(pty.master)), 1), + slave, + ) + } + + #[cfg(test)] + #[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"); + (winsize.ws_col, winsize.ws_row) + } + + #[must_use] + pub fn new(io: ProcessIo, pid: u32) -> Arc { + let terminal = matches!(io, ProcessIo::Pty(_)); + let (input, input_rx) = tokio::sync::mpsc::channel::>(64); + let pty_master = match &io { + ProcessIo::Pty(master) => { + set_nonblocking(master).expect("set canonical PTY master nonblocking"); + master.try_clone().ok().map(Arc::new) + } + ProcessIo::Pipes { .. } => None, + }; + let session = Arc::new(Self { + pid, + terminal, + input, + output: OutputLog::new(), + input_owner: Mutex::new(None), + next_owner: AtomicU64::new(1), + pty_master, + readers_remaining: AtomicUsize::new(if terminal { 1 } else { 2 }), + readers_done: Notify::new(), + finished: std::sync::atomic::AtomicBool::new(false), + terminal_attachments: Mutex::new(TerminalAttachmentState { + active: 0, + process_finished: false, + expectation: AttachmentExpectation::None, + }), + terminal_attachments_done: Notify::new(), + }); + Self::start_io(&session, io, input_rx); + session + } + + fn start_io( + this: &Arc, + io: ProcessIo, + mut input_rx: tokio::sync::mpsc::Receiver>, + ) { + match io { + ProcessIo::Pty(master) => { + let master = Arc::new(AsyncFd::new(master).expect("register canonical PTY master")); + let reader = Arc::clone(&master); + let output = Arc::clone(this); + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + let Ok(mut ready) = reader.readable().await else { + break; + }; + match ready.try_io(|inner| { + let mut file = inner.get_ref(); + file.read(&mut buffer) + }) { + Ok(Ok(0) | Err(_)) => break, + Ok(Ok(read)) => output.publish(MainOutput::Stdout( + Bytes::copy_from_slice(&buffer[..read]), + )), + Err(_would_block) => {} + } + } + output.reader_finished(); + }); + tokio::spawn(async move { + while let Some(data) = input_rx.recv().await { + let mut remaining = data.as_slice(); + while !remaining.is_empty() { + let Ok(mut ready) = master.writable().await else { + return; + }; + match ready.try_io(|inner| { + let mut file = inner.get_ref(); + file.write(remaining) + }) { + Ok(Ok(0) | Err(_)) => return, + Ok(Ok(written)) => remaining = &remaining[written..], + Err(_would_block) => {} + } + } + } + }); + } + ProcessIo::Pipes { + mut stdin, + mut stdout, + mut stderr, + } => { + let stdout_session = Arc::clone(this); + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + match stdout.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + stdout_session.publish(MainOutput::Stdout(Bytes::copy_from_slice( + &buffer[..read], + ))); + } + } + } + stdout_session.reader_finished(); + }); + let stderr_session = Arc::clone(this); + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + match stderr.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + stderr_session.publish(MainOutput::Stderr(Bytes::copy_from_slice( + &buffer[..read], + ))); + } + } + } + stderr_session.reader_finished(); + }); + tokio::spawn(async move { + while let Some(data) = input_rx.recv().await { + if stdin.write_all(&data).await.is_err() { + break; + } + let _ = stdin.flush().await; + } + }); + } + } + } + + fn publish(&self, event: MainOutput) { + self.output.publish(event); + } + + fn reader_finished(&self) { + if self.readers_remaining.fetch_sub(1, Ordering::AcqRel) == 1 { + self.readers_done.notify_waiters(); + } + } + + /// Publish the terminal event and retain the transport only when a real + /// foreground attachment exists or the creating client declared one. + /// + /// Returns whether terminal delivery must complete before shutdown. + pub async fn finish(&self, exit_code: i32, attachment_expected: bool) -> bool { + let notified = self.readers_done.notified(); + if self.readers_remaining.load(Ordering::Acquire) != 0 { + notified.await; + } + let delivery_pending = { + let mut state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + state.process_finished = true; + state.expectation = if attachment_expected { + if state.active == 0 && state.expectation != AttachmentExpectation::Satisfied { + AttachmentExpectation::Pending + } else { + AttachmentExpectation::Satisfied + } + } else { + AttachmentExpectation::None + }; + attachment_expected || state.active != 0 + }; + self.finished.store(true, Ordering::Release); + self.publish(MainOutput::Exit(exit_code)); + delivery_pending + } + + pub fn subscribe(&self) -> MainOutputCursor { + self.output.subscribe() + } + + /// Wait until the gateway durably acknowledges the main-process result. + pub async fn wait_for_terminal_reported(&self) { + let notified = self.output.terminal_reported_notify.notified(); + if self.output.terminal_reported.load(Ordering::Acquire) { + return; + } + notified.await; + } + + /// Release attached clients to receive their SSH exit status after the + /// durable sandbox phase and exit code have been recorded. + pub fn mark_terminal_reported(&self) { + self.output.terminal_reported.store(true, Ordering::Release); + self.output.terminal_reported_notify.notify_waiters(); + } + + /// Register a foreground main attachment while the process is live. + pub fn begin_terminal_attachment(&self) -> Result<(), &'static str> { + let mut state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + if state.process_finished && state.expectation != AttachmentExpectation::Pending { + return Err("canonical main process already finished"); + } + state.active = state + .active + .checked_add(1) + .expect("terminal attachment count exhausted"); + state.expectation = AttachmentExpectation::Satisfied; + self.terminal_attachments_done.notify_waiters(); + Ok(()) + } + + /// Release a foreground main attachment after its SSH channel closes. + pub fn end_terminal_attachment(&self) { + let completed = { + let mut state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + debug_assert!(state.active != 0, "terminal attachment count underflow"); + if state.active == 0 { + return; + } + state.active -= 1; + state.active == 0 + }; + if completed { + self.terminal_attachments_done.notify_waiters(); + } + } + + /// Wait for the declared foreground attachment to start, then for every + /// accepted attachment to close naturally. + pub async fn wait_for_terminal_attachments(&self) { + loop { + let notified = self.terminal_attachments_done.notified(); + let complete = { + let state = self + .terminal_attachments + .lock() + .expect("terminal attachment lock poisoned"); + state.active == 0 && state.expectation != AttachmentExpectation::Pending + }; + if complete { + return; + } + notified.await; + } + } + + pub fn acquire_input(&self) -> Result<(u64, tokio::sync::mpsc::Sender>), &'static str> { + let mut owner = self.input_owner.lock().expect("main input lock poisoned"); + if owner.is_some() { + return Err("canonical main process already has an input owner"); + } + let id = self.next_owner.fetch_add(1, Ordering::Relaxed); + *owner = Some(id); + Ok((id, self.input.clone())) + } + + pub fn release_input(&self, id: u64) { + let mut owner = self.input_owner.lock().expect("main input lock poisoned"); + if *owner == Some(id) { + *owner = None; + } + } + + pub fn resize(&self, columns: u32, rows: u32, pixel_width: u32, pixel_height: u32) { + let Some(master) = self.pty_master.as_ref() else { + return; + }; + let winsize = Winsize { + ws_row: u16::try_from(rows.max(1)).unwrap_or(u16::MAX), + ws_col: u16::try_from(columns.max(1)).unwrap_or(u16::MAX), + 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); + } + } + + pub fn signal_group(&self, signal: nix::sys::signal::Signal) -> Result<(), nix::errno::Errno> { + let pid = i32::try_from(self.pid).unwrap_or(i32::MAX); + nix::sys::signal::kill(nix::unistd::Pid::from_raw(-pid), signal) + } + + #[must_use] + pub const fn terminal(&self) -> bool { + self.terminal + } + + #[must_use] + pub fn finished(&self) -> bool { + self.finished.load(Ordering::Acquire) + } +} + +fn set_nonblocking(file: &std::fs::File) -> Result<(), nix::errno::Errno> { + let flags = fcntl(file.as_raw_fd(), FcntlArg::F_GETFL)?; + let flags = OFlag::from_bits_truncate(flags); + fcntl( + file.as_raw_fd(), + FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK), + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_lease_has_one_owner_and_can_be_reacquired() { + let session = MainSession::inert(); + let (first, _) = session.acquire_input().expect("first owner"); + assert!(session.acquire_input().is_err()); + + session.release_input(first); + let (second, _) = session.acquire_input().expect("replacement owner"); + assert_ne!(first, second); + } + + #[tokio::test] + async fn subscribers_receive_replay_then_live_output() { + let session = MainSession::inert(); + session.publish(MainOutput::Stdout(Bytes::from_static(b"before"))); + + let mut output = session.subscribe(); + assert!(matches!( + output.recv().await.expect("replayed output"), + MainOutput::Stdout(data) if data == b"before"[..] + )); + + session.publish(MainOutput::Stderr(Bytes::from_static(b"after"))); + assert!(matches!( + output.recv().await.expect("live output"), + MainOutput::Stderr(data) if data == b"after"[..] + )); + } + + #[tokio::test] + async fn finish_without_attachment_does_not_defer_shutdown() { + let session = MainSession::inert(); + assert!(!session.finish(0, false).await); + assert!(session.finished()); + assert!(session.begin_terminal_attachment().is_err()); + } + + #[tokio::test] + async fn terminal_report_acknowledgement_is_independent_from_delivery() { + let session = MainSession::inert(); + + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + session.wait_for_terminal_reported(), + ) + .await + .is_err(), + "draining output must not imply durable gateway persistence" + ); + + session.mark_terminal_reported(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + session.wait_for_terminal_reported(), + ) + .await + .expect("durable report acknowledgement should wake waiter"); + } + + #[tokio::test] + async fn finish_waits_for_an_active_attachment_to_close_naturally() { + let session = MainSession::inert(); + session + .begin_terminal_attachment() + .expect("begin terminal attachment"); + assert!(session.finish(0, false).await); + + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + session.wait_for_terminal_attachments(), + ) + .await + .is_err(), + "an active attachment must keep terminal delivery open" + ); + + session.end_terminal_attachment(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + session.wait_for_terminal_attachments(), + ) + .await + .expect("closing the attachment should wake the waiter"); + } + + #[tokio::test] + async fn declared_attachment_waits_for_connection_then_natural_close() { + let session = MainSession::inert(); + assert!(session.finish(0, true).await); + + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + session.wait_for_terminal_attachments(), + ) + .await + .is_err(), + "declared attachment must connect before delivery is complete" + ); + + session + .begin_terminal_attachment() + .expect("declared post-exit attachment"); + session.end_terminal_attachment(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + session.wait_for_terminal_attachments(), + ) + .await + .expect("natural attachment close should complete delivery"); + } + + #[tokio::test] + async fn exit_is_retained_in_the_output_log() { + let session = MainSession::inert(); + let _ = session.finish(0, false).await; + + let mut output = session.subscribe(); + assert!(matches!( + output.recv().await.expect("replayed exit"), + MainOutput::Exit(0) + )); + } + + #[tokio::test] + async fn slow_subscriber_reports_evicted_events_then_resumes() { + let session = MainSession::inert(); + let mut output = session.subscribe(); + let chunk = Bytes::from(vec![0; 4096]); + for _ in 0..=(OUTPUT_BUFFER_BYTES / chunk.len()) { + session.publish(MainOutput::Stdout(chunk.clone())); + } + + let lag = output.recv().await.expect_err("oldest event was evicted"); + assert_eq!(lag.skipped, 1); + assert!(matches!( + output.recv().await.expect("resume at oldest retained event"), + MainOutput::Stdout(data) if data.len() == chunk.len() + )); + } + + #[tokio::test] + async fn terminal_pump_reads_output_and_writes_input() { + let (session, mut slave) = MainSession::terminal_for_test(); + set_nonblocking(&slave).expect("set test PTY slave nonblocking"); + let mut output = session.subscribe(); + + slave + .write_all(b"process output") + .expect("write PTY output"); + let event = tokio::time::timeout(std::time::Duration::from_secs(1), output.recv()) + .await + .expect("PTY output timed out") + .expect("PTY output was retained"); + assert!(matches!( + event, + MainOutput::Stdout(data) if data == b"process output"[..] + )); + + let (owner, input) = session.acquire_input().expect("acquire PTY input"); + input + .send(b"client input\n".to_vec()) + .await + .expect("queue PTY input"); + let mut received = [0; 64]; + let read = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + match slave.read(&mut received) { + Ok(read) => break read, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + tokio::task::yield_now().await; + } + Err(error) => panic!("read PTY input: {error}"), + } + } + }) + .await + .expect("PTY input timed out"); + assert_eq!(&received[..read], b"client input\n"); + session.release_input(owner); + } +} diff --git a/crates/openshell-supervisor-process/src/netns/mod.rs b/crates/openshell-supervisor-process/src/netns/mod.rs index 44e9470931..2b4ea554ed 100644 --- a/crates/openshell-supervisor-process/src/netns/mod.rs +++ b/crates/openshell-supervisor-process/src/netns/mod.rs @@ -21,6 +21,11 @@ use uuid::Uuid; const SUBNET_PREFIX: &str = "10.200.0"; const HOST_IP_SUFFIX: u8 = 1; const SANDBOX_IP_SUFFIX: u8 = 2; +/// Unprivileged port owned by the supervisor's policy DNS service. Workload +/// queries still target the standard DNS port and nftables redirects them to +/// this listener before the bypass fence runs. +pub const POLICY_DNS_PORT: u16 = 15_053; +pub const TRANSPARENT_TCP_PORT: u16 = 15_001; const IP_SEARCH_PATHS: &[&str] = &["/usr/sbin/ip", "/sbin/ip", "/usr/bin/ip", "/bin/ip"]; const NSENTER_SEARCH_PATHS: &[&str] = &[ "/usr/bin/nsenter", @@ -153,9 +158,9 @@ impl NetworkNamespace { } // Open the namespace file descriptor for later use with setns - let ns_path = format!("/var/run/netns/{name}"); + let ns_path = openshell_core::container_paths::netns_path(&name); let ns_fd = match nix::fcntl::open( - ns_path.as_str(), + ns_path.as_path(), nix::fcntl::OFlag::O_RDONLY, nix::sys::stat::Mode::empty(), ) { @@ -318,6 +323,193 @@ impl NetworkNamespace { Ok(()) } + /// Replace the ordinary bypass fence with the policy-DNS and transparent + /// TCP ruleset. This is fail-closed: callers must not release workload + /// execution unless every required rule was installed. + pub fn install_transparent_tcp_rules( + &self, + proxy_port: u16, + synthetic_ipv4_cidr: &str, + synthetic_ipv6_cidr: &str, + ) -> Result<()> { + self.validate_synthetic_pool_routes(synthetic_ipv4_cidr, synthetic_ipv6_cidr)?; + // The inner namespace has an IPv4 default route, but not an IPv6 + // default route. Install only the active synthetic IPv6 epoch so the + // kernel reaches the nft OUTPUT hook; REDIRECT then reroutes it to + // the local transparent listener. + run_ip_netns( + &self.name, + &["-6", "route", "replace", synthetic_ipv6_cidr, "dev", "lo"], + )?; + let nft_path = find_nft().ok_or_else(|| { + miette::miette!( + "trusted nft helper not found; policy DNS and transparent TCP require nftables" + ) + })?; + let host_ip = self.host_ip.to_string(); + let log_prefix = format!("openshell:bypass:{}:", self.name); + let commands = nft_ruleset::generate_transparent_tcp_commands( + &host_ip, + proxy_port, + POLICY_DNS_PORT, + TRANSPARENT_TCP_PORT, + synthetic_ipv4_cidr, + synthetic_ipv6_cidr, + Some(&log_prefix), + ); + run_nft_commands_netns(&self.name, &nft_path, &commands)?; + openshell_ocsf::ocsf_emit!( + openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(openshell_ocsf::SeverityId::Informational) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Enabled, "installed") + .message(format!( + "Policy DNS and transparent TCP capture installed [ns:{}]", + self.name + )) + .build() + ); + Ok(()) + } + + fn validate_synthetic_pool_routes( + &self, + synthetic_ipv4_cidr: &str, + synthetic_ipv6_cidr: &str, + ) -> Result<()> { + let reserved = [ + synthetic_ipv4_cidr + .parse::() + .into_diagnostic()?, + synthetic_ipv6_cidr + .parse::() + .into_diagnostic()?, + ]; + for family in ["-4", "-6"] { + let routes = + run_ip_netns_output(&self.name, &[family, "route", "show", "table", "all"])?; + if let Some((route, pool)) = first_route_overlap(&routes, &reserved) { + return Err(miette::miette!( + "synthetic address pool {pool} overlaps workload route {route}; refusing to enable policy DNS" + )); + } + } + Ok(()) + } + + /// Bind IPv4 and IPv6 transparent listeners inside the workload network + /// namespace without moving an async runtime worker into that namespace. + pub async fn bind_transparent_tcp_listeners( + &self, + ) -> std::io::Result> { + let ns_fd = self + .ns_fd + .ok_or_else(|| std::io::Error::other("no namespace fd available for bind"))?; + let (tx, rx) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + let result = (|| -> std::io::Result> { + #[allow(unsafe_code)] + if unsafe { libc::setns(ns_fd, libc::CLONE_NEWNET) } != 0 { + return Err(std::io::Error::last_os_error()); + } + let mut listeners = Vec::with_capacity(2); + for (domain, address) in [ + ( + socket2::Domain::IPV4, + format!("0.0.0.0:{TRANSPARENT_TCP_PORT}"), + ), + ( + socket2::Domain::IPV6, + format!("[::]:{TRANSPARENT_TCP_PORT}"), + ), + ] { + let socket = socket2::Socket::new( + domain, + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + )?; + socket.set_reuse_address(true)?; + if domain == socket2::Domain::IPV6 { + socket.set_only_v6(true)?; + } + let address: std::net::SocketAddr = address.parse().map_err(|error| { + std::io::Error::other(format!("invalid listener address: {error}")) + })?; + socket.bind(&address.into())?; + socket.listen(128)?; + let listener: std::net::TcpListener = socket.into(); + listener.set_nonblocking(true)?; + listeners.push(listener); + } + Ok(listeners) + })(); + let _ = tx.send(result); + }); + rx.await + .map_err(|_| std::io::Error::other("netns bind thread panicked"))?? + .into_iter() + .map(tokio::net::TcpListener::from_std) + .collect() + } + + /// Bind UDP and TCP DNS listeners inside the workload network namespace. + /// The workload keeps its image-provided resolver configuration; nftables + /// redirects port 53 to these sockets before the bypass fence runs. + pub async fn bind_policy_dns_sockets( + &self, + ) -> std::io::Result<(tokio::net::UdpSocket, tokio::net::TcpListener)> { + let ns_fd = self + .ns_fd + .ok_or_else(|| std::io::Error::other("no namespace fd available for bind"))?; + let (tx, rx) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + let result = (|| -> std::io::Result<(std::net::UdpSocket, std::net::TcpListener)> { + #[allow(unsafe_code)] + if unsafe { libc::setns(ns_fd, libc::CLONE_NEWNET) } != 0 { + return Err(std::io::Error::last_os_error()); + } + // Bind the exact REDIRECT destination instead of INADDR_ANY. + // For UDP this keeps replies sourced from loopback so + // conntrack can reverse the port/address translation before + // delivering them to libc in nested rootless namespaces. + let address: std::net::SocketAddr = format!("127.0.0.1:{POLICY_DNS_PORT}") + .parse() + .map_err(|error| { + std::io::Error::other(format!("invalid DNS listener address: {error}")) + })?; + + let udp = socket2::Socket::new( + socket2::Domain::IPV4, + socket2::Type::DGRAM, + Some(socket2::Protocol::UDP), + )?; + udp.set_reuse_address(true)?; + udp.bind(&address.into())?; + udp.set_nonblocking(true)?; + + let tcp = socket2::Socket::new( + socket2::Domain::IPV4, + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + )?; + tcp.set_reuse_address(true)?; + tcp.bind(&address.into())?; + tcp.listen(128)?; + tcp.set_nonblocking(true)?; + + Ok((udp.into(), tcp.into())) + })(); + let _ = tx.send(result); + }); + let (udp, tcp) = rx + .await + .map_err(|_| std::io::Error::other("netns DNS bind thread panicked"))??; + Ok(( + tokio::net::UdpSocket::from_std(udp)?, + tokio::net::TcpListener::from_std(tcp)?, + )) + } + /// Bind a TCP listener inside this network namespace on a dedicated thread. /// /// Spawns a short-lived OS thread that enters the namespace via `setns`, @@ -729,10 +921,14 @@ fn run_nft_commands_current_namespace( /// The supervisor's operations (addr add, link set, route add) are all /// netlink-based and do not need sysfs access. fn run_ip_netns(netns: &str, args: &[&str]) -> Result<()> { + run_ip_netns_output(netns, args).map(|_| ()) +} + +fn run_ip_netns_output(netns: &str, args: &[&str]) -> Result { let ip_path = find_trusted_binary("ip", IP_SEARCH_PATHS)?; let nsenter_path = find_trusted_binary("nsenter", NSENTER_SEARCH_PATHS)?; - let ns_path = format!("/var/run/netns/{netns}"); - let net_flag = format!("--net={ns_path}"); + let ns_path = openshell_core::container_paths::netns_path(netns); + let net_flag = format!("--net={}", ns_path.display()); let mut full_args = vec![net_flag.as_str(), "--", ip_path]; full_args.extend(args); @@ -751,13 +947,37 @@ fn run_ip_netns(netns: &str, args: &[&str]) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); return Err(miette::miette!( "{nsenter_path} --net={} {ip_path} {} failed: {}", - ns_path, + ns_path.display(), args.join(" "), stderr.trim() )); } - Ok(()) + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn first_route_overlap( + routes: &str, + reserved: &[ipnet::IpNet], +) -> Option<(ipnet::IpNet, ipnet::IpNet)> { + routes.lines().find_map(|line| { + line.split_whitespace().find_map(|token| { + let route = token + .parse::() + .ok() + .or_else(|| token.parse::().ok().map(ipnet::IpNet::from))?; + reserved + .iter() + .copied() + .find(|pool| { + let same_family = route.addr().is_ipv4() == pool.addr().is_ipv4(); + let overlaps = + route.contains(&pool.network()) || pool.contains(&route.network()); + same_family && overlaps + }) + .map(|pool| (route, pool)) + }) + }) } /// Run a sequence of nft commands inside a network namespace via `nsenter --net=`. @@ -770,8 +990,8 @@ fn run_nft_commands_netns( commands: &[nft_ruleset::NftCommand], ) -> Result<()> { let nsenter_path = find_trusted_binary("nsenter", NSENTER_SEARCH_PATHS)?; - let ns_path = format!("/var/run/netns/{netns}"); - let net_flag = format!("--net={ns_path}"); + let ns_path = openshell_core::container_paths::netns_path(netns); + let net_flag = format!("--net={}", ns_path.display()); for cmd in commands { let args_str = cmd.args.join(" "); @@ -965,6 +1185,28 @@ fe800000000000000000000000000001 02 40 20 80 eth0 assert!(has_non_loopback_ipv6_interface(content)); } + #[test] + fn route_overlap_detects_reserved_pool_collision() { + let reserved = [ + "198.18.1.0/25".parse().unwrap(), + "fd23:6f70:656e:1::/120".parse().unwrap(), + ]; + let routes = "default via 10.200.0.1 dev veth\n198.18.0.0/15 dev eth1\n"; + let (route, pool) = first_route_overlap(routes, &reserved).expect("collision"); + assert_eq!(route.to_string(), "198.18.0.0/15"); + assert_eq!(pool.to_string(), "198.18.1.0/25"); + } + + #[test] + fn route_overlap_ignores_default_and_unrelated_routes() { + let reserved = [ + "198.18.1.0/25".parse().unwrap(), + "fd23:6f70:656e:1::/120".parse().unwrap(), + ]; + let routes = "default via 10.200.0.1 dev veth\n10.200.0.0/24 dev veth\n"; + assert_eq!(first_route_overlap(routes, &reserved), None); + } + #[test] #[ignore = "requires root privileges"] fn test_create_and_drop_namespace() { @@ -972,8 +1214,8 @@ fe800000000000000000000000000001 02 40 20 80 eth0 let name = ns.name().to_string(); // Verify namespace exists - let ns_path = format!("/var/run/netns/{name}"); - assert!(Path::new(&ns_path).exists(), "Namespace file should exist"); + let ns_path = openshell_core::container_paths::netns_path(&name); + assert!(ns_path.exists(), "Namespace file should exist"); // Verify IPs are set correctly assert_eq!( diff --git a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs index 60263e889c..aef95b6068 100644 --- a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs +++ b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs @@ -13,6 +13,8 @@ //! `ct state` without `nf_conntrack`, `log` without `nf_log`) rolls back the //! entire transaction including table/chain creation. +const DNS_DESTINATION_PORT: &str = "53"; + /// A single nft command with metadata about whether it is required. pub struct NftCommand { /// The nft command arguments (e.g. `["add", "table", "inet", "openshell_bypass"]`). @@ -203,6 +205,190 @@ pub fn generate_bypass_commands( cmds } +/// Generate the combined policy-DNS, transparent-TCP, and bypass fence. +/// +/// DNS may reach only the supervisor's trusted listener. TCP addressed to the +/// reserved synthetic pools is redirected before the terminal bypass reject; +/// all other direct TCP/UDP retains the existing fast-fail behavior. +pub fn generate_transparent_tcp_commands( + host_ip: &str, + proxy_port: u16, + dns_port: u16, + transparent_port: u16, + synthetic_ipv4_cidr: &str, + synthetic_ipv6_cidr: &str, + log_prefix: Option<&str>, +) -> Vec { + let mut cmds = vec![ + nft_cmd(true, &["add", "table", "inet", "openshell_transparent"]), + nft_cmd(true, &["flush", "table", "inet", "openshell_transparent"]), + nft_cmd( + true, + &[ + "add", + "chain", + "inet", + "openshell_transparent", + "output", + "{ type nat hook output priority dstnat; policy accept; }", + ], + ), + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + "openshell_transparent", + "output", + "meta", + "nfproto", + "ipv4", + "udp", + "dport", + DNS_DESTINATION_PORT, + "redirect", + "to", + &format!(":{dns_port}"), + ], + ), + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + "openshell_transparent", + "output", + "ip", + "daddr", + synthetic_ipv4_cidr, + "tcp", + "dport", + "1-65535", + "redirect", + "to", + &format!(":{transparent_port}"), + ], + ), + // Synthetic destinations must take precedence over the generic TCP + // DNS capture. A policy endpoint may legitimately use TCP port 53; + // that connection belongs to transparent TCP, not the DNS listener. + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + "openshell_transparent", + "output", + "meta", + "nfproto", + "ipv4", + "tcp", + "dport", + DNS_DESTINATION_PORT, + "redirect", + "to", + &format!(":{dns_port}"), + ], + ), + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + "openshell_transparent", + "output", + "ip6", + "daddr", + synthetic_ipv6_cidr, + "tcp", + "dport", + "1-65535", + "redirect", + "to", + &format!(":{transparent_port}"), + ], + ), + ]; + let mut bypass = generate_bypass_commands(host_ip, proxy_port, log_prefix); + // NAT REDIRECT rewrites both DNS and synthetic TCP to loopback before the + // filter hook. Some kernels retain the packet's pre-REDIRECT output + // interface for filter matching, so `oifname lo accept` alone is not + // portable. Admit only packets that the kernel records as DNATed to the + // supervisor listeners. A direct dial to either port has no DNAT status + // and still reaches the terminal bypass reject. Transparent TCP + // authorization after accept remains bound by SO_ORIGINAL_DST plus the + // synthetic-address mapping. + let insertion = bypass + .iter() + .position(|command| { + command.args.iter().any(|arg| arg == "log") + || command.args.iter().any(|arg| arg == "reject") + }) + .unwrap_or(bypass.len()); + bypass.splice( + insertion..insertion, + [ + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + "openshell_bypass", + "output", + "ct", + "status", + "dnat", + "udp", + "dport", + &dns_port.to_string(), + "accept", + ], + ), + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + "openshell_bypass", + "output", + "ct", + "status", + "dnat", + "tcp", + "dport", + &dns_port.to_string(), + "accept", + ], + ), + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + "openshell_bypass", + "output", + "ct", + "status", + "dnat", + "tcp", + "dport", + &transparent_port.to_string(), + "accept", + ], + ), + ], + ); + cmds.extend(bypass); + cmds +} + /// Generate nft commands for Kubernetes sidecar enforcement. /// /// The network sidecar and the process supervisor share a pod network @@ -433,6 +619,78 @@ mod tests { assert!(ct_pos < reject_pos); } + #[test] + fn transparent_rules_precede_bypass_rejects_and_scope_dns() { + let commands = generate_transparent_tcp_commands( + "10.200.0.1", + 3128, + 15053, + 15001, + "198.18.0.0/24", + "fd23:6f70:656e::/48", + None, + ); + let text = all_strs(&commands); + assert!(text.contains("meta nfproto ipv4 udp dport 53 redirect to :15053")); + assert!(text.contains("meta nfproto ipv4 tcp dport 53 redirect to :15053")); + assert!(!text.contains("udp dport 53 accept")); + assert!(text.contains("ip daddr 198.18.0.0/24 tcp dport 1-65535 redirect to :15001")); + assert!( + text.contains("ip6 daddr fd23:6f70:656e::/48 tcp dport 1-65535 redirect to :15001") + ); + assert!(!text.contains("meta mark")); + assert!(text.contains("ct status dnat udp dport 15053 accept")); + assert!(text.contains("ct status dnat tcp dport 15053 accept")); + assert!(text.contains("ct status dnat tcp dport 15001 accept")); + for (protocol, port) in [("udp", "15053"), ("tcp", "15053"), ("tcp", "15001")] { + assert!(!commands.iter().any(|command| { + command.args.ends_with(&[ + protocol.to_string(), + "dport".to_string(), + port.to_string(), + "accept".to_string(), + ]) && !command.args.windows(3).any(|window| { + window == ["ct".to_string(), "status".to_string(), "dnat".to_string()] + }) + })); + } + assert!(text.contains("oifname lo accept")); + assert!( + text.find("ct status dnat tcp dport 15053 accept").unwrap() + < text + .find("meta nfproto ipv4 meta l4proto tcp reject") + .unwrap() + ); + assert!( + text.find("ct status dnat udp dport 15053 accept").unwrap() + < text + .find("meta nfproto ipv4 meta l4proto udp reject") + .unwrap() + ); + assert!( + text.find("ct status dnat tcp dport 15001 accept").unwrap() + < text + .find("meta nfproto ipv4 meta l4proto tcp reject") + .unwrap() + ); + assert!( + text.find("ip daddr 198.18.0.0/24 tcp dport 1-65535 redirect to :15001") + .unwrap() + < text + .find("meta nfproto ipv4 meta l4proto tcp reject") + .unwrap() + ); + assert!(!text.contains("meta nfproto ipv6 udp dport 53 redirect")); + assert!( + text.find("ip daddr 198.18.0.0/24 tcp dport 1-65535 redirect to :15001") + .unwrap() + < text + .find("meta nfproto ipv4 tcp dport 53 redirect to :15053") + .unwrap(), + "synthetic TCP:53 must reach transparent TCP before generic DNS capture" + ); + } + #[test] fn both_ipv4_and_ipv6_reject_types_are_present() { let cmds = generate_bypass_commands("10.0.2.2", 8080, None); diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 93ab4787b3..0ab1dd3187 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -9,25 +9,45 @@ use crate::managed_children; #[cfg(target_os = "linux")] use crate::netns::NetworkNamespace; use crate::sandbox; +#[cfg(target_os = "linux")] +use miette::WrapErr; use miette::{IntoDiagnostic, Result}; use nix::sys::signal::{self, Signal}; use nix::unistd::{Gid, Group, Pid, Uid, User}; use openshell_core::policy::{NetworkMode, SandboxPolicy}; use std::collections::HashMap; use std::ffi::CString; +#[cfg(unix)] +use std::os::fd::AsRawFd; #[cfg(target_os = "linux")] -use std::os::fd::{AsRawFd, OwnedFd, RawFd}; +use std::os::fd::RawFd; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, PermissionsExt}; #[cfg(any(test, unix))] use std::path::Path; use std::path::PathBuf; use std::process::Stdio; #[cfg(target_os = "linux")] use std::sync::OnceLock; -use tokio::process::{Child, Command}; +#[cfg(target_os = "linux")] +use std::sync::mpsc; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; use tracing::{debug, info}; +// `libc::TIOCSCTTY` and the request parameter accepted by `ioctl` vary across +// glibc, musl, and BSD targets. The conversion is a no-op on some targets but +// is required on others. +#[cfg(unix)] +#[allow(unsafe_code, clippy::useless_conversion)] +fn set_controlling_tty(fd: libc::c_int) -> std::io::Result<()> { + if unsafe { libc::ioctl(fd, libc::TIOCSCTTY.into(), 0) } < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + /// Process/filesystem enforcement performed by the process supervisor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProcessEnforcementMode { @@ -79,6 +99,35 @@ impl ResolvedProcessIdentity { } } +/// Resolved process workspace and its child-environment semantics. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ResolvedWorkspace { + root: Option, + use_as_home: bool, +} + +impl ResolvedWorkspace { + #[must_use] + pub fn new(root: Option, use_as_home: bool) -> Self { + Self { root, use_as_home } + } + + #[must_use] + pub fn root(&self) -> Option<&str> { + self.root.as_deref() + } + + #[must_use] + pub fn owned_root(&self) -> Option { + self.root.clone() + } + + #[must_use] + pub fn home(&self) -> Option<&str> { + self.use_as_home.then(|| self.root()).flatten() + } +} + impl ProcessEnforcementMode { #[must_use] pub const fn uses_privileged_process_setup(self) -> bool { @@ -120,6 +169,7 @@ const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ openshell_core::sandbox_env::TLS_CERT, openshell_core::sandbox_env::TLS_KEY, openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, + openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, ]; pub fn is_supervisor_only_env_var(key: &str) -> bool { @@ -141,6 +191,70 @@ fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap } } +/// Derive the child USER and HOME from the policy's sandbox identity. +/// +/// Name-based identities use their passwd entry. Numeric identities have no +/// reliable passwd entry, so their workspace remains the portable fallback. +pub(crate) fn session_user_and_home( + policy: &SandboxPolicy, + workdir_home: Option<&str>, +) -> (String, String) { + let (user, default_home) = match policy.process.run_as_user.as_deref() { + Some(user) if !user.is_empty() => { + if user.parse::().is_ok() { + (user.to_string(), "/sandbox".to_string()) + } else { + let home = User::from_name(user).ok().flatten().map_or_else( + || format!("/home/{user}"), + |entry| entry.dir.to_string_lossy().into_owned(), + ); + (user.to_string(), home) + } + } + _ => ("sandbox".to_string(), "/sandbox".to_string()), + }; + let home = workdir_home.map_or(default_home, str::to_string); + (user, home) +} + +fn apply_canonical_process_environment( + cmd: &mut Command, + policy: &SandboxPolicy, + workspace: &ResolvedWorkspace, + interactive: bool, + user_environment: &HashMap, +) { + let (session_user, session_home) = session_user_and_home(policy, workspace.home()); + // Resolve a shell present in the sandbox image (minimal images such as + // Alpine ship only `/bin/sh`, not bash). Runs in the supervisor. + let shell = openshell_core::shell::detect_login_shell(); + + for (key, value) in [ + ("HOME", session_home.as_str()), + ("USER", session_user.as_str()), + ("SHELL", shell.as_str()), + ( + "TERM", + if interactive { + "xterm-256color" + } else { + "dumb" + }, + ), + ] { + if !user_environment.contains_key(key) { + cmd.env(key, value); + } + } +} + +fn configured_user_environment() -> HashMap { + std::env::var(openshell_core::sandbox_env::USER_ENVIRONMENT) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() +} + #[cfg(unix)] pub fn harden_child_process() -> Result<()> { use rustix::process::{Resource, Rlimit, setrlimit}; @@ -290,11 +404,13 @@ static SUPERVISOR_IDENTITY_MOUNT_NS: OnceLock, } #[cfg(target_os = "linux")] type SupervisorIdentityNsRef = &'static SupervisorIdentityMountNamespace; +#[cfg(target_os = "linux")] +type SupervisorIdentitySpawnJob = Box; #[cfg(target_os = "linux")] impl SupervisorIdentityMountNamespace { @@ -303,13 +419,9 @@ impl SupervisorIdentityMountNamespace { return Ok(None); }; Ok(Some(Self { - fd: create_supervisor_identity_mount_namespace(&target)?, + spawn_tx: start_supervisor_identity_spawn_worker(target)?, })) } - - pub fn enter_for_child(&self) -> std::io::Result<()> { - set_mount_namespace(self.fd.as_raw_fd()) - } } #[cfg(target_os = "linux")] @@ -340,6 +452,100 @@ pub fn supervisor_identity_mount_from_env() -> Result std::io::Result { + let namespace = supervisor_identity_mount_from_env() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let Some(namespace) = namespace else { + return cmd.spawn(); + }; + namespace.spawn_tokio_command(cmd) +} + +#[cfg(target_os = "linux")] +pub fn spawn_std_command_with_supervisor_identity_namespace( + mut cmd: std::process::Command, +) -> std::io::Result { + let namespace = supervisor_identity_mount_from_env() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let Some(namespace) = namespace else { + return cmd.spawn(); + }; + namespace.spawn_std_command(cmd) +} + +#[cfg(target_os = "linux")] +impl SupervisorIdentityMountNamespace { + fn spawn_tokio_command(&self, mut cmd: Command) -> std::io::Result { + let (result_tx, result_rx) = mpsc::channel(); + let handle = tokio::runtime::Handle::current(); + self.spawn_tx + .send(Box::new(move || { + let _guard = handle.enter(); + let _ = result_tx.send(cmd.spawn()); + })) + .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; + result_rx + .recv() + .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? + } + + fn spawn_std_command( + &self, + mut cmd: std::process::Command, + ) -> std::io::Result { + let (result_tx, result_rx) = mpsc::channel(); + self.spawn_tx + .send(Box::new(move || { + let _ = result_tx.send(cmd.spawn()); + })) + .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; + result_rx + .recv() + .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? + } +} + +#[cfg(target_os = "linux")] +fn start_supervisor_identity_spawn_worker( + target: PathBuf, +) -> Result> { + let (spawn_tx, spawn_rx) = mpsc::channel::(); + let (ready_tx, ready_rx) = mpsc::channel::>(); + std::thread::Builder::new() + .name("openshell-identity-spawn".into()) + .spawn(move || { + let setup = (|| -> std::io::Result<()> { + private_mount_namespace()?; + let target = + cstring_path(&target).map_err(|err| std::io::Error::other(err.to_string()))?; + mount_empty_tmpfs(&target) + })(); + let ready = match &setup { + Ok(()) => Ok(()), + Err(err) => Err(std::io::Error::new( + err.kind(), + format!("supervisor identity setup failed: {err}"), + )), + }; + let _ = ready_tx.send(ready); + if setup.is_err() { + return; + } + while let Ok(job) = spawn_rx.recv() { + job(); + } + }) + .map_err(|err| miette::miette!("failed to spawn supervisor identity worker: {err}"))?; + ready_rx + .recv() + .map_err(|err| miette::miette!("supervisor identity worker did not start: {err}"))? + .map_err(|err| miette::miette!("{err}"))?; + Ok(spawn_tx) +} + #[cfg(target_os = "linux")] fn supervisor_identity_socket_path_from_env() -> Option<(&'static str, String)> { std::env::var(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) @@ -360,10 +566,7 @@ fn supervisor_identity_mount_target(socket_path: &str) -> Result return Ok(None); } if trimmed.starts_with("tcp:") { - return Err(miette::miette!( - "{} must be a UNIX socket path so sandbox child processes can hide it", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET - )); + return Ok(None); } let path = trimmed.strip_prefix("unix:").unwrap_or(trimmed); let path = Path::new(path); @@ -406,52 +609,12 @@ fn cstring_path(path: &Path) -> Result { .map_err(|_| miette::miette!("path contains an interior NUL byte: {}", path.display())) } -#[cfg(target_os = "linux")] -fn create_supervisor_identity_mount_namespace(target: &Path) -> Result { - let original_ns = open_current_mount_namespace() - .map_err(|err| miette::miette!("failed to open original mount namespace: {err}"))?; - - private_mount_namespace() - .map_err(|err| miette::miette!("failed to create supervisor identity namespace: {err}"))?; - - let target = cstring_path(target)?; - let result = (|| -> Result { - mount_empty_tmpfs(&target).map_err(|err| { - miette::miette!("failed to hide supervisor identity mount from child namespace: {err}") - })?; - open_current_mount_namespace() - .map_err(|err| miette::miette!("failed to open sanitized mount namespace: {err}")) - })(); - - set_mount_namespace(original_ns.as_raw_fd()).map_err(|restore_err| { - let result_msg = result.as_ref().err().map_or_else( - || "sanitized namespace was created".to_string(), - ToString::to_string, - ); - miette::miette!( - "failed to restore original mount namespace after supervisor identity isolation setup: \ - {restore_err}; setup result: {result_msg}" - ) - })?; - - result -} - -#[cfg(target_os = "linux")] -fn open_current_mount_namespace() -> std::io::Result { - let file = std::fs::File::open("/proc/thread-self/ns/mnt")?; - Ok(file.into()) -} - #[cfg(target_os = "linux")] fn private_mount_namespace() -> std::io::Result<()> { #[allow(unsafe_code)] let rc = unsafe { libc::unshare(libc::CLONE_NEWNS) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to create private mount namespace: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } #[allow(unsafe_code)] @@ -466,23 +629,7 @@ fn private_mount_namespace() -> std::io::Result<()> { ) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to mark mount namespace private: {}", - std::io::Error::last_os_error() - ))); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn set_mount_namespace(fd: RawFd) -> std::io::Result<()> { - #[allow(unsafe_code)] - let rc = unsafe { libc::setns(fd, libc::CLONE_NEWNS) }; - if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to enter mount namespace: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } Ok(()) } @@ -502,10 +649,7 @@ fn mount_empty_tmpfs(target: &CString) -> std::io::Result<()> { ) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to hide supervisor identity mount from child process: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } Ok(()) } @@ -514,6 +658,18 @@ fn mount_empty_tmpfs(target: &CString) -> std::io::Result<()> { pub struct ProcessHandle { child: Child, pid: u32, + io: Option, +} + +/// Supervisor-owned canonical-process I/O. These handles outlive individual +/// SSH attachments and are consumed by the main-session multiplexer. +pub enum ProcessIo { + Pty(std::fs::File), + Pipes { + stdin: ChildStdin, + stdout: ChildStdout, + stderr: ChildStderr, + }, } impl ProcessHandle { @@ -527,7 +683,7 @@ impl ProcessHandle { pub fn spawn( program: &str, args: &[String], - workdir: Option<&str>, + workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, resolved_identity: ResolvedProcessIdentity, @@ -539,7 +695,7 @@ impl ProcessHandle { Self::spawn_impl( program, args, - workdir, + workspace, interactive, policy, resolved_identity, @@ -560,7 +716,7 @@ impl ProcessHandle { pub fn spawn( program: &str, args: &[String], - workdir: Option<&str>, + workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, resolved_identity: ResolvedProcessIdentity, @@ -571,7 +727,7 @@ impl ProcessHandle { Self::spawn_impl( program, args, - workdir, + workspace, interactive, policy, resolved_identity, @@ -586,7 +742,7 @@ impl ProcessHandle { fn spawn_impl( program: &str, args: &[String], - workdir: Option<&str>, + workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, resolved_identity: ResolvedProcessIdentity, @@ -597,12 +753,32 @@ impl ProcessHandle { ) -> Result { let mut cmd = Command::new(program); cmd.args(args) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) .kill_on_drop(true) .env(openshell_core::sandbox_env::SANDBOX, "1"); + let mut pty_master = None; + let mut terminal_slave_fd = None; + if interactive { + let winsize = nix::pty::Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let pty = nix::pty::openpty(Some(&winsize), None).into_diagnostic()?; + let master = std::fs::File::from(pty.master); + let slave = std::fs::File::from(pty.slave); + terminal_slave_fd = Some(slave.as_raw_fd()); + cmd.stdin(slave.try_clone().into_diagnostic()?) + .stdout(slave.try_clone().into_diagnostic()?) + .stderr(slave); + pty_master = Some(master); + } else { + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } + // Strip supervisor-only identity material from the entrypoint's // inherited environment. The entrypoint drops to the sandbox user // before `exec`; without this strip, sandbox code could recover @@ -610,8 +786,15 @@ impl ProcessHandle { strip_supervisor_only_env(&mut cmd); inject_provider_env(&mut cmd, provider_env); + apply_canonical_process_environment( + &mut cmd, + policy, + workspace, + interactive, + &configured_user_environment(), + ); - if let Some(dir) = workdir { + if let Some(dir) = workspace.root() { cmd.current_dir(dir); } @@ -651,7 +834,7 @@ impl ProcessHandle { // pre_exec context cannot reliably emit structured logs. #[cfg(target_os = "linux")] if enforcement_mode.enforces_child_sandbox() { - sandbox::linux::log_sandbox_readiness(policy, workdir); + sandbox::linux::log_sandbox_readiness(policy, workspace.root()); } // Phase 1: Prepare Landlock ruleset by opening PathFds. @@ -660,17 +843,8 @@ impl ProcessHandle { // runs as the sandbox UID, so inaccessible paths are unavailable to // the workload and best-effort compatibility skips them. #[cfg(target_os = "linux")] - let prepared_sandbox = prepare_child_sandbox(policy, workdir, enforcement_mode) + let prepared_sandbox = prepare_child_sandbox(policy, workspace.root(), enforcement_mode) .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; - #[cfg(target_os = "linux")] - let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() { - supervisor_identity_mount_from_env().map_err(|err| { - miette::miette!("Failed to prepare supervisor identity isolation: {err}") - })? - } else { - None - }; - // 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. @@ -685,24 +859,26 @@ impl ProcessHandle { #[allow(unsafe_code)] unsafe { cmd.pre_exec(move || { - if !interactive { - // Create new process group - libc::setpgid(0, 0); + if let Some(slave_fd) = terminal_slave_fd { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + set_controlling_tty(slave_fd)?; + } else if libc::setpgid(0, 0) < 0 { + return Err(std::io::Error::last_os_error()); } - // Enter network namespace before applying other restrictions + // Enter network namespace before applying other restrictions. if let Some(fd) = netns_fd { let result = libc::setns(fd, libc::CLONE_NEWNET); if result != 0 { - return Err(std::io::Error::last_os_error()); + return Err(std::io::Error::other(format!( + "failed to enter network namespace: {}", + std::io::Error::last_os_error() + ))); } } - #[cfg(target_os = "linux")] - if let Some(mount) = supervisor_identity_mount { - mount.enter_for_child()?; - } - // Drop privileges. initgroups/setgid/setuid need access to // /etc/group and /etc/passwd which would be blocked if // Landlock were already enforced. @@ -727,13 +903,39 @@ impl ProcessHandle { } } - let child = cmd.spawn().into_diagnostic()?; + // Name the program in the error: a bare "No such file or directory" + // here is otherwise indistinguishable from a missing working directory + // or interpreter, and is a common failure on images that lack the + // requested shell/binary (e.g. bash on Alpine). + #[cfg(target_os = "linux")] + let mut child = spawn_command_with_supervisor_identity_namespace(cmd) + .into_diagnostic() + .wrap_err_with(|| format!("failed to spawn sandbox entrypoint process '{program}'"))?; + #[cfg(not(target_os = "linux"))] + let mut child = cmd + .spawn() + .into_diagnostic() + .wrap_err_with(|| format!("failed to spawn sandbox entrypoint process '{program}'"))?; let pid = child.id().unwrap_or(0); managed_children::register(pid); + let io = if let Some(master) = pty_master { + ProcessIo::Pty(master) + } else { + ProcessIo::Pipes { + stdin: child.stdin.take().expect("canonical stdin must be piped"), + stdout: child.stdout.take().expect("canonical stdout must be piped"), + stderr: child.stderr.take().expect("canonical stderr must be piped"), + } + }; + debug!(pid, program, "Process spawned"); - Ok(Self { child, pid }) + Ok(Self { + child, + pid, + io: Some(io), + }) } #[cfg(not(target_os = "linux"))] @@ -741,7 +943,7 @@ impl ProcessHandle { fn spawn_impl( program: &str, args: &[String], - workdir: Option<&str>, + workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, resolved_identity: ResolvedProcessIdentity, @@ -751,19 +953,48 @@ impl ProcessHandle { ) -> Result { let mut cmd = Command::new(program); cmd.args(args) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) .kill_on_drop(true) .env(openshell_core::sandbox_env::SANDBOX, "1"); + let mut pty_master = None; + let mut terminal_slave_fd = None; + #[cfg(unix)] + if interactive { + let winsize = nix::pty::Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let pty = nix::pty::openpty(Some(&winsize), None).into_diagnostic()?; + let master = std::fs::File::from(pty.master); + let slave = std::fs::File::from(pty.slave); + terminal_slave_fd = Some(slave.as_raw_fd()); + cmd.stdin(slave.try_clone().into_diagnostic()?) + .stdout(slave.try_clone().into_diagnostic()?) + .stderr(slave); + pty_master = Some(master); + } + if !interactive { + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } + // Strip supervisor-only identity material from the entrypoint's // inherited environment. strip_supervisor_only_env(&mut cmd); inject_provider_env(&mut cmd, provider_env); + apply_canonical_process_environment( + &mut cmd, + policy, + workspace, + interactive, + &configured_user_environment(), + ); - if let Some(dir) = workdir { + if let Some(dir) = workspace.root() { cmd.current_dir(dir); } @@ -788,21 +1019,25 @@ impl ProcessHandle { } } - // 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. + // Create a dedicated session for PTY children and a dedicated process + // group for pipe children so attachment signals target only the + // canonical workload tree. // SAFETY: pre_exec runs after fork but before exec in the child process. // setpgid is async-signal-safe and safe to call in this context. #[cfg(unix)] { let policy = policy.clone(); - let workdir = workdir.map(str::to_string); + let workdir = workspace.owned_root(); #[allow(unsafe_code)] unsafe { cmd.pre_exec(move || { - if !interactive { - // Create new process group - libc::setpgid(0, 0); + if let Some(slave_fd) = terminal_slave_fd { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + set_controlling_tty(slave_fd)?; + } else if libc::setpgid(0, 0) < 0 { + return Err(std::io::Error::last_os_error()); } // Drop privileges before applying sandbox restrictions. @@ -825,14 +1060,28 @@ impl ProcessHandle { } } - let child = cmd.spawn().into_diagnostic()?; + let mut child = cmd.spawn().into_diagnostic()?; let pid = child.id().unwrap_or(0); #[cfg(target_os = "linux")] managed_children::register(pid); debug!(pid, program, "Process spawned"); - Ok(Self { child, pid }) + let io = if let Some(master) = pty_master { + ProcessIo::Pty(master) + } else { + ProcessIo::Pipes { + stdin: child.stdin.take().expect("canonical stdin must be piped"), + stdout: child.stdout.take().expect("canonical stdout must be piped"), + stderr: child.stderr.take().expect("canonical stderr must be piped"), + } + }; + + Ok(Self { + child, + pid, + io: Some(io), + }) } /// Get the process ID. @@ -841,6 +1090,11 @@ impl ProcessHandle { self.pid } + /// Transfer retained stdio to the main-session multiplexer. + pub fn take_io(&mut self) -> ProcessIo { + self.io.take().expect("canonical process I/O already taken") + } + /// Wait for the process to exit. /// /// # Errors @@ -854,6 +1108,16 @@ impl ProcessHandle { Ok(ProcessStatus::from(status)) } + /// Observe an already-terminated child without blocking. + pub fn try_wait(&mut self) -> std::io::Result> { + let status = self.child.try_wait()?; + if status.is_some() { + #[cfg(target_os = "linux")] + managed_children::unregister(self.pid); + } + Ok(status.map(ProcessStatus::from)) + } + /// Send a signal to the process. /// /// # Errors @@ -1253,133 +1517,585 @@ fn chown_sandbox_home(root: &Path, uid: Option, gid: Option) -> Result } #[cfg(unix)] -fn chown_children( - dir: &Path, +fn prepare_oci_workspace( + root: &Path, uid: Option, gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, + supplementary_gids: &[Gid], ) -> Result<()> { - match std::fs::read_dir(dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.into_diagnostic()?; - chown_recursive(&entry.path(), uid, gid, do_chown)?; - } - } - Err(error) => { - debug!( - path = %dir.display(), - %error, - "Cannot list directory during sandbox home chown" - ); - } - } - Ok(()) + prepare_oci_workspace_with(root, uid, gid, supplementary_gids, &nix::unistd::chown) } +/// Validate that selecting an image-provided OCI workdir does not grant the +/// sandbox identity any filesystem authority it lacked in the immutable image. +/// +/// Every path component must be a real directory (never a symlink), every +/// parent must already be traversable, and the final directory must already be +/// writable and traversable. No ownership or mode bits are changed. #[cfg(unix)] -fn chown_recursive( - path: &Path, +pub fn validate_oci_workspace( + root: &Path, uid: Option, gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, + supplementary_gids: &[Gid], ) -> Result<()> { - let meta = std::fs::symlink_metadata(path).into_diagnostic()?; - if meta.file_type().is_symlink() { - debug!(path = %path.display(), "Skipping symlink during sandbox home chown"); - return Ok(()); - } - - if let Err(error) = do_chown(path, uid, gid) { - if error == nix::errno::Errno::EROFS { - debug!(path = %path.display(), "Skipping read-only path during sandbox home chown"); - return Ok(()); - } - return Err(error).into_diagnostic(); - } - - if meta.is_dir() { - chown_children(path, uid, gid, do_chown)?; + let components = validated_workspace_components(root, false)?; + let mut current = PathBuf::from("/"); + validate_workspace_component(¤t, uid, gid, supplementary_gids, false)?; + let last_component = components.len().saturating_sub(1); + for (index, component) in components.into_iter().enumerate() { + current.push(component); + validate_workspace_component( + ¤t, + uid, + gid, + supplementary_gids, + index == last_component, + )?; } - Ok(()) } -/// Prepare filesystem for the sandboxed process. -/// -/// Creates `read_write` directories if they don't exist and sets ownership -/// on newly-created paths to the configured sandbox user/group. This runs as -/// the supervisor (root) before forking the child process. -/// -/// Accepts both name-based identities (resolved via `/etc/passwd`) and numeric -/// UIDs/GIDs (passed directly to `chown` without a passwd lookup). -#[cfg(unix)] -pub fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { - prepare_filesystem_with_identity(policy, ResolvedProcessIdentity::default()) -} - -#[cfg(unix)] -pub fn prepare_filesystem_with_identity( +/// Validate an image-provided workdir in a clean copy of the supervisor so the +/// main process retains the root authority needed for subsequent setup. +#[cfg(target_os = "linux")] +fn validate_oci_workspace_in_subprocess( policy: &SandboxPolicy, resolved_identity: ResolvedProcessIdentity, + workdir: &Path, ) -> Result<()> { - use nix::unistd::chown; - use nix::unistd::{Gid, Uid}; - - let user_name = match policy.process.run_as_user.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; - let group_name = match policy.process.run_as_group.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; + use std::os::unix::process::CommandExt; + + let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; + let uid = uid.ok_or_else(|| miette::miette!("workspace validator UID is unresolved"))?; + let gid = gid.ok_or_else(|| miette::miette!("workspace validator GID is unresolved"))?; + let groups = supplementary_gids + .iter() + .map(|group| group.as_raw()) + .collect::>(); + let executable = std::env::current_exe().into_diagnostic()?; + let mut command = std::process::Command::new(executable); + command + .arg("validate-workspace") + .arg("--workdir") + .arg(workdir) + .arg("--expected-uid") + .arg(uid.to_string()) + .arg("--expected-gid") + .arg(gid.to_string()) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + // `pre_exec` runs after fork and before exec. These direct credential + // syscalls are async-signal-safe and affect only the one-shot child. + #[allow(unsafe_code)] + unsafe { + command.pre_exec(move || { + if libc::setgroups(groups.len(), groups.as_ptr()) != 0 + || libc::setgid(gid.as_raw()) != 0 + || libc::setuid(uid.as_raw()) != 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } - // If no user/group configured, nothing to do - if user_name.is_none() && group_name.is_none() { + let output = command.output().into_diagnostic()?; + if output.status.success() { return Ok(()); } - // Resolve UID: numeric values are passed directly; names resolve via passwd. - let uid = match resolved_identity.uid() { - Some(uid) => Some(Uid::from_raw(uid)), - None => match user_name { - Some(name) if name.parse::().is_ok() => { - Some(Uid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => User::from_name(name).into_diagnostic()?.map(|u| u.uid), - _ => None, - }, - }; - - // Resolve GID: numeric values are passed directly; names resolve via group. - let gid = match resolved_identity.gid() { - Some(gid) => Some(Gid::from_raw(gid)), - None => match group_name { - Some(name) if name.parse::().is_ok() => { - Some(Gid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => Group::from_name(name).into_diagnostic()?.map(|g| g.gid), - _ => None, - }, - }; - - // Create missing read_write paths and only chown the ones we created. - for path in &policy.filesystem.read_write { - if prepare_read_write_path(path)? { - debug!( - path = %path.display(), - ?uid, - ?gid, - "Setting ownership on newly created read_write path" - ); - chown(path, uid, gid).into_diagnostic()?; - } + let diagnostic = String::from_utf8_lossy(&output.stderr); + let diagnostic = diagnostic.trim(); + if diagnostic.is_empty() { + return Err(miette::miette!( + "image workspace validation failed with status {}", + output.status + )); + } + Err(miette::miette!( + "image workspace validation failed: {diagnostic}" + )) +} + +#[cfg(unix)] +fn validate_workspace_component( + path: &Path, + uid: Option, + gid: Option, + supplementary_gids: &[Gid], + is_workspace: bool, +) -> Result<()> { + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + miette::miette!( + "image workspace path component '{}' does not exist", + path.display() + ) + } else { + miette::miette!( + "failed to inspect image workspace path component '{}': {error}", + path.display() + ) + } + })?; + if metadata.file_type().is_symlink() { + return Err(miette::miette!( + "workspace path component '{}' is a symlink — refusing to follow it", + path.display() + )); + } + if !metadata.is_dir() { + return Err(miette::miette!( + "workspace path component '{}' is not a directory", + path.display() + )); + } + let required = if is_workspace { 0o3 } else { 0o1 }; + if !identity_has_permissions(&metadata, uid, gid, supplementary_gids, required) { + let requirement = if is_workspace { + "writable and traversable" + } else { + "traversable" + }; + return Err(miette::miette!( + "workspace path component '{}' is not {requirement} by the sandbox identity in the image", + path.display() + )); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn validate_oci_workspace_as_effective_identity(root: &Path) -> Result<()> { + use rustix::fs::{Access, AtFlags, FileType, Mode, OFlags}; + + let components = validated_workspace_components(root, false)?; + let open_flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut current_path = PathBuf::from("/"); + let mut current_fd = rustix::fs::open("/", open_flags, Mode::empty()).into_diagnostic()?; + rustix::fs::accessat( + ¤t_fd, + ".", + Access::EXEC_OK, + AtFlags::EACCESS | AtFlags::SYMLINK_NOFOLLOW, + ) + .map_err(|error| { + miette::miette!( + "workspace path component '{}' is not traversable by the sandbox identity in the image: {error}", + current_path.display() + ) + })?; + + let last_component = components.len().saturating_sub(1); + for (index, component) in components.into_iter().enumerate() { + current_path.push(&component); + let stat = rustix::fs::statat(¤t_fd, &component, AtFlags::SYMLINK_NOFOLLOW).map_err( + |error| { + if error == rustix::io::Errno::NOENT { + miette::miette!( + "image workspace path component '{}' does not exist", + current_path.display() + ) + } else { + miette::miette!( + "failed to inspect image workspace path component '{}': {error}", + current_path.display() + ) + } + }, + )?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_symlink() { + return Err(miette::miette!( + "workspace path component '{}' is a symlink — refusing to follow it", + current_path.display() + )); + } + if !file_type.is_dir() { + return Err(miette::miette!( + "workspace path component '{}' is not a directory", + current_path.display() + )); + } + + let is_workspace = index == last_component; + rustix::fs::accessat( + ¤t_fd, + &component, + Access::EXEC_OK, + AtFlags::EACCESS | AtFlags::SYMLINK_NOFOLLOW, + ) + .map_err(|error| { + miette::miette!( + "workspace path component '{}' is not traversable by the sandbox identity in the image: {error}", + current_path.display() + ) + })?; + + let next_fd = rustix::fs::openat(¤t_fd, &component, open_flags, Mode::empty()) + .map_err(|error| { + miette::miette!( + "failed to open image workspace path component '{}': {error}", + current_path.display() + ) + })?; + if is_workspace { + validate_effective_workspace_write(&next_fd, ¤t_path)?; + } + current_fd = next_fd; + } + + Ok(()) +} + +#[cfg(target_os = "linux")] +fn validate_effective_workspace_write(fd: &impl std::os::fd::AsFd, path: &Path) -> Result<()> { + use rustix::fs::{AtFlags, Mode, OFlags}; + + let mode = Mode::RUSR | Mode::WUSR; + let tmpfile_flags = OFlags::TMPFILE | OFlags::WRONLY | OFlags::CLOEXEC; + match rustix::fs::openat(fd, ".", tmpfile_flags, mode) { + Ok(_probe) => return Ok(()), + Err(rustix::io::Errno::INVAL | rustix::io::Errno::ISDIR | rustix::io::Errno::NOTSUP) => {} + Err(error) => { + return Err(miette::miette!( + "workspace path component '{}' is not writable by the sandbox identity in the image: {error}", + path.display() + )); + } + } + + // Some filesystems do not implement O_TMPFILE. Fall back to a short-lived, + // no-follow entry. A collision fails closed after bounded retries. + let create_flags = + OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + for attempt in 0..16 { + let name = format!(".openshell-workdir-probe-{}-{attempt}", std::process::id()); + match rustix::fs::openat(fd, &name, create_flags, mode) { + Ok(_probe) => { + rustix::fs::unlinkat(fd, &name, AtFlags::empty()).map_err(|error| { + miette::miette!( + "workspace write probe cleanup failed for '{}': {error}", + path.display() + ) + })?; + return Ok(()); + } + Err(rustix::io::Errno::EXIST) => {} + Err(error) => { + return Err(miette::miette!( + "workspace path component '{}' is not writable by the sandbox identity in the image: {error}", + path.display() + )); + } + } + } + + Err(miette::miette!( + "workspace write probe could not allocate a unique entry in '{}'", + path.display() + )) +} + +/// Prepare only the resolved `OpenShell` workspace directory itself. +/// +/// Image-provided children retain their declared ownership. This avoids +/// crossing symlinks or user-provided nested mounts. +#[cfg(unix)] +fn prepare_oci_workspace_with( + root: &Path, + uid: Option, + gid: Option, + supplementary_gids: &[Gid], + do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, +) -> Result<()> { + let components = validated_workspace_components(root, true)?; + + let last_component = components.len().saturating_sub(1); + let mut current = PathBuf::from("/"); + for (index, component) in components.into_iter().enumerate() { + current.push(component); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(miette::miette!( + "workspace path component '{}' is a symlink — refusing to follow it", + current.display() + )); + } + Ok(metadata) if !metadata.is_dir() => { + return Err(miette::miette!( + "workspace path component '{}' is not a directory", + current.display() + )); + } + Ok(metadata) => { + if index != last_component + && !identity_can_traverse(&metadata, uid, gid, supplementary_gids) + { + return Err(miette::miette!( + "workspace parent '{}' is not traversable by the sandbox identity", + current.display() + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir(¤t).into_diagnostic()?; + std::fs::set_permissions(¤t, std::fs::Permissions::from_mode(0o755)) + .into_diagnostic()?; + } + Err(error) => return Err(error).into_diagnostic(), + } + } + + do_chown(root, uid, gid).into_diagnostic()?; + + let metadata = std::fs::symlink_metadata(root).into_diagnostic()?; + let mode = metadata.permissions().mode() & 0o7777; + if mode & 0o300 != 0o300 { + std::fs::set_permissions(root, std::fs::Permissions::from_mode(mode | 0o300)) + .into_diagnostic()?; + } + Ok(()) +} + +#[cfg(unix)] +fn validated_workspace_components( + root: &Path, + allow_managed_fallback: bool, +) -> Result> { + let root_str = root + .to_str() + .ok_or_else(|| miette::miette!("workspace path must be valid UTF-8"))?; + let validated_root = openshell_core::driver_mounts::resolve_oci_workspace_root(root_str) + .map_err(|error| miette::miette!(error))?; + if Path::new(&validated_root) != root + || (!allow_managed_fallback + && validated_root == openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) + { + return Err(miette::miette!( + "workspace path '{}' must be a normalized absolute {}path", + root.display(), + if allow_managed_fallback { + "non-root " + } else { + "non-fallback " + } + )); + } + + root.components() + .skip(1) + .map(|component| match component { + std::path::Component::Normal(component) => Ok(component.to_os_string()), + _ => Err(miette::miette!( + "workspace path '{}' must be normalized", + root.display() + )), + }) + .collect() +} + +#[cfg(unix)] +fn identity_can_traverse( + metadata: &std::fs::Metadata, + uid: Option, + gid: Option, + supplementary_gids: &[Gid], +) -> bool { + identity_has_permissions(metadata, uid, gid, supplementary_gids, 0o1) +} + +#[cfg(unix)] +fn identity_has_permissions( + metadata: &std::fs::Metadata, + uid: Option, + gid: Option, + supplementary_gids: &[Gid], + required: u32, +) -> bool { + let user_id = uid.unwrap_or_else(nix::unistd::geteuid).as_raw(); + if user_id == 0 { + return true; + } + + let group_id = gid.unwrap_or_else(nix::unistd::getegid).as_raw(); + let mode = metadata.permissions().mode(); + if metadata.uid() == user_id { + mode & (required << 6) == required << 6 + } else if metadata.gid() == group_id + || supplementary_gids + .iter() + .any(|supplementary_gid| supplementary_gid.as_raw() == metadata.gid()) + { + mode & (required << 3) == required << 3 + } else { + mode & required == required + } +} + +#[cfg(not(any( + target_os = "aix", + target_os = "haiku", + target_os = "illumos", + target_os = "ios", + target_os = "macos", + target_os = "redox", + target_os = "solaris" +)))] +fn named_user_supplementary_groups(user_name: &str, primary_gid: Gid) -> Result> { + let user_name = CString::new(user_name).map_err(|_| miette::miette!("Invalid user name"))?; + nix::unistd::getgrouplist(user_name.as_c_str(), primary_gid).into_diagnostic() +} + +#[cfg(any( + target_os = "aix", + target_os = "haiku", + target_os = "illumos", + target_os = "ios", + target_os = "macos", + target_os = "redox", + target_os = "solaris" +))] +#[allow(clippy::unnecessary_wraps)] +fn named_user_supplementary_groups(_user_name: &str, _primary_gid: Gid) -> Result> { + // Privilege dropping does not call initgroups on these targets. + Ok(Vec::new()) +} + +#[cfg(unix)] +fn chown_children( + dir: &Path, + uid: Option, + gid: Option, + do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, +) -> Result<()> { + match std::fs::read_dir(dir) { + Ok(entries) => { + for entry in entries { + let entry = entry.into_diagnostic()?; + chown_recursive(&entry.path(), uid, gid, do_chown)?; + } + } + Err(error) => { + debug!( + path = %dir.display(), + %error, + "Cannot list directory during sandbox home chown" + ); + } + } + Ok(()) +} + +#[cfg(unix)] +fn chown_recursive( + path: &Path, + uid: Option, + gid: Option, + do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, +) -> Result<()> { + let meta = std::fs::symlink_metadata(path).into_diagnostic()?; + if meta.file_type().is_symlink() { + debug!(path = %path.display(), "Skipping symlink during sandbox home chown"); + return Ok(()); + } + + if let Err(error) = do_chown(path, uid, gid) { + if error == nix::errno::Errno::EROFS { + debug!(path = %path.display(), "Skipping read-only path during sandbox home chown"); + return Ok(()); + } + return Err(error).into_diagnostic(); + } + + if meta.is_dir() { + chown_children(path, uid, gid, do_chown)?; + } + + Ok(()) +} + +/// Prepare filesystem for the sandboxed process. +/// +/// Creates `read_write` directories if they don't exist and sets ownership +/// on newly-created paths to the configured sandbox user/group. This runs as +/// the supervisor (root) before forking the child process. +/// +/// Accepts both name-based identities (resolved via `/etc/passwd`) and numeric +/// UIDs/GIDs (passed directly to `chown` without a passwd lookup). +#[cfg(unix)] +pub fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { + prepare_filesystem_with_identity(policy, ResolvedProcessIdentity::default(), None, false) +} + +#[cfg(unix)] +pub fn prepare_filesystem_with_identity( + policy: &SandboxPolicy, + resolved_identity: ResolvedProcessIdentity, + workdir: Option<&str>, + prepare_workspace: bool, +) -> Result<()> { + use nix::unistd::chown; + + // If no user/group configured, nothing to do + if policy + .process + .run_as_user + .as_deref() + .is_none_or(str::is_empty) + && policy + .process + .run_as_group + .as_deref() + .is_none_or(str::is_empty) + { + return Ok(()); + } + + let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; + + // Docker owns workspace resolution and must make the selected root usable + // by the final effective identity, including when both policy identity + // fields were explicit. Validate it before processing any user-authored + // read-write paths so an unsafe image path fails first. Other drivers + // retain their preparation. + if prepare_workspace { + let workspace = workdir.ok_or_else(|| { + miette::miette!("local container driver did not supply a workspace workdir") + })?; + let workspace = Path::new(workspace); + if workspace == Path::new(openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) { + info!(path = %workspace.display(), ?uid, ?gid, "Preparing managed workspace"); + prepare_oci_workspace(workspace, uid, gid, &supplementary_gids)?; + } else { + info!(path = %workspace.display(), ?uid, ?gid, "Validating image workspace authority"); + #[cfg(target_os = "linux")] + validate_oci_workspace_in_subprocess(policy, resolved_identity, workspace)?; + #[cfg(not(target_os = "linux"))] + validate_oci_workspace(workspace, uid, gid, &supplementary_gids)?; + } + } + + // Create missing read_write paths and only chown the ones we created. + for path in &policy.filesystem.read_write { + if prepare_read_write_path(path)? { + debug!( + path = %path.display(), + ?uid, + ?gid, + "Setting ownership on newly created read_write path" + ); + chown(path, uid, gid).into_diagnostic()?; + } } // Retain the existing Kubernetes/OpenShift behavior for driver-injected - // numeric identities. Docker and Podman clear this variable and do not - // receive identity-specific workspace preparation. + // numeric identities. Docker clears this variable and does not receive + // identity-specific workspace preparation. if std::env::var(openshell_core::sandbox_env::SANDBOX_UID).is_ok_and(|uid| !uid.is_empty()) { let sandbox_home = Path::new("/sandbox"); if sandbox_home.exists() { @@ -1391,6 +2107,72 @@ pub fn prepare_filesystem_with_identity( Ok(()) } +#[cfg(unix)] +fn resolve_filesystem_identity( + policy: &SandboxPolicy, + resolved_identity: ResolvedProcessIdentity, +) -> Result<(Option, Option, Vec)> { + let user_name = policy + .process + .run_as_user + .as_deref() + .filter(|name| !name.is_empty()); + let group_name = policy + .process + .run_as_group + .as_deref() + .filter(|name| !name.is_empty()); + + let uid = match resolved_identity.uid() { + Some(uid) => Some(Uid::from_raw(uid)), + None => match user_name { + Some(name) if name.parse::().is_ok() => { + Some(Uid::from_raw(name.parse().into_diagnostic()?)) + } + Some(name) => User::from_name(name).into_diagnostic()?.map(|u| u.uid), + _ => None, + }, + }; + + // Resolve GID: numeric values are passed directly; names resolve via group. + let gid = match resolved_identity.gid() { + Some(gid) => Some(Gid::from_raw(gid)), + None => match group_name { + Some(name) if name.parse::().is_ok() => { + Some(Gid::from_raw(name.parse().into_diagnostic()?)) + } + Some(name) => Group::from_name(name).into_diagnostic()?.map(|g| g.gid), + _ => None, + }, + }; + + let supplementary_gids = match user_name { + Some(name) if name.parse::().is_err() => { + let primary_gid = if let Some(gid) = gid { + gid + } else { + let uid = + uid.ok_or_else(|| miette::miette!("Failed to resolve sandbox user '{name}'"))?; + User::from_uid(uid) + .into_diagnostic()? + .ok_or_else(|| miette::miette!("Failed to resolve user from UID {uid}"))? + .gid + }; + if resolved_identity.uid().is_some() { + crate::identity::resolve_oci_supplementary_gids(name, primary_gid.as_raw())? + .into_iter() + .map(Gid::from_raw) + .collect() + } else { + named_user_supplementary_groups(name, primary_gid)? + } + } + _ => Vec::new(), + }; + + Ok((uid, gid, supplementary_gids)) +} + #[cfg(not(unix))] pub fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> { Ok(()) @@ -1404,19 +2186,6 @@ pub fn drop_privileges(policy: &SandboxPolicy) -> Result<()> { drop_privileges_with_identity(policy, ResolvedProcessIdentity::default()) } -#[cfg(unix)] -fn should_clear_supplementary_groups( - current_uid: Uid, - target_uid: Uid, - user_name: Option<&str>, - resolved_identity: ResolvedProcessIdentity, -) -> bool { - resolved_identity.uses_oci_user_fallback() - && target_uid != current_uid - && !(user_name.is_some_and(|name| name.parse::().is_err()) - && resolved_identity.uid().is_none()) -} - #[cfg(unix)] #[allow(clippy::similar_names)] pub fn drop_privileges_with_identity( @@ -1512,23 +2281,21 @@ pub fn drop_privileges_with_identity( }; if target_uid != nix::unistd::geteuid() { - if should_clear_supplementary_groups( - nix::unistd::geteuid(), - target_uid, - user_name, - resolved_identity, - ) { - // OCI-derived users do not have a trustworthy NSS - // supplementary-group source. Clear the root supervisor's - // inherited groups before changing UID/GID. Platform-resolved and - // explicit numeric identities retain their pre-OCI behavior. + if resolved_identity.uses_oci_user_fallback() { + // OCI named users use the bounded /etc/group parser shared with + // workspace validation. Numeric OCI users resolve to an empty + // list. Never retain the root supervisor's inherited groups. #[cfg(not(any( target_os = "macos", target_os = "ios", target_os = "haiku", target_os = "redox" )))] - nix::unistd::setgroups(&[]).into_diagnostic()?; + { + let (_, _, supplementary_gids) = + resolve_filesystem_identity(policy, resolved_identity)?; + nix::unistd::setgroups(&supplementary_gids).into_diagnostic()?; + } } else if let Some(ref user_name) = initgroups_name { let user_cstr = CString::new(user_name.as_str()) .map_err(|_| miette::miette!("Invalid user name"))?; @@ -1610,6 +2377,12 @@ pub struct ProcessStatus { } impl ProcessStatus { + /// Get the conventional exit code when the process exited normally. + #[must_use] + pub const fn exit_code(&self) -> Option { + self.code + } + /// Get the exit code, or 128 + signal number if killed by signal. #[must_use] pub fn code(&self) -> i32 { @@ -1677,6 +2450,45 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + async fn canonical_tty_environment_replaces_supervisor_identity_defaults() { + let current_user = User::from_uid(nix::unistd::geteuid()) + .expect("look up current user") + .expect("current user entry"); + let policy = policy_with_process(ProcessPolicy { + run_as_user: Some(current_user.name.clone()), + run_as_group: None, + }); + let workspace = ResolvedWorkspace::default(); + let mut cmd = Command::new("/usr/bin/env"); + cmd.env_clear() + .env("HOME", "/root") + .env("TERM", "dumb") + .stdout(StdStdio::piped()); + + apply_canonical_process_environment(&mut cmd, &policy, &workspace, true, &HashMap::new()); + + let output = cmd.output().await.expect("run environment probe"); + assert!(output.status.success()); + let environment = String::from_utf8(output.stdout).expect("environment is UTF-8"); + let variables: HashMap<_, _> = environment + .lines() + .filter_map(|line| line.split_once('=')) + .collect(); + + assert_eq!( + variables.get("HOME"), + Some(¤t_user.dir.to_string_lossy().as_ref()) + ); + assert_eq!(variables.get("USER"), Some(¤t_user.name.as_str())); + // SHELL is the shell detected in the current root filesystem, not a + // hardcoded path (bash-less images resolve to /bin/sh). + let expected_shell = openshell_core::shell::detect_login_shell(); + assert_eq!(variables.get("SHELL"), Some(&expected_shell.as_str())); + assert_eq!(variables.get("TERM"), Some(&"xterm-256color")); + } + /// Unknown names may yield `Ok(None)` (`… not found …`) or `Err` when NSS fails first /// (e.g. `ENOENT: No such file or directory`). fn assert_unknown_identity_lookup_failed(msg: &str) { @@ -1690,14 +2502,14 @@ mod tests { #[test] #[cfg(unix)] - fn explicit_identity_rejects_non_root_system_ids() { + fn explicit_identity_accepts_non_root_system_ids() { let policy = policy_with_process(ProcessPolicy { run_as_user: Some("101".into()), run_as_group: Some("102".into()), }); - assert!(validate_sandbox_user(&policy).is_err()); - assert!(validate_sandbox_group(&policy).is_err()); + assert!(validate_sandbox_user(&policy).is_ok()); + assert!(validate_sandbox_group(&policy).is_ok()); } #[test] @@ -1755,43 +2567,6 @@ mod tests { assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); } - #[test] - #[cfg(unix)] - fn only_oci_numeric_user_paths_clear_supplementary_groups_before_uid_drop() { - let current_uid = Uid::from_raw(0); - let target_uid = Uid::from_raw(1234); - - assert!(!should_clear_supplementary_groups( - current_uid, - target_uid, - Some("1234"), - ResolvedProcessIdentity::default(), - )); - assert!(should_clear_supplementary_groups( - current_uid, - target_uid, - Some("1234"), - ResolvedProcessIdentity::new(None, Some(1235)), - )); - } - - #[test] - #[cfg(unix)] - fn supplementary_group_clearing_preserves_explicit_named_user_behavior() { - assert!(!should_clear_supplementary_groups( - Uid::from_raw(0), - Uid::from_raw(1234), - Some("app"), - ResolvedProcessIdentity::default(), - )); - assert!(!should_clear_supplementary_groups( - Uid::from_raw(1234), - Uid::from_raw(1234), - Some("1234"), - ResolvedProcessIdentity::new(None, Some(1235)), - )); - } - #[test] fn full_enforcement_uses_privileged_setup_and_child_sandbox() { assert!(ProcessEnforcementMode::Full.uses_privileged_process_setup()); @@ -2234,117 +3009,504 @@ mod tests { #[cfg(unix)] #[test] - fn prepare_read_write_path_preserves_existing_directory() { - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); + fn prepare_read_write_path_preserves_existing_directory() { + let dir = tempfile::tempdir().unwrap(); + let existing = dir.path().join("existing"); + std::fs::create_dir(&existing).unwrap(); + + assert!(!prepare_read_write_path(&existing).unwrap()); + assert!(existing.is_dir()); + } + + #[cfg(unix)] + #[test] + fn prepare_read_write_path_rejects_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target"); + let link = dir.path().join("link"); + std::fs::create_dir(&target).unwrap(); + symlink(&target, &link).unwrap(); + + let error = prepare_read_write_path(&link).unwrap_err(); + assert!( + error + .to_string() + .contains("is a symlink — refusing to chown"), + "unexpected error: {error}" + ); + } + + #[cfg(unix)] + #[test] + fn prepare_filesystem_skips_chown_for_existing_read_write_paths() { + use std::os::unix::fs::MetadataExt; + + if nix::unistd::geteuid().is_root() { + return; + } + + let Ok(Some(current_user)) = User::from_uid(nix::unistd::geteuid()) else { + eprintln!("skipping: current UID has no /etc/passwd entry"); + return; + }; + let restricted_group = Group::from_gid(Gid::from_raw(0)) + .unwrap() + .expect("gid 0 group entry"); + if restricted_group.gid == nix::unistd::getegid() { + return; + } + + let dir = tempfile::tempdir().unwrap(); + let existing = dir.path().join("existing"); + std::fs::create_dir(&existing).unwrap(); + let before = std::fs::metadata(&existing).unwrap(); + + let policy = sandbox_policy_with_read_write( + existing.clone(), + Some(current_user.name), + Some(restricted_group.name), + ); + + prepare_filesystem(&policy).expect("existing path should not be re-owned"); + + let after = std::fs::metadata(&existing).unwrap(); + assert_eq!(after.uid(), before.uid()); + assert_eq!(after.gid(), before.gid()); + } + + #[cfg(unix)] + #[test] + #[allow(clippy::similar_names)] + fn chown_sandbox_home_changes_ownership_recursively() { + use std::os::unix::fs::MetadataExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("sandbox"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("file.txt"), "hello").unwrap(); + std::fs::create_dir(root.join("subdir")).unwrap(); + std::fs::write(root.join("subdir").join("nested.txt"), "world").unwrap(); + + let expected_uid = nix::unistd::geteuid(); + let expected_gid = nix::unistd::getegid(); + chown_sandbox_home(&root, Some(expected_uid), Some(expected_gid)).unwrap(); + + for path in &[ + root.clone(), + root.join("file.txt"), + root.join("subdir"), + root.join("subdir").join("nested.txt"), + ] { + let meta = std::fs::metadata(path).unwrap(); + assert_eq!(meta.uid(), expected_uid.as_raw()); + assert_eq!(meta.gid(), expected_gid.as_raw()); + } + } + + #[cfg(unix)] + #[test] + fn chown_sandbox_home_rejects_symlink_root() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + let link = dir.path().join("link"); + std::fs::create_dir(&target).unwrap(); + symlink(&target, &link).unwrap(); + + let err = chown_sandbox_home( + &link, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + ) + .unwrap_err(); + assert!( + err.to_string().contains("symlink"), + "expected symlink rejection: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn chown_sandbox_home_skips_symlink_children() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("sandbox"); + std::fs::create_dir(&root).unwrap(); + let target = dir.path().join("outside"); + std::fs::write(&target, "secret").unwrap(); + symlink(&target, root.join("link")).unwrap(); + + chown_sandbox_home( + &root, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + ) + .expect("symlink children should be skipped"); + } + + #[cfg(unix)] + #[test] + fn chown_recursive_skips_erofs_subtree_but_continues_siblings() { + use std::sync::{Arc, Mutex}; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("sandbox"); + std::fs::create_dir(&root).unwrap(); + + let readonly_dir = root.join("ro-mount"); + std::fs::create_dir(&readonly_dir).unwrap(); + std::fs::write(readonly_dir.join("child-under-ro.txt"), "data").unwrap(); + std::fs::write(root.join("writable-sibling.txt"), "data").unwrap(); + + let chowned = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&chowned); + let readonly_dir_for_chown = readonly_dir.clone(); + let fake_chown = + move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { + if path == readonly_dir_for_chown { + return Err(nix::errno::Errno::EROFS); + } + observed.lock().unwrap().push(path.to_path_buf()); + Ok(()) + }; + + chown_children( + &root, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &fake_chown, + ) + .expect("read-only subtree should be skipped"); + + let chowned = chowned.lock().unwrap(); + assert!( + !chowned.contains(&readonly_dir.join("child-under-ro.txt")), + "children under EROFS directory must not be traversed" + ); + assert!( + chowned.contains(&root.join("writable-sibling.txt")), + "writable sibling should still be chowned" + ); + } + + #[cfg(unix)] + #[test] + fn chown_recursive_propagates_non_erofs_errors() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("sandbox"); + std::fs::create_dir(&root).unwrap(); + let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { + Err(nix::errno::Errno::EPERM) + }; + + let result = chown_recursive( + &root, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &fake_chown, + ); + assert!(result.is_err(), "non-EROFS errors should propagate"); + } + + #[cfg(unix)] + #[test] + fn prepare_oci_workspace_chowns_only_root() { + use std::sync::{Arc, Mutex}; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap().join("sandbox"); + std::fs::create_dir(&root).unwrap(); + let child = root.join("image-content.txt"); + std::fs::write(&child, "image-owned").unwrap(); + + let chowned = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&chowned); + let fake_chown = + move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { + observed.lock().unwrap().push(path.to_path_buf()); + Ok(()) + }; + + prepare_oci_workspace_with( + &root, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &[], + &fake_chown, + ) + .expect("workspace root should be prepared"); + + assert_eq!(*chowned.lock().unwrap(), vec![root]); + assert!(child.exists(), "image-provided child should be untouched"); + } + + #[cfg(unix)] + #[test] + fn validate_oci_workspace_accepts_existing_owner_writable_directory() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap().join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + + validate_oci_workspace( + &root, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &[], + ) + .expect("image owner already has write and traverse authority"); + } + + #[cfg(unix)] + #[test] + fn validate_oci_workspace_accepts_supplementary_group_write_authority() { + let dir = tempfile::tempdir_in("/tmp").unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); + let root = dir.path().canonicalize().unwrap().join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o070)).unwrap(); + let metadata = std::fs::symlink_metadata(&root).unwrap(); + + validate_oci_workspace( + &root, + Some(Uid::from_raw(metadata.uid().wrapping_add(1))), + Some(Gid::from_raw(metadata.gid().wrapping_add(1))), + &[Gid::from_raw(metadata.gid())], + ) + .expect("supplementary group already has write and traverse authority"); + } + + #[cfg(unix)] + #[test] + fn validate_oci_workspace_rejects_unwritable_directory() { + let dir = tempfile::tempdir_in("/tmp").unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); + let root = dir.path().canonicalize().unwrap().join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); + let metadata = std::fs::symlink_metadata(&root).unwrap(); - assert!(!prepare_read_write_path(&existing).unwrap()); - assert!(existing.is_dir()); + let error = validate_oci_workspace( + &root, + Some(Uid::from_raw(metadata.uid().wrapping_add(1))), + Some(Gid::from_raw(metadata.gid().wrapping_add(1))), + &[], + ) + .unwrap_err(); + assert!(error.to_string().contains("not writable and traversable")); } #[cfg(unix)] #[test] - fn prepare_read_write_path_rejects_symlink() { - use std::os::unix::fs::symlink; - + fn validate_oci_workspace_rejects_missing_path() { let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - let link = dir.path().join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); + let root = dir.path().canonicalize().unwrap().join("missing"); - let error = prepare_read_write_path(&link).unwrap_err(); - assert!( - error - .to_string() - .contains("is a symlink — refusing to chown"), - "unexpected error: {error}" - ); + let error = validate_oci_workspace( + &root, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &[], + ) + .unwrap_err(); + assert!(error.to_string().contains("does not exist")); } - #[cfg(unix)] + #[cfg(target_os = "linux")] #[test] - fn prepare_filesystem_skips_chown_for_existing_read_write_paths() { - use std::os::unix::fs::MetadataExt; - - if nix::unistd::geteuid().is_root() { + #[allow(unsafe_code)] + fn effective_identity_validation_honors_named_user_acl() { + const TEST_UID: u32 = 42_234; + const TEST_GID: u32 = 42_235; + const ACL_XATTR_VERSION: u32 = 2; + const ACL_USER_OBJ: u16 = 0x01; + const ACL_USER: u16 = 0x02; + const ACL_GROUP_OBJ: u16 = 0x04; + const ACL_MASK: u16 = 0x10; + const ACL_OTHER: u16 = 0x20; + const ACL_UNDEFINED_ID: u32 = u32::MAX; + + if !nix::unistd::geteuid().is_root() { return; } - let Ok(Some(current_user)) = User::from_uid(nix::unistd::geteuid()) else { - eprintln!("skipping: current UID has no /etc/passwd entry"); - return; + let dir = tempfile::tempdir_in("/tmp").unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); + let root = dir.path().canonicalize().unwrap().join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let mut acl = ACL_XATTR_VERSION.to_ne_bytes().to_vec(); + for (tag, permissions, id) in [ + (ACL_USER_OBJ, 0o7_u16, ACL_UNDEFINED_ID), + (ACL_USER, 0o7_u16, TEST_UID), + (ACL_GROUP_OBJ, 0o0_u16, ACL_UNDEFINED_ID), + (ACL_MASK, 0o7_u16, ACL_UNDEFINED_ID), + (ACL_OTHER, 0o0_u16, ACL_UNDEFINED_ID), + ] { + acl.extend_from_slice(&tag.to_ne_bytes()); + acl.extend_from_slice(&permissions.to_ne_bytes()); + acl.extend_from_slice(&id.to_ne_bytes()); + } + let path = CString::new(root.as_os_str().as_encoded_bytes()).unwrap(); + let name = c"system.posix_acl_access"; + let result = unsafe { + libc::setxattr( + path.as_ptr(), + name.as_ptr(), + acl.as_ptr().cast(), + acl.len(), + 0, + ) }; - let restricted_group = Group::from_gid(Gid::from_raw(0)) - .unwrap() - .expect("gid 0 group entry"); - if restricted_group.gid == nix::unistd::getegid() { + assert_eq!( + result, + 0, + "setxattr failed: {}", + std::io::Error::last_os_error() + ); + + match unsafe { fork() }.expect("fork should succeed") { + ForkResult::Child => { + let credentials_dropped = unsafe { + libc::setgroups(0, std::ptr::null()) == 0 + && libc::setgid(TEST_GID) == 0 + && libc::setuid(TEST_UID) == 0 + }; + let valid = credentials_dropped + && validate_oci_workspace_as_effective_identity(&root).is_ok(); + unsafe { libc::_exit(i32::from(!valid)) }; + } + ForkResult::Parent { child } => { + assert_eq!( + waitpid(child, None).expect("waitpid should succeed"), + WaitStatus::Exited(child, 0), + "named ACL user should retain workspace authority" + ); + } + } + } + + #[cfg(target_os = "linux")] + #[test] + #[allow(unsafe_code)] + fn effective_identity_validation_honors_landlock_denial() { + let dir = tempfile::tempdir_in("/tmp").unwrap(); + let root = dir.path().canonicalize().unwrap().join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let mut policy = policy_with_process(ProcessPolicy::default()); + policy.filesystem = FilesystemPolicy { + read_only: vec![root.clone()], + read_write: Vec::new(), + include_workdir: false, + }; + policy.landlock = LandlockPolicy { + compatibility: openshell_core::policy::LandlockCompatibility::HardRequirement, + }; + let Ok(prepared) = sandbox::linux::prepare_current_user(&policy, None) else { return; + }; + + match unsafe { fork() }.expect("fork should succeed") { + ForkResult::Child => { + let denied = sandbox::linux::enforce(prepared).is_ok() + && validate_oci_workspace_as_effective_identity(&root).is_err(); + unsafe { libc::_exit(i32::from(!denied)) }; + } + ForkResult::Parent { child } => { + assert_eq!( + waitpid(child, None).expect("waitpid should succeed"), + WaitStatus::Exited(child, 0), + "kernel-effective validation should honor an enforced LSM denial" + ); + } } + } + #[cfg(unix)] + #[test] + fn validate_oci_workspace_rejects_restrictive_parent() { let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - let before = std::fs::metadata(&existing).unwrap(); + let parent = dir.path().canonicalize().unwrap().join("private"); + let root = parent.join("project"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); + let metadata = std::fs::symlink_metadata(&parent).unwrap(); + + let error = validate_oci_workspace( + &root, + Some(Uid::from_raw(metadata.uid().wrapping_add(1))), + Some(Gid::from_raw(metadata.gid().wrapping_add(1))), + &[], + ) + .unwrap_err(); + assert!(error.to_string().contains("not traversable")); + } - let policy = sandbox_policy_with_read_write( - existing.clone(), - Some(current_user.name), - Some(restricted_group.name), - ); + #[cfg(unix)] + #[test] + fn validate_oci_workspace_rejects_symlink_component() { + use std::os::unix::fs::symlink; - prepare_filesystem(&policy).expect("existing path should not be re-owned"); + let dir = tempfile::tempdir().unwrap(); + let base = dir.path().canonicalize().unwrap(); + let target = base.join("target"); + let link = base.join("link"); + std::fs::create_dir(&target).unwrap(); + symlink(&target, &link).unwrap(); - let after = std::fs::metadata(&existing).unwrap(); - assert_eq!(after.uid(), before.uid()); - assert_eq!(after.gid(), before.gid()); + let error = validate_oci_workspace( + &link, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &[], + ) + .unwrap_err(); + assert!(error.to_string().contains("symlink")); } #[cfg(unix)] #[test] - #[allow(clippy::similar_names)] - fn chown_sandbox_home_changes_ownership_recursively() { - use std::os::unix::fs::MetadataExt; - + fn prepare_oci_workspace_makes_existing_root_owner_writable() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); + let root = dir.path().canonicalize().unwrap().join("sandbox"); std::fs::create_dir(&root).unwrap(); - std::fs::write(root.join("file.txt"), "hello").unwrap(); - std::fs::create_dir(root.join("subdir")).unwrap(); - std::fs::write(root.join("subdir").join("nested.txt"), "world").unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap(); - let expected_uid = nix::unistd::geteuid(); - let expected_gid = nix::unistd::getegid(); - chown_sandbox_home(&root, Some(expected_uid), Some(expected_gid)).unwrap(); + prepare_oci_workspace_with(&root, None, None, &[], &|_, _, _| Ok(())) + .expect("read-only workspace root should be prepared"); - for path in &[ - root.clone(), - root.join("file.txt"), - root.join("subdir"), - root.join("subdir").join("nested.txt"), - ] { - let meta = std::fs::metadata(path).unwrap(); - assert_eq!(meta.uid(), expected_uid.as_raw()); - assert_eq!(meta.gid(), expected_gid.as_raw()); - } + let mode = std::fs::symlink_metadata(&root) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755); } #[cfg(unix)] #[test] - fn chown_sandbox_home_rejects_symlink_root() { + fn prepare_oci_workspace_rejects_symlink_root() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("real"); - let link = dir.path().join("link"); + let base = dir.path().canonicalize().unwrap(); + let target = base.join("real"); + let link = base.join("link"); std::fs::create_dir(&target).unwrap(); symlink(&target, &link).unwrap(); - let err = chown_sandbox_home( + let err = prepare_oci_workspace( &link, Some(nix::unistd::geteuid()), Some(nix::unistd::getegid()), + &[], ) .unwrap_err(); assert!( @@ -2355,86 +3517,211 @@ mod tests { #[cfg(unix)] #[test] - fn chown_sandbox_home_skips_symlink_children() { + fn prepare_oci_workspace_rejects_symlink_parent() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let target = dir.path().join("outside"); - std::fs::write(&target, "secret").unwrap(); - symlink(&target, root.join("link")).unwrap(); + let base = dir.path().canonicalize().unwrap(); + let target = base.join("real"); + let parent_link = base.join("parent-link"); + std::fs::create_dir(&target).unwrap(); + symlink(&target, &parent_link).unwrap(); - chown_sandbox_home( - &root, + let err = prepare_oci_workspace( + &parent_link.join("workspace"), Some(nix::unistd::geteuid()), Some(nix::unistd::getegid()), + &[], ) - .expect("symlink children should be skipped"); + .unwrap_err(); + assert!( + err.to_string().contains("symlink"), + "expected parent symlink rejection: {err}" + ); + assert!( + !target.join("workspace").exists(), + "workspace must not be created through a symlink parent" + ); } #[cfg(unix)] #[test] - fn chown_recursive_skips_erofs_subtree_but_continues_siblings() { - use std::sync::{Arc, Mutex}; + fn prepare_oci_workspace_rejects_parent_traversal() { + let err = prepare_oci_workspace( + Path::new("/tmp/workspace/../escape"), + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &[], + ) + .unwrap_err(); + assert!( + err.to_string().contains("must be normalized"), + "expected traversal rejection: {err}" + ); + } + #[cfg(unix)] + #[test] + fn prepare_oci_workspace_rejects_inaccessible_existing_parent() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); + let parent = dir.path().canonicalize().unwrap().join("workspace"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let metadata = std::fs::symlink_metadata(&parent).unwrap(); + let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); + let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); + let root = parent.join("project"); + + let error = prepare_oci_workspace_with( + &root, + Some(different_user), + Some(different_group), + &[], + &|_, _, _| Ok(()), + ) + .unwrap_err(); - let readonly_dir = root.join("ro-mount"); - std::fs::create_dir(&readonly_dir).unwrap(); - std::fs::write(readonly_dir.join("child-under-ro.txt"), "data").unwrap(); - std::fs::write(root.join("writable-sibling.txt"), "data").unwrap(); + assert!( + error.to_string().contains("is not traversable"), + "unexpected error: {error}" + ); + assert!( + !root.exists(), + "workspace must not be created below an inaccessible parent" + ); + } - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let readonly_dir_for_chown = readonly_dir.clone(); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - if path == readonly_dir_for_chown { - return Err(nix::errno::Errno::EROFS); - } - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; + #[cfg(unix)] + #[test] + fn prepare_oci_workspace_accepts_supplementary_group_parent() { + let dir = tempfile::tempdir_in("/tmp").unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o710)).unwrap(); + let parent = dir.path().canonicalize().unwrap().join("workspace"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o710)).unwrap(); + let metadata = std::fs::symlink_metadata(&parent).unwrap(); + let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); + let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); + let supplementary_group = Gid::from_raw(metadata.gid()); + let root = parent.join("project"); + + prepare_oci_workspace_with( + &root, + Some(different_user), + Some(different_group), + &[supplementary_group], + &|_, _, _| Ok(()), + ) + .expect("supplementary group execute permission should allow traversal"); - chown_children( + assert!(root.is_dir()); + } + + #[cfg(not(any( + target_os = "aix", + target_os = "haiku", + target_os = "illumos", + target_os = "ios", + target_os = "macos", + target_os = "redox", + target_os = "solaris" + )))] + #[test] + fn named_user_supplementary_groups_include_primary_group() { + let user = User::from_uid(nix::unistd::geteuid()) + .expect("resolve current UID") + .expect("current user exists"); + + let groups = named_user_supplementary_groups(&user.name, user.gid) + .expect("resolve named-user supplementary groups"); + + assert!(groups.contains(&user.gid)); + } + + #[cfg(unix)] + #[test] + fn prepare_oci_workspace_rejects_non_directory_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap().join("sandbox"); + std::fs::write(&root, "not a directory").unwrap(); + + let error = prepare_oci_workspace( &root, Some(nix::unistd::geteuid()), Some(nix::unistd::getegid()), - &fake_chown, + &[], ) - .expect("read-only subtree should be skipped"); - - let chowned = chowned.lock().unwrap(); - assert!( - !chowned.contains(&readonly_dir.join("child-under-ro.txt")), - "children under EROFS directory must not be traversed" - ); + .unwrap_err(); assert!( - chowned.contains(&root.join("writable-sibling.txt")), - "writable sibling should still be chowned" + error.to_string().contains("is not a directory"), + "unexpected error: {error}" ); } #[cfg(unix)] #[test] - fn chown_recursive_propagates_non_erofs_errors() { + fn prepare_oci_workspace_propagates_root_chown_error() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); + let root = dir.path().canonicalize().unwrap().join("sandbox"); std::fs::create_dir(&root).unwrap(); let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - Err(nix::errno::Errno::EPERM) + Err(nix::errno::Errno::EROFS) }; - let result = chown_recursive( + let error = prepare_oci_workspace_with( &root, Some(nix::unistd::geteuid()), Some(nix::unistd::getegid()), + &[], + &fake_chown, + ) + .unwrap_err(); + + assert!( + error.to_string().contains("Read-only file system"), + "unexpected error: {error}" + ); + } + + #[cfg(unix)] + #[test] + fn prepare_oci_workspace_creates_missing_root() { + use std::sync::{Arc, Mutex}; + + let dir = tempfile::tempdir().unwrap(); + let missing = dir + .path() + .canonicalize() + .unwrap() + .join("missing") + .join("sandbox"); + let chowned = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&chowned); + let fake_chown = + move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { + observed.lock().unwrap().push(path.to_path_buf()); + Ok(()) + }; + + prepare_oci_workspace_with( + &missing, + Some(nix::unistd::geteuid()), + Some(nix::unistd::getegid()), + &[], &fake_chown, + ) + .expect("missing OCI workspace should be created"); + + assert!(missing.is_dir()); + assert_eq!( + std::fs::symlink_metadata(missing.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 ); - assert!(result.is_err(), "non-EROFS errors should propagate"); + assert_eq!(*chowned.lock().unwrap(), vec![missing]); } #[cfg(unix)] @@ -2627,7 +3914,11 @@ mod tests { #[test] fn supervisor_identity_mount_target_rejects_unhideable_endpoints() { - assert!(supervisor_identity_mount_target("tcp:127.0.0.1:8081").is_err()); + assert_eq!( + supervisor_identity_mount_target("tcp:127.0.0.1:8081") + .expect("tcp endpoint should not require mount hiding"), + None + ); assert!(supervisor_identity_mount_target("spiffe-workload-api/spire-agent.sock").is_err()); assert!(supervisor_identity_mount_target("/spire-agent.sock").is_err()); } diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index a5ff0456c9..8c47e789ba 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -36,8 +36,21 @@ use openshell_core::denial::DenialEvent; use crate::managed_children; use crate::process::{ ProcessEnforcementMode, ProcessHandle, ProcessStatus, ResolvedProcessIdentity, + ResolvedWorkspace, }; +pub enum SidecarExitReport { + Exited { + instance_id: String, + exit_code: i32, + ack: tokio::sync::oneshot::Sender>, + }, + Finalized { + instance_id: String, + ack: tokio::sync::oneshot::Sender>, + }, +} + fn ocsf_ctx() -> &'static openshell_ocsf::SandboxContext { openshell_ocsf::ctx::ctx() } @@ -53,18 +66,21 @@ fn ocsf_ctx() -> &'static openshell_ocsf::SandboxContext { pub async fn run_process( program: &str, args: &[String], - workdir: Option<&str>, + workspace: ResolvedWorkspace, timeout_secs: u64, interactive: bool, + await_main_process_attachment: bool, sandbox_id: Option<&str>, openshell_endpoint: Option<&str>, ssh_socket_path: Option, shared_ssh_socket: bool, + ssh_exit_tx: Option>, policy: &SandboxPolicy, resolved_process_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, entrypoint_pid: Arc, - entrypoint_started_tx: Option>, + entrypoint_started_tx: Option>, + sidecar_exit_tx: Option>, provider_credentials: ProviderCredentialState, provider_env: std::collections::HashMap, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, @@ -95,7 +111,12 @@ pub async fn run_process( // is forked so the workload sees writable paths it owns. #[cfg(unix)] if enforcement_mode.uses_privileged_process_setup() { - crate::process::prepare_filesystem_with_identity(policy, resolved_process_identity)?; + crate::process::prepare_filesystem_with_identity( + policy, + resolved_process_identity, + workspace.root(), + workspace.home().is_some(), + )?; } // Eagerly fetch initial settings and install the agent skill if the @@ -104,10 +125,18 @@ pub async fn run_process( // the flag stays at its default (false) and no skill is installed. install_initial_agent_skill(sandbox_id, openshell_endpoint, &agent_proposals).await; + // Provider token grants may mount supervisor-only identity sockets such as + // the SPIFFE Workload API. Prepare the child mount namespace that hides + // those mounts before supervisor seccomp hardening removes the needed + // namespace syscalls. + #[cfg(target_os = "linux")] + crate::process::prepare_supervisor_identity_mount_namespace_from_env()?; + // Install the supervisor seccomp prelude before spawning any workload-side // tasks. By this point the orchestrator has finished privileged startup - // helpers (network namespace setup, nftables probes via run_networking), - // and the SSH listener and entrypoint child have not been exposed yet. + // helpers (network namespace setup, identity mount namespace setup, + // nftables probes via run_networking), and the SSH listener and entrypoint + // child have not been exposed yet. crate::sandbox::apply_supervisor_startup_hardening()?; // Spawn the bypass detection monitor. It tails dmesg for nftables LOG @@ -213,6 +242,37 @@ pub async fn run_process( #[cfg(not(target_os = "linux"))] let ssh_netns_fd: Option = None; + #[cfg(target_os = "linux")] + let mut handle = ProcessHandle::spawn( + program, + args, + &workspace, + interactive, + policy, + resolved_process_identity, + enforcement_mode, + netns, + ca_file_paths.as_ref(), + &provider_env, + )?; + + #[cfg(not(target_os = "linux"))] + let mut handle = ProcessHandle::spawn( + program, + args, + &workspace, + interactive, + policy, + resolved_process_identity, + enforcement_mode, + ca_file_paths.as_ref(), + &provider_env, + )?; + + let main_pid = handle.pid(); + let main_session = crate::main_session::MainSession::new(handle.take_io(), main_pid); + let main_instance_id = uuid::Uuid::new_v4().to_string(); + // SSH-spawned shells get http_proxy=http://: exported into // their env so cooperative tools (curl, npm, Node) route through the // CONNECT proxy. Linux uses the netns host_ip; on other targets fall back @@ -225,11 +285,12 @@ pub async fn run_process( let ssh_socket_path: Option = ssh_socket_path.map(std::path::PathBuf::from); if let Some(listen_path) = ssh_socket_path.clone() { let policy_clone = policy.clone(); - let workdir_clone = workdir.map(str::to_string); + let workspace_clone = workspace.clone(); let proxy_url = ssh_proxy_url; let netns_fd = ssh_netns_fd; let ca_paths = ca_file_paths.clone(); let provider_credentials_clone = provider_credentials.clone(); + let main_session_clone = Arc::clone(&main_session); let user_env_clone: std::collections::HashMap = std::env::var(openshell_core::sandbox_env::USER_ENVIRONMENT) .ok() @@ -239,11 +300,12 @@ pub async fn run_process( let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { + let _ssh_exit_guard = ssh_exit_tx; if let Err(err) = crate::ssh::run_ssh_server( listen_path, ssh_ready_tx, policy_clone, - workdir_clone, + workspace_clone, netns_fd, proxy_url, ca_paths, @@ -252,6 +314,7 @@ pub async fn run_process( resolved_process_identity, enforcement_mode, shared_ssh_socket, + main_session_clone, ) .await { @@ -266,9 +329,9 @@ pub async fn run_process( } }); - // Wait for the SSH server to bind its socket before spawning the - // entrypoint process. This prevents exec requests from racing against - // SSH server startup when Kubernetes marks the pod Ready. + // Wait for the SSH server to bind before advertising its relay. The + // main process is already supervised; MainSession retains any output + // produced while this endpoint is being prepared. match timeout(Duration::from_secs(10), ssh_ready_rx).await { Ok(Ok(Ok(()))) => { ocsf_emit!( @@ -297,55 +360,35 @@ pub async fn run_process( } let supervisor_terminating = Arc::new(AtomicBool::new(false)); + // A canonical process may have completed while the SSH socket was being + // prepared. Detect that exit before entering the main wait path. + let early_exit = handle.try_wait().into_diagnostic()?; // Spawn the persistent supervisor session if we have a gateway endpoint // and sandbox identity. The session provides relay channels for SSH // connect and ExecSandbox through the gateway. - if let (Some(endpoint), Some(id), Some(socket)) = + let supervisor_session_task = if let (Some(endpoint), Some(id), Some(socket)) = (openshell_endpoint, sandbox_id, ssh_socket_path.as_ref()) { - crate::supervisor_session::spawn( + let task = crate::supervisor_session::spawn( endpoint.to_string(), id.to_string(), socket.clone(), ssh_netns_fd, None, Arc::clone(&supervisor_terminating), + main_instance_id.clone(), ); info!("supervisor session task spawned"); - } - - #[cfg(target_os = "linux")] - let mut handle = ProcessHandle::spawn( - program, - args, - workdir, - interactive, - policy, - resolved_process_identity, - enforcement_mode, - netns, - ca_file_paths.as_ref(), - &provider_env, - )?; - - #[cfg(not(target_os = "linux"))] - let mut handle = ProcessHandle::spawn( - program, - args, - workdir, - interactive, - policy, - resolved_process_identity, - enforcement_mode, - ca_file_paths.as_ref(), - &provider_env, - )?; + Some(task) + } else { + None + }; // Store the entrypoint PID so the proxy can resolve TCP peer identity entrypoint_pid.store(handle.pid(), Ordering::Release); if let Some(tx) = entrypoint_started_tx { - let _ = tx.send(handle.pid()); + let _ = tx.send((handle.pid(), main_instance_id.clone())); } ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -360,12 +403,15 @@ pub async fn run_process( .build() ); - let outcome = + let outcome = if let Some(status) = early_exit { + ProcessWaitOutcome::Exited(status) + } else { wait_for_process_exit_or_shutdown(&mut handle, timeout_secs, &supervisor_terminating) - .await?; + .await? + }; - let status = match outcome { - ProcessWaitOutcome::Exited(status) => status, + let (rendered_code, drain_terminal) = match outcome { + ProcessWaitOutcome::Exited(status) => (status.code(), true), ProcessWaitOutcome::TimedOut => { ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -377,7 +423,7 @@ pub async fn run_process( .message("Process timed out, killing") .build() ); - return Ok(124); // Standard timeout exit code + (124, false) } ProcessWaitOutcome::ShutdownSignal { signal, status } => { info!( @@ -385,10 +431,15 @@ pub async fn run_process( exit_code = status.code(), "Entrypoint exited after supervisor shutdown signal" ); - status + (status.code(), false) } }; - supervisor_terminating.store(true, Ordering::Release); + let terminal_delivery_pending = main_session + .finish( + rendered_code, + drain_terminal && await_main_process_attachment, + ) + .await; ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -397,12 +448,129 @@ pub async fn run_process( .disposition(DispositionId::Allowed) .severity(SeverityId::Informational) .status(StatusId::Success) - .exit_code(status.code()) - .message(format!("Process exited with code {}", status.code())) + .exit_code(rendered_code) + .message(format!("Process exited with code {rendered_code}")) .build() ); - Ok(status.code()) + if outcome.should_report_main_process_exit() { + if let Some(tx) = sidecar_exit_tx.as_ref() { + report_sidecar_main_process_exit(tx, &main_instance_id, rendered_code).await?; + } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { + report_main_process_exit_until_ack(endpoint, id, &main_instance_id, rendered_code) + .await; + info!(instance_id = %main_instance_id, "main-process exit acknowledged"); + } + } else { + info!( + instance_id = %main_instance_id, + "skipping main-process exit report during supervisor shutdown" + ); + } + main_session.mark_terminal_reported(); + if outcome.should_report_main_process_exit() && drain_terminal && terminal_delivery_pending { + // The peer's SSH channel-close confirms that the terminal frames sent + // above traversed russh and the relay. Detached commands have no active + // attachment and never enter this wait. + main_session.wait_for_terminal_attachments().await; + } + if outcome.should_report_main_process_exit() { + if let Some(tx) = sidecar_exit_tx.as_ref() { + finalize_sidecar_main_process_exit(tx, &main_instance_id).await?; + } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { + finalize_main_process_exit_until_ack(endpoint, id, &main_instance_id).await; + info!(instance_id = %main_instance_id, "main-process terminal delivery finalized"); + } + } + + supervisor_terminating.store(true, Ordering::Release); + if let Some(task) = supervisor_session_task { + task.abort(); + } + + Ok(rendered_code) +} + +async fn report_main_process_exit_until_ack( + endpoint: &str, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, +) { + let mut retry_delay = Duration::from_millis(250); + loop { + match crate::supervisor_session::report_main_process_exit( + endpoint, + sandbox_id, + instance_id, + exit_code, + ) + .await + { + Ok(()) => return, + Err(error) => { + tracing::warn!(%error, "main-process exit report failed; retrying"); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(2)); + } + } + } +} + +async fn finalize_main_process_exit_until_ack(endpoint: &str, sandbox_id: &str, instance_id: &str) { + let mut retry_delay = Duration::from_millis(250); + loop { + match crate::supervisor_session::finalize_main_process_exit( + endpoint, + sandbox_id, + instance_id, + ) + .await + { + Ok(()) => return, + Err(error) => { + tracing::warn!(%error, "main-process terminal finalization failed; retrying"); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(2)); + } + } + } +} + +async fn report_sidecar_main_process_exit( + tx: &tokio::sync::mpsc::Sender, + instance_id: &str, + exit_code: i32, +) -> Result<()> { + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + tx.send(SidecarExitReport::Exited { + instance_id: instance_id.to_string(), + exit_code, + ack: ack_tx, + }) + .await + .map_err(|_| miette::miette!("sidecar exit reporter closed"))?; + ack_rx + .await + .map_err(|_| miette::miette!("sidecar exit reporter dropped acknowledgement"))? + .map_err(|error| miette::miette!(error)) +} + +async fn finalize_sidecar_main_process_exit( + tx: &tokio::sync::mpsc::Sender, + instance_id: &str, +) -> Result<()> { + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + tx.send(SidecarExitReport::Finalized { + instance_id: instance_id.to_string(), + ack: ack_tx, + }) + .await + .map_err(|_| miette::miette!("sidecar exit reporter closed"))?; + ack_rx + .await + .map_err(|_| miette::miette!("sidecar exit reporter dropped acknowledgement"))? + .map_err(|error| miette::miette!(error)) } enum ProcessWaitOutcome { @@ -414,6 +582,16 @@ enum ProcessWaitOutcome { }, } +impl ProcessWaitOutcome { + /// A gateway acknowledgement is required for ordinary canonical-process + /// completion, but cannot be awaited after the supervisor itself has been + /// asked to terminate. At that point the gateway may already be shutting + /// down and no longer able to acknowledge the report. + fn should_report_main_process_exit(&self) -> bool { + !matches!(self, Self::ShutdownSignal { .. }) + } +} + async fn wait_for_process_exit_or_shutdown( handle: &mut ProcessHandle, timeout_secs: u64, @@ -428,7 +606,6 @@ async fn wait_for_process_exit_or_shutdown( tokio::pin!(deadline); tokio::select! { result = &mut wait => { - terminating.store(true, Ordering::Release); Ok(ProcessWaitOutcome::Exited(result.into_diagnostic()?)) } () = &mut deadline => { @@ -446,7 +623,6 @@ async fn wait_for_process_exit_or_shutdown( } else { tokio::select! { result = &mut wait => { - terminating.store(true, Ordering::Release); Ok(ProcessWaitOutcome::Exited(result.into_diagnostic()?)) } signal = wait_for_supervisor_shutdown_signal() => { @@ -480,13 +656,13 @@ fn signal_entrypoint_for_shutdown(_pid: u32, _signal: &'static str) {} #[cfg(unix)] fn signal_pid(pid: u32, signal: nix::sys::signal::Signal, reason: &'static str) { let raw_pid = i32::try_from(pid).unwrap_or(i32::MAX); - if let Err(error) = nix::sys::signal::kill(nix::unistd::Pid::from_raw(raw_pid), signal) { + if let Err(error) = nix::sys::signal::kill(nix::unistd::Pid::from_raw(-raw_pid), signal) { tracing::warn!( pid, signal = ?signal, reason, error = %error, - "failed to signal entrypoint process" + "failed to signal entrypoint process group" ); } } @@ -633,4 +809,22 @@ mod tests { assert_eq!(ssh_proxy_url_for_policy(&policy, None), None); } + + #[cfg(unix)] + #[test] + fn supervisor_shutdown_exit_skips_gateway_acknowledgement() { + use std::os::unix::process::ExitStatusExt; + + let status = ProcessStatus::from(std::process::ExitStatus::from_raw(libc::SIGTERM)); + + assert!(ProcessWaitOutcome::Exited(status).should_report_main_process_exit()); + assert!(ProcessWaitOutcome::TimedOut.should_report_main_process_exit()); + assert!( + !ProcessWaitOutcome::ShutdownSignal { + signal: "SIGTERM", + status, + } + .should_report_main_process_exit() + ); + } } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index b1250f990f..fbd6d9275b 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -4,24 +4,30 @@ //! Embedded SSH server for sandbox access. use crate::child_env; +use crate::main_session::{MainOutput, MainSession}; #[cfg(target_os = "linux")] use crate::managed_children; use crate::process::{ - ProcessEnforcementMode, ResolvedProcessIdentity, drop_privileges_with_identity, - is_supervisor_only_env_var, + ProcessEnforcementMode, ResolvedProcessIdentity, ResolvedWorkspace, + drop_privileges_with_identity, is_supervisor_only_env_var, session_user_and_home, }; use crate::sandbox; +#[cfg(unix)] +use libc; use miette::{IntoDiagnostic, Result}; use nix::pty::{Winsize, openpty}; use nix::unistd::setsid; +use openshell_core::VERSION; +use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::policy::SandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, SeverityId, SshActivityBuilder, StatusId, ocsf_emit, }; -use russh::ChannelId; use russh::keys::{Algorithm, PrivateKey}; -use russh::server::{Auth, Handle, Session}; +use russh::server::{Auth, ChannelOpenHandle, Handle, Session}; +use russh::{ChannelId, ChannelOpenFailure, Sig}; +use std::borrow::Cow; use std::collections::HashMap; use std::io::{Read, Write}; use std::os::fd::{AsRawFd, RawFd}; @@ -32,6 +38,8 @@ use std::time::Duration; use tokio::net::UnixListener; use tracing::warn; +const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); + /// Perform SSH server initialization: generate a host key, build the config, /// and bind the Unix socket listener. Extracted so that startup errors can be /// forwarded through the readiness channel rather than being silently logged. @@ -50,7 +58,9 @@ fn ssh_server_init( let mut rng = rand::rng(); let host_key = PrivateKey::random(&mut rng, Algorithm::Ed25519).into_diagnostic()?; + // TODO: while building the SSH config, refactor the server_id to be "SSH-2.0-OpenShell_" from `openshell_core::VERSION` let mut config = russh::server::Config { + server_id: russh::SshId::Standard(Cow::Owned(format!("SSH-2.0-OpenShell_{VERSION}"))), auth_rejection_time: Duration::from_secs(1), ..Default::default() }; @@ -110,7 +120,7 @@ pub async fn run_ssh_server( listen_path: PathBuf, ready_tx: tokio::sync::oneshot::Sender>, policy: SandboxPolicy, - workdir: Option, + workspace: ResolvedWorkspace, netns_fd: Option, proxy_url: Option, ca_file_paths: Option<(PathBuf, PathBuf)>, @@ -119,6 +129,7 @@ pub async fn run_ssh_server( resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, shared_socket: bool, + main_session: Arc, ) -> Result<()> { let (listener, config, ca_paths) = match ssh_server_init( &listen_path, @@ -140,42 +151,186 @@ pub async fn run_ssh_server( } }; - loop { - let (stream, _peer) = listener.accept().await.into_diagnostic()?; - let config = config.clone(); - let policy = policy.clone(); - let workdir = workdir.clone(); - let proxy_url = proxy_url.clone(); - let ca_paths = ca_paths.clone(); - let provider_credentials = provider_credentials.clone(); - let user_environment = user_environment.clone(); + let mut consecutive_resource_errors: u32 = 0; + let mut consecutive_unknown_errors: u32 = 0; - tokio::spawn(async move { - if let Err(err) = handle_connection( - stream, - config, - policy, - workdir, - netns_fd, - proxy_url, - ca_paths, - provider_credentials, - user_environment, - resolved_identity, - enforcement_mode, - ) - .await - { - ocsf_emit!( - SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("SSH connection failed: {err}")) - .build() - ); + loop { + match listener.accept().await { + Ok((stream, _peer)) => { + consecutive_resource_errors = 0; + consecutive_unknown_errors = 0; + let config = config.clone(); + let policy = policy.clone(); + let workspace = workspace.clone(); + let proxy_url = proxy_url.clone(); + let ca_paths = ca_paths.clone(); + let provider_credentials = provider_credentials.clone(); + let user_environment = user_environment.clone(); + let main_session = Arc::clone(&main_session); + + tokio::spawn(async move { + if let Err(err) = handle_connection( + stream, + config, + policy, + workspace, + netns_fd, + proxy_url, + ca_paths, + provider_credentials, + user_environment, + resolved_identity, + enforcement_mode, + main_session, + ) + .await + { + ocsf_emit!( + SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(format!("SSH connection failed: {err}")) + .build() + ); + } + }); } - }); + Err(err) => { + match classify_ssh_accept_error( + &err, + &mut consecutive_resource_errors, + &mut consecutive_unknown_errors, + ) { + SshAcceptAction::Terminal => { + ocsf_emit!( + SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message(format!( + "SSH accept loop exiting on terminal error: {err}" + )) + .build() + ); + break; + } + SshAcceptAction::Retry { backoff, severity } => { + ocsf_emit!( + SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(severity) + .status(StatusId::Failure) + .message(format!( + "SSH accept error (retrying in {}ms): {err}", + backoff.as_millis(), + )) + .build() + ); + tokio::time::sleep(backoff).await; + } + } + } + } + } + + Ok(()) +} + +const MAX_CONSECUTIVE_UNKNOWN_SSH_ACCEPT_ERRORS: u32 = 10; + +#[derive(Debug, PartialEq)] +enum SshAcceptAction { + Terminal, + Retry { + backoff: Duration, + severity: SeverityId, + }, +} + +fn classify_ssh_accept_error( + err: &std::io::Error, + consecutive_resource_errors: &mut u32, + consecutive_unknown_errors: &mut u32, +) -> SshAcceptAction { + #[cfg(unix)] + if matches!( + err.raw_os_error(), + Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK) + ) { + return SshAcceptAction::Terminal; + } + + #[cfg(unix)] + if matches!( + err.raw_os_error(), + Some( + libc::EMFILE + | libc::ENFILE + | libc::ENOBUFS + | libc::ENOMEM + | libc::ECONNABORTED + | libc::ECONNRESET + | libc::EINTR + | libc::ENETDOWN + | libc::EPROTO + | libc::ENOPROTOOPT + | libc::EHOSTDOWN + | libc::EHOSTUNREACH + | libc::EOPNOTSUPP + | libc::ENETUNREACH + | libc::ENOSR + | libc::ESOCKTNOSUPPORT + | libc::EPROTONOSUPPORT + | libc::ETIMEDOUT + ) + ) { + *consecutive_unknown_errors = 0; + + #[cfg(unix)] + let is_resource_pressure = matches!( + err.raw_os_error(), + Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR) + ); + #[cfg(not(unix))] + let is_resource_pressure = false; + + if is_resource_pressure { + *consecutive_resource_errors = consecutive_resource_errors.saturating_add(1); + let backoff_ms = 100u64 + .saturating_mul(1u64 << (*consecutive_resource_errors).min(7).saturating_sub(1)) + .min(5_000); + return SshAcceptAction::Retry { + backoff: Duration::from_millis(backoff_ms), + severity: SeverityId::Medium, + }; + } + + *consecutive_resource_errors = 0; + return SshAcceptAction::Retry { + backoff: Duration::from_millis(100), + severity: SeverityId::Low, + }; + } + + #[cfg(unix)] + #[cfg(target_os = "linux")] + if matches!(err.raw_os_error(), Some(libc::ENONET)) { + *consecutive_unknown_errors = 0; + *consecutive_resource_errors = 0; + return SshAcceptAction::Retry { + backoff: Duration::from_millis(100), + severity: SeverityId::Low, + }; + } + + *consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1); + if *consecutive_unknown_errors >= MAX_CONSECUTIVE_UNKNOWN_SSH_ACCEPT_ERRORS { + return SshAcceptAction::Terminal; + } + SshAcceptAction::Retry { + backoff: Duration::from_millis(100), + severity: SeverityId::Low, } } @@ -184,7 +339,7 @@ async fn handle_connection( stream: tokio::net::UnixStream, config: Arc, policy: SandboxPolicy, - workdir: Option, + workspace: ResolvedWorkspace, netns_fd: Option, proxy_url: Option, ca_file_paths: Option>, @@ -192,6 +347,7 @@ async fn handle_connection( user_environment: HashMap, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, + main_session: Arc, ) -> Result<()> { // Access is gated by the Unix-socket filesystem permissions (root-only), // not by an application-level preface. The supervisor bridges the @@ -209,7 +365,7 @@ async fn handle_connection( let handler = SshHandler::new( policy, - workdir, + workspace, netns_fd, proxy_url, ca_file_paths, @@ -217,6 +373,7 @@ async fn handle_connection( user_environment, resolved_identity, enforcement_mode, + main_session, ); russh::server::run_stream(config, stream, handler) .await @@ -230,16 +387,75 @@ async fn handle_connection( /// sender. This allows `window_change_request` to resize the correct PTY when /// multiple channels are open simultaneously (e.g. parallel shells, shell + /// sftp, etc.). +// Several independent per-channel boolean flags (login-shell opt-out and the +// main-attachment state bits) legitimately live side by side here. +#[allow(clippy::struct_excessive_bools)] #[derive(Default)] struct ChannelState { - input_sender: Option>>, + input_sender: Option, pty_master: Option, pty_request: Option, + no_login_shell: bool, + main_input_owner: Option, + main_attached: bool, + main_read_only: bool, + main_detach_prefix_pending: bool, + main_output_task: Option, +} + +const MAIN_DETACH_PREFIX: u8 = 0x10; // Ctrl-P +const MAIN_DETACH_KEY: u8 = 0x11; // Ctrl-Q + +/// Remove the `OpenShell` detach sequence from canonical-main input. +/// +/// A trailing Ctrl-P remains pending across SSH data frames. If the following +/// byte is not Ctrl-Q, both bytes are forwarded unchanged. Bytes after a +/// completed detach sequence are discarded because the attachment is closing. +fn filter_main_detach_sequence(prefix_pending: &mut bool, data: &[u8]) -> (Vec, bool) { + let mut forward = Vec::with_capacity(data.len() + usize::from(*prefix_pending)); + + for &byte in data { + if *prefix_pending { + if byte == MAIN_DETACH_KEY { + *prefix_pending = false; + return (forward, true); + } + forward.push(MAIN_DETACH_PREFIX); + *prefix_pending = false; + } + + if byte == MAIN_DETACH_PREFIX { + *prefix_pending = true; + } else { + forward.push(byte); + } + } + + (forward, false) +} + +enum InputSender { + Process(mpsc::Sender>), + Main(tokio::sync::mpsc::Sender>), +} + +impl InputSender { + fn send(&self, data: Vec) -> Result<(), &'static str> { + match self { + Self::Process(sender) => sender.send(data).map_err(|_| "process stdin closed"), + Self::Main(sender) => sender.try_send(data).map_err(|error| match error { + tokio::sync::mpsc::error::TrySendError::Full(_) => "canonical stdin buffer is full", + tokio::sync::mpsc::error::TrySendError::Closed(_) => { + "canonical process stdin closed" + } + }), + } + } } struct SshHandler { policy: SandboxPolicy, - workdir: Option, + workspace: ResolvedWorkspace, netns_fd: Option, proxy_url: Option, ca_file_paths: Option>, @@ -247,14 +463,32 @@ struct SshHandler { user_environment: HashMap, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, + main_session: Arc, channels: HashMap, } +impl Drop for SshHandler { + fn drop(&mut self) { + for state in self.channels.values_mut() { + if state.main_attached { + self.main_session.end_terminal_attachment(); + state.main_attached = false; + } + if let Some(owner) = state.main_input_owner.take() { + self.main_session.release_input(owner); + } + if let Some(task) = state.main_output_task.take() { + task.abort(); + } + } + } +} + impl SshHandler { #[allow(clippy::too_many_arguments)] fn new( policy: SandboxPolicy, - workdir: Option, + workspace: ResolvedWorkspace, netns_fd: Option, proxy_url: Option, ca_file_paths: Option>, @@ -262,10 +496,11 @@ impl SshHandler { user_environment: HashMap, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, + main_session: Arc, ) -> Self { Self { policy, - workdir, + workspace, netns_fd, proxy_url, ca_file_paths, @@ -273,6 +508,7 @@ impl SshHandler { user_environment, resolved_identity, enforcement_mode, + main_session, channels: HashMap::new(), } } @@ -296,10 +532,12 @@ impl russh::server::Handler for SshHandler { async fn channel_open_session( &mut self, channel: russh::Channel, + reply: ChannelOpenHandle, _session: &mut Session, - ) -> Result { + ) -> Result<(), Self::Error> { self.channels.insert(channel.id(), ChannelState::default()); - Ok(true) + reply.accept().await; + Ok(()) } /// Clean up per-channel state when the channel is closed. @@ -312,7 +550,17 @@ impl russh::server::Handler for SshHandler { channel: ChannelId, _session: &mut Session, ) -> Result<(), Self::Error> { - self.channels.remove(&channel); + if let Some(state) = self.channels.remove(&channel) { + if state.main_attached { + self.main_session.end_terminal_attachment(); + } + if let Some(owner) = state.main_input_owner { + self.main_session.release_input(owner); + } + if let Some(task) = state.main_output_task { + task.abort(); + } + } Ok(()) } @@ -323,8 +571,15 @@ impl russh::server::Handler for SshHandler { port_to_connect: u32, _originator_address: &str, _originator_port: u32, + reply: ChannelOpenHandle, _session: &mut Session, - ) -> Result { + ) -> Result<(), Self::Error> { + if self.main_session.finished() { + reply + .reject(ChannelOpenFailure::AdministrativelyProhibited) + .await; + return Ok(()); + } // Validate port range before truncating u32 -> u16. The SSH protocol // uses u32 for ports, but valid TCP ports are 0-65535. Without this // check, port 65537 truncates to port 1 (privileged). @@ -338,7 +593,10 @@ impl russh::server::Handler for SshHandler { "direct-tcpip rejected: port {port_to_connect} exceeds valid TCP range for host {host_to_connect}" )) .build()); - return Ok(false); + reply + .reject(ChannelOpenFailure::AdministrativelyProhibited) + .await; + return Ok(()); } // Only allow forwarding to loopback destinations to prevent the @@ -353,7 +611,10 @@ impl russh::server::Handler for SshHandler { "direct-tcpip rejected: non-loopback destination {host_to_connect}:{port_to_connect}" )) .build()); - return Ok(false); + reply + .reject(ChannelOpenFailure::AdministrativelyProhibited) + .await; + return Ok(()); } let host = host_to_connect.to_string(); @@ -362,6 +623,10 @@ impl russh::server::Handler for SshHandler { let port = u16::try_from(port_to_connect).unwrap_or(u16::MAX); let netns_fd = self.netns_fd; + // Confirm the channel before spawning: the task below writes to it, and + // the peer must see the open-confirmation first. + reply.accept().await; + tokio::spawn(async move { let addr = format!("{host}:{port}"); let tcp = match connect_in_netns(&addr, netns_fd).await { @@ -386,7 +651,7 @@ impl russh::server::Handler for SshHandler { let _ = tokio::io::copy_bidirectional(&mut channel_stream, &mut tcp_stream).await; }); - Ok(true) + Ok(()) } async fn pty_request( @@ -428,7 +693,10 @@ impl russh::server::Handler for SshHandler { warn!("window_change_request on unknown channel {channel:?}"); return Ok(()); }; - if let Some(master) = state.pty_master.as_ref() { + if state.main_attached { + self.main_session + .resize(col_width, row_height, pixel_width, pixel_height); + } else if let Some(master) = state.pty_master.as_ref() { let winsize = Winsize { ws_row: to_u16(row_height.max(1)), ws_col: to_u16(col_width.max(1)), @@ -447,6 +715,10 @@ impl russh::server::Handler for SshHandler { channel: ChannelId, session: &mut Session, ) -> Result<(), Self::Error> { + if self.main_session.finished() { + session.channel_failure(channel)?; + return Ok(()); + } session.channel_success(channel)?; // Only allocate a PTY when the client explicitly requested one via // pty_request. VS Code Remote-SSH sends shell_request *without* a @@ -464,6 +736,10 @@ impl russh::server::Handler for SshHandler { data: &[u8], session: &mut Session, ) -> Result<(), Self::Error> { + if self.main_session.finished() { + session.channel_failure(channel)?; + return Ok(()); + } session.channel_success(channel)?; let command = String::from_utf8_lossy(data).trim().to_string(); if command.is_empty() { @@ -479,7 +755,92 @@ impl russh::server::Handler for SshHandler { name: &str, session: &mut Session, ) -> Result<(), Self::Error> { - if name == "sftp" { + if name == "openshell-main" { + if !self.channels.contains_key(&channel) { + return Err(anyhow::anyhow!( + "subsystem_request on unknown channel {channel:?}" + )); + } + if self.main_session.begin_terminal_attachment().is_err() { + session.channel_failure(channel)?; + return Ok(()); + } + let state = self + .channels + .get_mut(&channel) + .expect("main channel existence checked above"); + state.main_attached = true; + if let Some(pty) = state.pty_request.take() { + self.main_session.resize( + pty.col_width, + pty.row_height, + pty.pixel_width, + pty.pixel_height, + ); + } + let (input, input_warning) = if state.main_read_only { + (None, None) + } else { + match self.main_session.acquire_input() { + Ok((owner, input)) => { + state.main_input_owner = Some(owner); + (Some(InputSender::Main(input)), None) + } + Err(error) => { + warn!(%error, "main process input lease unavailable; attaching read-only"); + (None, Some(error)) + } + } + }; + state.main_detach_prefix_pending = false; + state.input_sender = input; + let mut output = self.main_session.subscribe(); + let terminal_delivery = Arc::clone(&self.main_session); + let handle = session.handle(); + session.channel_success(channel)?; + if let Some(error) = input_warning { + let _ = handle + .extended_data( + channel, + 1, + format!("openshell: {error}; attached read-only\n").into_bytes(), + ) + .await; + } + let output_task = tokio::spawn(async move { + loop { + match output.recv().await { + Ok(event) => { + if let MainOutput::Exit(code) = event { + terminal_delivery.wait_for_terminal_reported().await; + let _ = send_main_output(&handle, channel, MainOutput::Exit(code)) + .await; + break; + } + let _ = send_main_output(&handle, channel, event).await; + } + Err(error) => { + let _ = handle + .extended_data( + channel, + 1, + format!( + "openshell: attachment fell behind by {} output chunks; reconnect for buffered output\n", + error.skipped + ) + .into_bytes(), + ) + .await; + let _ = handle.close(channel).await; + break; + } + } + } + }); + if let Some(state) = self.channels.get_mut(&channel) { + state.main_output_task = Some(output_task.abort_handle()); + } + } else if name == "sftp" && !self.main_session.finished() { session.channel_success(channel)?; // sftp-server speaks the SFTP binary protocol over stdin/stdout, // which is exactly what spawn_pipe_exec wires up. This enables @@ -487,8 +848,9 @@ impl russh::server::Handler for SshHandler { // transfer files into and out of the sandbox. let input_sender = spawn_pipe_exec( &self.policy, - self.workdir.clone(), + &self.workspace, Some("/usr/lib/openssh/sftp-server".to_string()), + false, session.handle(), channel, self.netns_fd, @@ -502,7 +864,7 @@ impl russh::server::Handler for SshHandler { let state = self.channels.get_mut(&channel).ok_or_else(|| { anyhow::anyhow!("subsystem_request on unknown channel {channel:?}") })?; - state.input_sender = Some(input_sender); + state.input_sender = Some(InputSender::Process(input_sender)); } else { ocsf_emit!( SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) @@ -526,9 +888,23 @@ impl russh::server::Handler for SshHandler { session: &mut Session, ) -> Result<(), Self::Error> { // Accept the env request so the client knows we handled it, but we - // don't actually propagate the variables — the sandbox environment is - // controlled via policy. We must reply so VSCode doesn't stall. - let _ = (variable_name, variable_value); + // don't actually propagate arbitrary variables — the sandbox + // environment is controlled via policy. We must reply so VSCode + // doesn't stall. Two exceptions carry supervisor signals the SSH + // protocol has no native field for: + // - OPENSHELL_NO_LOGIN_SHELL: gateway login-shell opt-out. + // - OPENSHELL_MAIN_READ_ONLY: read-only main attachment. + if variable_name == NO_LOGIN_SHELL_ENV.0 + && let Some(state) = self.channels.get_mut(&channel) + { + state.no_login_shell = variable_value == NO_LOGIN_SHELL_ENV.1; + } + if variable_name == "OPENSHELL_MAIN_READ_ONLY" + && variable_value == "1" + && let Some(state) = self.channels.get_mut(&channel) + { + state.main_read_only = true; + } session.channel_success(channel)?; Ok(()) } @@ -537,14 +913,44 @@ impl russh::server::Handler for SshHandler { &mut self, channel: ChannelId, data: &[u8], - _session: &mut Session, + session: &mut Session, ) -> Result<(), Self::Error> { - let Some(state) = self.channels.get(&channel) else { + let Some(state) = self.channels.get_mut(&channel) else { warn!("data on unknown channel {channel:?}"); return Ok(()); }; - if let Some(sender) = state.input_sender.as_ref() { - let _ = sender.send(data.to_vec()); + + let main_attached = state.main_attached; + let (forward, detach) = if main_attached { + filter_main_detach_sequence(&mut state.main_detach_prefix_pending, data) + } else { + (data.to_vec(), false) + }; + let send_error = (!forward.is_empty()) + .then(|| state.input_sender.as_ref()?.send(forward).err()) + .flatten(); + + if let Some(error) = send_error { + let handle = session.handle(); + if main_attached { + self.close_main_attachment(channel, handle, Some(error)) + .await; + } else { + let _ = handle + .extended_data( + channel, + 1, + format!("openshell: {error}; closing attachment\n").into_bytes(), + ) + .await; + let _ = handle.close(channel).await; + } + return Ok(()); + } + if detach { + self.close_main_attachment(channel, session.handle(), None) + .await; + return Ok(()); } Ok(()) } @@ -559,15 +965,100 @@ impl russh::server::Handler for SshHandler { // is essential for commands like `cat | tar xf -` which need // stdin EOF to know the input stream is complete. if let Some(state) = self.channels.get_mut(&channel) { + if state.main_attached + && let Some(owner) = state.main_input_owner.take() + { + self.main_session.release_input(owner); + } state.input_sender.take(); + state.main_detach_prefix_pending = false; } else { warn!("channel_eof on unknown channel {channel:?}"); } Ok(()) } + + async fn signal( + &mut self, + channel: ChannelId, + signal: Sig, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if !self + .channels + .get(&channel) + .is_some_and(|state| state.main_attached) + { + return Ok(()); + } + let signal = match signal { + Sig::HUP => Some(nix::sys::signal::Signal::SIGHUP), + Sig::INT => Some(nix::sys::signal::Signal::SIGINT), + Sig::KILL => Some(nix::sys::signal::Signal::SIGKILL), + Sig::QUIT => Some(nix::sys::signal::Signal::SIGQUIT), + Sig::TERM => Some(nix::sys::signal::Signal::SIGTERM), + _ => None, + }; + if let Some(signal) = signal + && let Err(error) = self.main_session.signal_group(signal) + { + warn!(%error, ?signal, "failed to signal canonical main process group"); + } + Ok(()) + } +} + +async fn send_main_output(handle: &Handle, channel: ChannelId, event: MainOutput) -> bool { + match event { + MainOutput::Stdout(data) => handle.data(channel, data).await.is_ok(), + MainOutput::Stderr(data) => handle.extended_data(channel, 1, data).await.is_ok(), + MainOutput::Exit(code) => { + let eof_sent = handle.eof(channel).await.is_ok(); + let status_sent = handle + .exit_status_request(channel, code.max(0).unsigned_abs()) + .await + .is_ok(); + let close_sent = handle.close(channel).await.is_ok(); + eof_sent && status_sent && close_sent + } + } } impl SshHandler { + async fn close_main_attachment( + &mut self, + channel: ChannelId, + handle: Handle, + error: Option<&str>, + ) { + if let Some(state) = self.channels.get_mut(&channel) { + if state.main_attached { + self.main_session.end_terminal_attachment(); + state.main_attached = false; + } + if let Some(owner) = state.main_input_owner.take() { + self.main_session.release_input(owner); + } + state.input_sender.take(); + state.main_detach_prefix_pending = false; + if let Some(task) = state.main_output_task.take() { + task.abort(); + } + } + if let Some(error) = error { + let _ = handle + .extended_data( + channel, + 1, + format!("openshell: {error}; closing attachment\n").into_bytes(), + ) + .await; + } + let _ = handle.eof(channel).await; + let _ = handle.exit_status_request(channel, 0).await; + let _ = handle.close(channel).await; + } + fn start_shell( &mut self, channel: ChannelId, @@ -579,13 +1070,15 @@ impl SshHandler { .channels .get_mut(&channel) .ok_or_else(|| anyhow::anyhow!("start_shell on unknown channel {channel:?}"))?; + let no_login_shell = state.no_login_shell; if let Some(pty) = state.pty_request.take() { // PTY was requested — allocate a real PTY (interactive shell or // exec that explicitly asked for a terminal). let (pty_master, input_sender) = spawn_pty_shell( &self.policy, - self.workdir.clone(), + &self.workspace, command, + no_login_shell, &pty, handle, channel, @@ -598,15 +1091,16 @@ impl SshHandler { self.enforcement_mode, )?; state.pty_master = Some(pty_master); - state.input_sender = Some(input_sender); + state.input_sender = Some(InputSender::Process(input_sender)); } else { // No PTY requested — use plain pipes so stdout/stderr are // separate and output has clean LF line endings. This is the // path VSCode Remote-SSH exec commands take. let input_sender = spawn_pipe_exec( &self.policy, - self.workdir.clone(), + &self.workspace, command, + no_login_shell, handle, channel, self.netns_fd, @@ -617,7 +1111,7 @@ impl SshHandler { self.resolved_identity, self.enforcement_mode, )?; - state.input_sender = Some(input_sender); + state.input_sender = Some(InputSender::Process(input_sender)); } Ok(()) } @@ -663,13 +1157,17 @@ pub async fn connect_in_netns( .await .map_err(|_| std::io::Error::other("netns connect thread panicked"))??; std_stream.set_nonblocking(true)?; - return tokio::net::TcpStream::from_std(std_stream); + let stream = tokio::net::TcpStream::from_std(std_stream)?; + set_tcp_nodelay_best_effort(&stream); + return Ok(stream); } #[cfg(not(target_os = "linux"))] let _ = netns_fd; - tokio::net::TcpStream::connect(addr).await + let stream = tokio::net::TcpStream::connect(addr).await?; + set_tcp_nodelay_best_effort(&stream); + Ok(stream) } #[derive(Clone)] @@ -693,35 +1191,6 @@ impl Default for PtyRequest { } } -/// Derive the session USER and HOME from the policy's `run_as_user`. -/// -/// For name-based identities, looks up the home directory via `/etc/passwd` -/// (or defaults to `/home/{user}`). -/// -/// For numeric UIDs, there is no passwd entry — falls back to -/// `("{uid}", "/sandbox")` so the agent session still has a meaningful -/// USER identifier. -fn session_user_and_home(policy: &SandboxPolicy) -> (String, String) { - match policy.process.run_as_user.as_deref() { - Some(user) if !user.is_empty() => { - // Numeric UID — no passwd entry expected; use default HOME. - if user.parse::().is_ok() { - return (user.to_string(), "/sandbox".to_string()); - } - // Name-based identity — look up home from /etc/passwd. - let home = nix::unistd::User::from_name(user) - .ok() - .flatten() - .map_or_else( - || format!("/home/{user}"), - |u| u.dir.to_string_lossy().into_owned(), - ); - (user.to_string(), home) - } - _ => ("sandbox".to_string(), "/sandbox".to_string()), - } -} - #[allow(clippy::too_many_arguments)] fn apply_child_env( cmd: &mut Command, @@ -739,7 +1208,7 @@ fn apply_child_env( .env(openshell_core::sandbox_env::SANDBOX, "1") .env("HOME", session_home) .env("USER", session_user) - .env("SHELL", "/bin/bash") + .env("SHELL", openshell_core::shell::detect_login_shell()) .env("PATH", &path) .env("TERM", term); @@ -769,11 +1238,43 @@ fn apply_child_env( } } +const fn login_shell_flag(no_login_shell: bool) -> &'static str { + if no_login_shell { "-c" } else { "-lc" } +} + +/// Build the shell command for an SSH session using a shell that exists in the +/// sandbox image (minimal images such as Alpine ship only `/bin/sh`, not bash). +/// +/// `no_command_arg` is appended only when no explicit command is given: `-i` +/// for an interactive PTY session, or `None` for the non-PTY stdin path (a +/// bare shell already reads piped stdin line-by-line). With an explicit +/// command the login-shell flag is used per `no_login_shell`. +fn build_ssh_shell_command( + shell: &str, + command: Option, + no_login_shell: bool, + no_command_arg: Option<&str>, +) -> Command { + let mut cmd = Command::new(shell); + match command { + None => { + if let Some(arg) = no_command_arg { + cmd.arg(arg); + } + } + Some(command) => { + cmd.arg(login_shell_flag(no_login_shell)).arg(command); + } + } + cmd +} + #[allow(clippy::too_many_arguments)] fn spawn_pty_shell( policy: &SandboxPolicy, - workdir: Option, + workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, pty: &PtyRequest, handle: Handle, channel: ChannelId, @@ -802,18 +1303,11 @@ fn spawn_pty_shell( let mut reader = master.try_clone()?; let mut writer = master.try_clone()?; - let mut cmd = command.map_or_else( - || { - let mut c = Command::new("/bin/bash"); - c.arg("-i"); - c - }, - |command| { - let mut c = Command::new("/bin/bash"); - c.arg("-lc").arg(command); - c - }, - ); + // Resolve a shell present in the sandbox image; interactive PTY sessions + // pass `-i` when no command is given. Runs in the supervisor, so it + // inspects the sandbox filesystem. + let shell = openshell_core::shell::detect_login_shell(); + let mut cmd = build_ssh_shell_command(&shell, command, no_login_shell, Some("-i")); let term = if pty.term.is_empty() { "xterm-256color" @@ -823,7 +1317,7 @@ fn spawn_pty_shell( // Derive USER and HOME from the policy's run_as_user when available, // falling back to "sandbox" / "/sandbox" for backward compatibility. - let (session_user, session_home) = session_user_and_home(policy); + let (session_user, session_home) = session_user_and_home(policy, workspace.home()); apply_child_env( &mut cmd, &session_home, @@ -836,20 +1330,20 @@ fn spawn_pty_shell( ); cmd.stdin(stdin).stdout(stdout).stderr(stderr); - if let Some(dir) = workdir.as_deref() { + if let Some(dir) = workspace.root() { cmd.current_dir(dir); } // Probe Landlock availability from the parent process where tracing works. #[cfg(target_os = "linux")] if enforcement_mode.enforces_child_sandbox() { - sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref()); + sandbox::linux::log_sandbox_readiness(policy, workspace.root()); } // Phase 1: Prepare Landlock ruleset before the child applies it. #[cfg(target_os = "linux")] let prepared_sandbox = - crate::process::prepare_child_sandbox(policy, workdir.as_deref(), enforcement_mode) + crate::process::prepare_child_sandbox(policy, workspace.root(), enforcement_mode) .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; #[cfg(unix)] @@ -857,16 +1351,19 @@ fn spawn_pty_shell( unsafe_pty::install_pre_exec( &mut cmd, policy.clone(), - workdir.clone(), + workspace.owned_root(), slave_fd, netns_fd, resolved_identity, enforcement_mode, #[cfg(target_os = "linux")] prepared_sandbox, - )?; + ); } + #[cfg(target_os = "linux")] + let mut child = crate::process::spawn_std_command_with_supervisor_identity_namespace(cmd)?; + #[cfg(not(target_os = "linux"))] let mut child = cmd.spawn()?; #[cfg(target_os = "linux")] let child_pid = child.id(); @@ -945,8 +1442,9 @@ fn spawn_pty_shell( #[allow(clippy::too_many_arguments)] fn spawn_pipe_exec( policy: &SandboxPolicy, - workdir: Option, + workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, handle: Handle, channel: ChannelId, netns_fd: Option, @@ -957,27 +1455,17 @@ fn spawn_pipe_exec( resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, ) -> anyhow::Result>> { - let mut cmd = command.map_or_else( - || { - // No command — read from stdin. Do *not* pass `-i`; interactive - // mode reads .bashrc, writes prompts to stderr, and can introduce - // just enough latency for VS Code Remote-SSH's platform detection - // to time out and fall back to "windows". Plain `bash` with piped - // stdin already reads commands line-by-line (script mode), which is - // exactly what VS Code's local server expects. - Command::new("/bin/bash") - }, - |command| { - let mut c = Command::new("/bin/bash"); - // Use login shell (-l) so that .profile/.bashrc are sourced and - // tool-specific env vars (VIRTUAL_ENV, UV_PYTHON_INSTALL_DIR, etc.) - // are available without hardcoding them here. - c.arg("-lc").arg(command); - c - }, - ); - - let (session_user, session_home) = session_user_and_home(policy); + // Resolve a shell present in the sandbox image; minimal images (e.g. Alpine) + // don't ship bash, only `/bin/sh`. Runs in the supervisor, so it inspects + // the sandbox filesystem. No command → read from stdin with no `-i`: + // interactive mode reads .bashrc, writes prompts to stderr, and can add + // just enough latency for VS Code Remote-SSH's platform detection to time + // out and fall back to "windows". A plain shell with piped stdin already + // reads commands line-by-line (script mode), which is what VS Code expects. + let shell = openshell_core::shell::detect_login_shell(); + let mut cmd = build_ssh_shell_command(&shell, command, no_login_shell, None); + + let (session_user, session_home) = session_user_and_home(policy, workspace.home()); apply_child_env( &mut cmd, &session_home, @@ -992,20 +1480,20 @@ fn spawn_pipe_exec( .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if let Some(dir) = workdir.as_deref() { + if let Some(dir) = workspace.root() { cmd.current_dir(dir); } // Probe Landlock availability from the parent process where tracing works. #[cfg(target_os = "linux")] if enforcement_mode.enforces_child_sandbox() { - sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref()); + sandbox::linux::log_sandbox_readiness(policy, workspace.root()); } // Phase 1: Prepare Landlock ruleset before the child applies it. #[cfg(target_os = "linux")] let prepared_sandbox = - crate::process::prepare_child_sandbox(policy, workdir.as_deref(), enforcement_mode) + crate::process::prepare_child_sandbox(policy, workspace.root(), enforcement_mode) .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; #[cfg(unix)] @@ -1013,15 +1501,18 @@ fn spawn_pipe_exec( unsafe_pty::install_pre_exec_no_pty( &mut cmd, policy.clone(), - workdir.clone(), + workspace.owned_root(), netns_fd, resolved_identity, enforcement_mode, #[cfg(target_os = "linux")] prepared_sandbox, - )?; + ); } + #[cfg(target_os = "linux")] + let mut child = crate::process::spawn_std_command_with_supervisor_identity_namespace(cmd)?; + #[cfg(not(target_os = "linux"))] let mut child = cmd.spawn()?; #[cfg(target_os = "linux")] let child_pid = child.id(); @@ -1162,19 +1653,11 @@ mod unsafe_pty { resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, #[cfg(target_os = "linux")] prepared: Option, - ) -> anyhow::Result<()> { + ) { // Wrap in Option so we can .take() it out of the FnMut closure. // pre_exec is only called once (after fork, before exec). #[cfg(target_os = "linux")] let mut prepared = prepared; - #[cfg(target_os = "linux")] - let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() { - crate::process::supervisor_identity_mount_from_env().map_err(|err| { - anyhow::anyhow!("failed to prepare supervisor identity isolation: {err}") - })? - } else { - None - }; unsafe { cmd.pre_exec(move || { setsid().map_err(|err| std::io::Error::other(err.to_string()))?; @@ -1186,13 +1669,10 @@ mod unsafe_pty { resolved_identity, enforcement_mode, #[cfg(target_os = "linux")] - supervisor_identity_mount, - #[cfg(target_os = "linux")] prepared.take(), ) }); } - Ok(()) } /// Pre-exec hook for pipe-based (non-PTY) exec. @@ -1214,17 +1694,9 @@ mod unsafe_pty { resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, #[cfg(target_os = "linux")] prepared: Option, - ) -> anyhow::Result<()> { + ) { #[cfg(target_os = "linux")] let mut prepared = prepared; - #[cfg(target_os = "linux")] - let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() { - crate::process::supervisor_identity_mount_from_env().map_err(|err| { - anyhow::anyhow!("failed to prepare supervisor identity isolation: {err}") - })? - } else { - None - }; unsafe { cmd.pre_exec(move || { enter_netns_and_sandbox( @@ -1233,13 +1705,10 @@ mod unsafe_pty { resolved_identity, enforcement_mode, #[cfg(target_os = "linux")] - supervisor_identity_mount, - #[cfg(target_os = "linux")] prepared.take(), ) }); } - Ok(()) } fn enter_netns_and_sandbox( @@ -1247,9 +1716,6 @@ mod unsafe_pty { policy: &SandboxPolicy, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, - #[cfg(target_os = "linux")] supervisor_identity_mount: Option< - &crate::process::SupervisorIdentityMountNamespace, - >, #[cfg(target_os = "linux")] prepared: Option, ) -> std::io::Result<()> { // Enter network namespace before dropping privileges. @@ -1268,11 +1734,6 @@ mod unsafe_pty { #[cfg(not(target_os = "linux"))] let _ = netns_fd; - #[cfg(target_os = "linux")] - if let Some(mount) = supervisor_identity_mount { - mount.enter_for_child()?; - } - // Drop privileges. initgroups/setgid/setuid need /etc/group and // /etc/passwd which would be blocked if Landlock were already enforced. if enforcement_mode.uses_privileged_process_setup() { @@ -1346,8 +1807,54 @@ fn is_loopback_host(host: &str) -> bool { )] mod tests { use super::*; + use std::ffi::OsStr; use std::process::Stdio; + /// Regression test: SSH sessions run the shell they are given, never a + /// hardcoded bash, so sh-only images (e.g. Alpine) work. Covers both the + /// interactive PTY path (`-i` when no command) and the non-PTY path. + #[test] + fn build_ssh_shell_command_uses_given_shell() { + // PTY, no command → given shell + interactive flag. + let cmd = build_ssh_shell_command("/bin/sh", None, false, Some("-i")); + assert_eq!(cmd.get_program(), OsStr::new("/bin/sh")); + assert_eq!(cmd.get_args().collect::>(), vec![OsStr::new("-i")]); + + // Non-PTY, no command → bare shell, no args (reads piped stdin). + let cmd = build_ssh_shell_command("/bin/sh", None, false, None); + assert_eq!(cmd.get_program(), OsStr::new("/bin/sh")); + assert_eq!(cmd.get_args().count(), 0); + + // Explicit command → login-shell flag + command, still on the given shell. + let cmd = build_ssh_shell_command("/bin/sh", Some("echo hi".into()), false, Some("-i")); + assert_eq!(cmd.get_program(), OsStr::new("/bin/sh")); + assert_eq!( + cmd.get_args().collect::>(), + vec![OsStr::new("-lc"), OsStr::new("echo hi")] + ); + + // OPENSHELL_NO_LOGIN_SHELL → plain -c. + let cmd = build_ssh_shell_command("/bin/sh", Some("echo hi".into()), true, None); + assert_eq!( + cmd.get_args().collect::>(), + vec![OsStr::new("-c"), OsStr::new("echo hi")] + ); + } + + /// Regression test: the direct-tcpip connect path sets `TCP_NODELAY`. + #[tokio::test] + async fn connect_in_netns_sets_tcp_nodelay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let addr = listener.local_addr().expect("local addr"); + + let stream = connect_in_netns(&addr.to_string(), None) + .await + .expect("connect"); + assert!(stream.nodelay().expect("query TCP_NODELAY")); + } + #[cfg(unix)] fn file_mode(path: &Path) -> u32 { use std::os::unix::fs::PermissionsExt; @@ -1464,6 +1971,38 @@ mod tests { assert_eq!(output.stdout, b"hello"); } + /// Command execution selects a login shell by default and a non-login shell + /// under `--no-login-shell`, so user startup files are sourced only in the + /// default case. + #[cfg(unix)] + #[test] + fn login_shell_flag_controls_profile_sourcing() { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join(".bash_profile"), "echo LOGIN_MARKER\n").unwrap(); + + let run = |flag: &str| -> String { + let out = Command::new("bash") + .arg(flag) + .arg("true") + .env("HOME", home.path()) + .env_remove("BASH_ENV") // isolate: -c still reads BASH_ENV if set + .output() + .expect("spawn bash"); + String::from_utf8_lossy(&out.stdout).into_owned() + }; + + assert_eq!(login_shell_flag(true), "-c"); + assert_eq!(login_shell_flag(false), "-lc"); + assert!( + run("-lc").contains("LOGIN_MARKER"), + "login shell must source .bash_profile" + ); + assert!( + !run("-c").contains("LOGIN_MARKER"), + "non-login shell must not source it" + ); + } + /// Verify that the stdin writer delivers all buffered data before exiting /// when the sender is dropped. This ensures channel_eof doesn't cause /// data loss — only signals "no more data after this". @@ -1630,11 +2169,11 @@ mod tests { let (tx_b, rx_b) = mpsc::channel::>(); let mut state_a = ChannelState { - input_sender: Some(tx_a), + input_sender: Some(InputSender::Process(tx_a)), ..Default::default() }; let state_b = ChannelState { - input_sender: Some(tx_b), + input_sender: Some(InputSender::Process(tx_b)), ..Default::default() }; @@ -1673,6 +2212,56 @@ mod tests { assert_eq!(rx_b.recv().unwrap(), b"still-alive"); } + #[test] + fn main_detach_filter_forwards_ctrl_c_unchanged() { + let mut prefix_pending = false; + let (forward, detach) = + filter_main_detach_sequence(&mut prefix_pending, b"before\x03after"); + + assert_eq!(forward, b"before\x03after"); + assert!(!detach); + assert!(!prefix_pending); + } + + #[test] + fn main_detach_filter_removes_sequence_and_trailing_input() { + let mut prefix_pending = false; + let (forward, detach) = + filter_main_detach_sequence(&mut prefix_pending, b"before\x10\x11after"); + + assert_eq!(forward, b"before"); + assert!(detach); + assert!(!prefix_pending); + } + + #[test] + fn main_detach_filter_recognizes_sequence_across_frames() { + let mut prefix_pending = false; + let (forward, detach) = filter_main_detach_sequence(&mut prefix_pending, b"before\x10"); + assert_eq!(forward, b"before"); + assert!(!detach); + assert!(prefix_pending); + + let (forward, detach) = filter_main_detach_sequence(&mut prefix_pending, b"\x11"); + assert!(forward.is_empty()); + assert!(detach); + assert!(!prefix_pending); + } + + #[test] + fn main_detach_filter_forwards_unmatched_prefix() { + let mut prefix_pending = false; + let (forward, detach) = filter_main_detach_sequence(&mut prefix_pending, b"\x10"); + assert!(forward.is_empty()); + assert!(!detach); + assert!(prefix_pending); + + let (forward, detach) = filter_main_detach_sequence(&mut prefix_pending, b"x"); + assert_eq!(forward, b"\x10x"); + assert!(!detach); + assert!(!prefix_pending); + } + // ----------------------------------------------------------------------- // session_user_and_home tests (Phase 2: numeric UID support) // ----------------------------------------------------------------------- @@ -1692,12 +2281,33 @@ mod tests { run_as_group: None, }, }; - let (user, home) = session_user_and_home(&policy); + let (user, home) = session_user_and_home(&policy, None); assert_eq!(user, "1000"); // Numeric UID has no passwd entry — defaults to /sandbox. assert_eq!(home, "/sandbox"); } + #[test] + fn session_user_and_home_uses_driver_workspace_when_supplied() { + use openshell_core::policy::{ + FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, + }; + let policy = SandboxPolicy { + version: 1, + filesystem: FilesystemPolicy::default(), + network: NetworkPolicy::default(), + landlock: LandlockPolicy::default(), + process: ProcessPolicy { + run_as_user: Some("1234".into()), + run_as_group: Some("1235".into()), + }, + }; + + let (user, home) = session_user_and_home(&policy, Some("/workspace/project")); + assert_eq!(user, "1234"); + assert_eq!(home, "/workspace/project"); + } + #[test] fn session_user_and_home_returns_name_from_passwd() { use openshell_core::policy::{ @@ -1713,7 +2323,7 @@ mod tests { run_as_group: None, }, }; - let (user, home) = session_user_and_home(&policy); + let (user, home) = session_user_and_home(&policy, None); assert_eq!(user, "sandbox"); // Name-based — should resolve via passwd (or /home/{user}). assert!(!home.is_empty()); @@ -1734,7 +2344,7 @@ mod tests { run_as_group: None, }, }; - let (user, home) = session_user_and_home(&policy); + let (user, home) = session_user_and_home(&policy, None); assert_eq!(user, "sandbox"); assert_eq!(home, "/sandbox"); } @@ -1754,7 +2364,7 @@ mod tests { run_as_group: None, }, }; - let (user, home) = session_user_and_home(&policy); + let (user, home) = session_user_and_home(&policy, None); assert_eq!(user, "sandbox"); assert_eq!(home, "/sandbox"); } @@ -1774,7 +2384,7 @@ mod tests { run_as_group: None, }, }; - let (user, home) = session_user_and_home(&policy); + let (user, home) = session_user_and_home(&policy, None); assert_eq!(user, "1000660000"); assert_eq!(home, "/sandbox"); } @@ -1838,8 +2448,7 @@ mod tests { ) .expect("prepare should succeed in test environment"), ), - ) - .expect("install pre_exec should succeed"); + ); let output = cmd .spawn() @@ -1894,8 +2503,7 @@ mod tests { ProcessEnforcementMode::Full, #[cfg(target_os = "linux")] None, - ) - .expect("install pre_exec should succeed"); + ); let output = cmd .spawn() @@ -1908,4 +2516,299 @@ mod tests { "resolved-identity-ok" ); } + + // ----------------------------------------------------------------------- + // direct-tcpip authorization wiring (SEC-007) + // + // The `loopback_host_*` tests above cover the predicate in isolation. + // These drive the real `russh::server::Handler` over an in-memory duplex + // so the deny path itself is covered: channel-open authorization travels + // through a reply handle rather than the handler's return value, so a + // handler that never rejects anything still type-checks and still passes + // every predicate test. + // ----------------------------------------------------------------------- + + struct AcceptAnyServerKey; + + impl russh::client::Handler for AcceptAnyServerKey { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + } + + fn forwarding_test_policy() -> SandboxPolicy { + use openshell_core::policy::{ + FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, + }; + + SandboxPolicy { + version: 0, + filesystem: FilesystemPolicy::default(), + network: NetworkPolicy::default(), + landlock: LandlockPolicy::default(), + process: ProcessPolicy { + run_as_user: None, + run_as_group: None, + }, + } + } + + /// Serve `SshHandler` on one end of an in-memory duplex and return an + /// authenticated client handle for the other end. + /// + /// The handler gets `netns_fd: None` so `connect_in_netns` performs a plain + /// TCP connect, making the forwarding path reachable without a network + /// namespace. + async fn authenticated_test_client_with_main( + main_session: Arc, + ) -> russh::client::Handle { + // Scoped so the `!Send` ThreadRng is dropped before the first await. + let host_key = { + let mut rng = rand::rng(); + PrivateKey::random(&mut rng, Algorithm::Ed25519).expect("host key") + }; + let mut server_config = russh::server::Config { + auth_rejection_time: Duration::from_millis(1), + ..Default::default() + }; + server_config.keys.push(host_key); + + let handler = SshHandler::new( + forwarding_test_policy(), + ResolvedWorkspace::default(), + None, + None, + None, + ProviderCredentialState::from_child_env_snapshot(0, HashMap::new()), + HashMap::new(), + ResolvedProcessIdentity::default(), + ProcessEnforcementMode::NetworkOnly, + main_session, + ); + + let (server_stream, client_stream) = tokio::io::duplex(64 * 1024); + tokio::spawn(async move { + if let Ok(session) = + russh::server::run_stream(Arc::new(server_config), server_stream, handler).await + { + let _ = session.await; + } + }); + + let mut client = russh::client::connect_stream( + Arc::new(russh::client::Config::default()), + client_stream, + AcceptAnyServerKey, + ) + .await + .expect("SSH handshake should complete over the duplex"); + + let auth = client + .authenticate_none("sandbox") + .await + .expect("auth_none should not error"); + assert!( + matches!(auth, russh::client::AuthResult::Success), + "sandbox SSH server accepts the none auth method" + ); + + client + } + + async fn authenticated_test_client() -> russh::client::Handle { + authenticated_test_client_with_main(MainSession::inert()).await + } + + #[tokio::test] + async fn abrupt_transport_drop_releases_main_input_lease() { + let main_session = MainSession::inert(); + let client = authenticated_test_client_with_main(Arc::clone(&main_session)).await; + let channel = client.channel_open_session().await.expect("open session"); + channel + .request_subsystem(true, "openshell-main") + .await + .expect("attach main subsystem"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match main_session.acquire_input() { + Err(_) => break, + Ok((owner, _)) => main_session.release_input(owner), + } + tokio::task::yield_now().await; + } + }) + .await + .expect("main subsystem should acquire canonical input lease"); + + drop(channel); + drop(client); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if main_session.acquire_input().is_ok() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("handler drop should release canonical input lease"); + } + + #[tokio::test] + async fn main_attachment_closes_naturally_after_terminal_delivery() { + let main_session = MainSession::inert(); + let client = authenticated_test_client_with_main(Arc::clone(&main_session)).await; + let mut channel = client.channel_open_session().await.expect("open session"); + channel + .request_subsystem(true, "openshell-main") + .await + .expect("attach main subsystem"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match main_session.acquire_input() { + Err(_) => break, + Ok((owner, _)) => main_session.release_input(owner), + } + tokio::task::yield_now().await; + } + }) + .await + .expect("main subsystem should register its attachment"); + + assert!(main_session.finish(7, false).await); + main_session.mark_terminal_reported(); + + let exit_status = tokio::time::timeout(Duration::from_secs(1), async { + let mut exit_status = None; + loop { + match channel.wait().await { + Some(russh::ChannelMsg::ExitStatus { + exit_status: status, + }) => { + exit_status = Some(status); + } + Some(russh::ChannelMsg::Close) => break exit_status, + None => panic!("main channel ended without a close message"), + Some(_) => {} + } + } + }) + .await + .expect("main channel should deliver its exit status"); + assert_eq!(exit_status, Some(7)); + drop(channel); + drop(client); + + tokio::time::timeout( + Duration::from_secs(1), + main_session.wait_for_terminal_attachments(), + ) + .await + .expect("peer channel close should release terminal delivery"); + } + + #[tokio::test] + async fn main_subsystem_applies_initial_pty_dimensions() { + let (main_session, _slave) = MainSession::terminal_for_test(); + let client = authenticated_test_client_with_main(Arc::clone(&main_session)).await; + let channel = client.channel_open_session().await.expect("open session"); + channel + .request_pty(true, "xterm-256color", 200, 60, 1600, 900, &[]) + .await + .expect("request PTY"); + channel + .request_subsystem(true, "openshell-main") + .await + .expect("attach main subsystem"); + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if main_session.terminal_size_for_test() == (200, 60) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("main subsystem should apply the initial PTY dimensions"); + } + + #[tokio::test] + async fn direct_tcpip_rejects_non_loopback_destination() { + let client = authenticated_test_client().await; + + let err = client + .channel_open_direct_tcpip("10.0.0.1", 80, "127.0.0.1", 0) + .await + .expect_err("forwarding to a non-loopback host must be refused"); + + assert!( + matches!( + err, + russh::Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited) + ), + "expected AdministrativelyProhibited, got {err:?}" + ); + } + + #[tokio::test] + async fn direct_tcpip_rejects_port_above_tcp_range() { + let client = authenticated_test_client().await; + + // 65_537 truncates to port 1 when cast to u16, so the guard has to + // reject it before the cast rather than forward to a privileged port. + let err = client + .channel_open_direct_tcpip("127.0.0.1", 65_537, "127.0.0.1", 0) + .await + .expect_err("a port outside the TCP range must be refused"); + + assert!( + matches!( + err, + russh::Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited) + ), + "expected AdministrativelyProhibited, got {err:?}" + ); + } + + #[tokio::test] + async fn direct_tcpip_forwards_to_loopback_listener() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback echo listener"); + let port = listener.local_addr().expect("listener address").port(); + tokio::spawn(async move { + if let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0u8; 64]; + if let Ok(n) = socket.read(&mut buf).await + && n > 0 + { + let _ = socket.write_all(&buf[..n]).await; + } + } + }); + + let client = authenticated_test_client().await; + let channel = client + .channel_open_direct_tcpip("127.0.0.1", u32::from(port), "127.0.0.1", 0) + .await + .expect("forwarding to a loopback listener must be allowed"); + + let mut stream = channel.into_stream(); + stream.write_all(b"ping").await.expect("write to channel"); + + let mut echoed = [0u8; 4]; + tokio::time::timeout(Duration::from_secs(10), stream.read_exact(&mut echoed)) + .await + .expect("relayed response should arrive before the timeout") + .expect("read from channel"); + assert_eq!(&echoed, b"ping", "bytes round-trip through the tunnel"); + } } diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 63fcad4c7a..98a3c0497b 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -19,9 +19,9 @@ use std::time::Duration; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::{ - GatewayMessage, RelayFrame, RelayInit, RelayOpen, RelayOpenResult, SupervisorHeartbeat, - SupervisorHello, SupervisorMessage, TcpRelayTarget, gateway_message, relay_open, - supervisor_message, + FinalizeMainProcessExitRequest, GatewayMessage, RelayFrame, RelayInit, RelayOpen, + RelayOpenResult, ReportMainProcessExitRequest, SupervisorHeartbeat, SupervisorHello, + SupervisorMessage, TcpRelayTarget, gateway_message, relay_open, supervisor_message, }; use openshell_ocsf::{ ActivityId, ConnectionInfo, Endpoint, NetworkActivityBuilder, OcsfEvent, SandboxContext, @@ -33,6 +33,7 @@ use tokio_stream::StreamExt; use tracing::{debug, warn}; use openshell_core::grpc_client; +use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::transport_errors::is_expected_transport_close_status; const INITIAL_BACKOFF: Duration = Duration::from_secs(1); @@ -280,51 +281,51 @@ pub fn spawn( netns_fd: Option, expected_ssh_peer_pid: Option, terminating: Arc, + instance_id: String, ) -> tokio::task::JoinHandle<()> { - tokio::spawn(run_session_loop( + let config = SessionConfig { endpoint, sandbox_id, ssh_socket_path, netns_fd, expected_ssh_peer_pid, terminating, - )) + instance_id, + }; + tokio::spawn(run_session_loop(config)) } -async fn run_session_loop( +struct SessionConfig { endpoint: String, sandbox_id: String, ssh_socket_path: std::path::PathBuf, netns_fd: Option, expected_ssh_peer_pid: Option, terminating: Arc, -) { + instance_id: String, +} + +async fn run_session_loop(config: SessionConfig) { let mut backoff = INITIAL_BACKOFF; let mut attempt: u64 = 0; loop { attempt += 1; - match run_single_session( - &endpoint, - &sandbox_id, - &ssh_socket_path, - netns_fd, - expected_ssh_peer_pid, - Arc::clone(&terminating), - ) - .await - { + match run_single_session(&config).await { Ok(()) => { - let event = - session_closed_event(openshell_ocsf::ctx::ctx(), &endpoint, &sandbox_id); + let event = session_closed_event( + openshell_ocsf::ctx::ctx(), + &config.endpoint, + &config.sandbox_id, + ); ocsf_emit!(event); break; } Err(e) => { let event = session_failed_event( openshell_ocsf::ctx::ctx(), - &endpoint, + &config.endpoint, attempt, &e.to_string(), ); @@ -337,18 +338,13 @@ async fn run_session_loop( } async fn run_single_session( - endpoint: &str, - sandbox_id: &str, - ssh_socket_path: &std::path::Path, - netns_fd: Option, - expected_ssh_peer_pid: Option, - terminating: Arc, + config: &SessionConfig, ) -> Result<(), Box> { // Connect to the gateway. The same `Channel` is used for both the // long-lived control stream and all data-plane `RelayStream` calls, so // every relay rides the same TCP+TLS+HTTP/2 connection — no new TLS // handshake per relay. - let channel = grpc_client::connect_channel_pub(endpoint) + let channel = grpc_client::connect_channel_pub(&config.endpoint) .await .map_err(|e| format!("connect failed: {e}"))?; let mut client = OpenShellClient::new(channel.clone()); @@ -358,11 +354,10 @@ async fn run_single_session( let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); // Send hello as the first message. - let instance_id = uuid::Uuid::new_v4().to_string(); tx.send(SupervisorMessage { payload: Some(supervisor_message::Payload::Hello(SupervisorHello { - sandbox_id: sandbox_id.to_string(), - instance_id: instance_id.clone(), + sandbox_id: config.sandbox_id.clone(), + instance_id: config.instance_id.clone(), })), }) .await @@ -392,12 +387,11 @@ async fn run_single_session( let heartbeat_secs = accepted.heartbeat_interval_secs.max(5); let event = session_established_event( openshell_ocsf::ctx::ctx(), - endpoint, + &config.endpoint, &accepted.session_id, heartbeat_secs, ); ocsf_emit!(event); - // Main loop: receive gateway messages + send heartbeats. let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(u64::from(heartbeat_secs))); @@ -409,19 +403,19 @@ async fn run_single_session( let msg = match map_session_stream_message( msg, "gateway closed stream", - &terminating, + &config.terminating, )? { SessionStreamMessage::Message(msg) => msg, SessionStreamMessage::ExpectedShutdownClose => return Ok(()), }; let context = GatewayMessageContext { - sandbox_id, - ssh_socket_path, - netns_fd, - expected_ssh_peer_pid, + sandbox_id: &config.sandbox_id, + ssh_socket_path: &config.ssh_socket_path, + netns_fd: config.netns_fd, + expected_ssh_peer_pid: config.expected_ssh_peer_pid, channel: &channel, tx: &tx, - terminating: &terminating, + terminating: &config.terminating, }; handle_gateway_message( &msg, @@ -442,6 +436,46 @@ async fn run_single_session( } } +/// Report the canonical process result and wait for durable handling. +pub async fn report_main_process_exit( + endpoint: &str, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, +) -> Result<(), Box> { + let channel = grpc_client::connect_channel_pub(endpoint) + .await + .map_err(|error| format!("connect failed: {error}"))?; + let mut client = OpenShellClient::new(channel); + client + .report_main_process_exit(ReportMainProcessExitRequest { + sandbox_id: sandbox_id.to_string(), + instance_id: instance_id.to_string(), + exit_code, + }) + .await?; + Ok(()) +} + +/// Confirm terminal delivery and permit ephemeral cleanup. +pub async fn finalize_main_process_exit( + endpoint: &str, + sandbox_id: &str, + instance_id: &str, +) -> Result<(), Box> { + let channel = grpc_client::connect_channel_pub(endpoint) + .await + .map_err(|error| format!("connect failed: {error}"))?; + let mut client = OpenShellClient::new(channel); + client + .finalize_main_process_exit(FinalizeMainProcessExitRequest { + sandbox_id: sandbox_id.to_string(), + instance_id: instance_id.to_string(), + }) + .await?; + Ok(()) +} + struct GatewayMessageContext<'a> { sandbox_id: &'a str, ssh_socket_path: &'a std::path::Path, @@ -745,10 +779,14 @@ async fn connect_tcp_target( .await .map_err(|_| "netns tcp connect thread panicked")??; stream.set_nonblocking(true)?; - return Ok(tokio::net::TcpStream::from_std(stream)?); + let stream = tokio::net::TcpStream::from_std(stream)?; + set_tcp_nodelay_best_effort(&stream); + return Ok(stream); } - Ok(tokio::net::TcpStream::connect((host.as_str(), port)).await?) + let stream = tokio::net::TcpStream::connect((host.as_str(), port)).await?; + set_tcp_nodelay_best_effort(&stream); + Ok(stream) } #[cfg(not(target_os = "linux"))] @@ -757,7 +795,9 @@ async fn connect_tcp_target( port: u16, _netns_fd: Option, ) -> Result> { - Ok(tokio::net::TcpStream::connect((host.as_str(), port)).await?) + let stream = tokio::net::TcpStream::connect((host.as_str(), port)).await?; + set_tcp_nodelay_best_effort(&stream); + Ok(stream) } #[cfg(test)] @@ -799,6 +839,20 @@ mod target_tests { } } + /// Regression test: the TCP relay connect path sets `TCP_NODELAY`. + #[tokio::test] + async fn connect_tcp_target_sets_tcp_nodelay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let addr = listener.local_addr().expect("local addr"); + + let stream = connect_tcp_target(addr.ip().to_string(), addr.port(), None) + .await + .expect("connect"); + assert!(stream.nodelay().expect("query TCP_NODELAY")); + } + #[test] fn tcp_target_allows_loopback_hosts() { validate_tcp_target(&tcp("127.0.0.1", 8080)).expect("ipv4 loopback"); diff --git a/crates/openshell-tui/BUILD.bazel b/crates/openshell-tui/BUILD.bazel deleted file mode 100644 index 09b422ecae..0000000000 --- a/crates/openshell-tui/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-tui", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - visibility = ["//visibility:public"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-tui_test", - crate = ":openshell-tui", - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-tui", - ":openshell-tui_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 1619dab9fa..cf7464fcd8 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -11,6 +11,9 @@ use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::setting_value; use openshell_core::settings::{self, SettingValueKind}; +use openshell_providers::{ + DiscoveredProvider, ProviderTypeProfile, RealDiscoveryContext, discover_from_profile, +}; use tonic::service::interceptor::InterceptedService; use tonic::transport::Channel; @@ -350,9 +353,9 @@ pub enum ProviderKeyField { Name, /// Focused credential row for known types (index via `cred_cursor`). Credential, - /// Custom env var name (generic / no-known-env-vars types only). + /// Custom env var name (legacy no-known-env-vars types only). EnvVarName, - /// Custom env var value (generic / no-known-env-vars types only). + /// Custom env var value (legacy no-known-env-vars types only). GenericValue, /// Browsing/deleting existing config entries (Up/Down/Ctrl+D). ConfigList, @@ -385,14 +388,16 @@ pub struct CreateProviderForm { pub config_key_input: String, /// Config value being entered. pub config_value_input: String, - /// For generic / types with no known env vars: custom env var name. + /// For legacy types with no known env vars: custom env var name. pub generic_env_name: String, - /// For generic / types with no known env vars: custom value. + /// For legacy types with no known env vars: custom value. pub generic_value: String, /// Which field is focused in the key entry form. pub key_field: ProviderKeyField, - /// True when the provider type has no known env vars (generic, outlook). + /// True when the selected profile has no accepted stored credential keys. pub is_generic: bool, + /// True when the selected profile permits creation without stored credentials. + pub allows_empty_credentials: bool, /// Status message (errors, validation). pub status: Option, /// Warning shown at top of `EnterKey` modal (e.g. autodetect failure). @@ -405,6 +410,12 @@ pub struct CreateProviderForm { pub discovered_credentials: Option>, } +fn apply_discovered_provider(form: &mut CreateProviderForm, discovered: DiscoveredProvider) { + form.discovered_credentials = Some(discovered.credentials); + form.config = discovered.config.into_iter().collect(); + form.config_cursor = 0; +} + // --------------------------------------------------------------------------- // Provider detail view (Get) // --------------------------------------------------------------------------- @@ -590,7 +601,7 @@ pub struct App { pub pending_workspace_refresh: bool, // Provider list - pub providers_v2_enabled: bool, + pub provider_profiles: Vec, pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, @@ -711,6 +722,13 @@ pub struct App { pub draft_viewport_height: usize, /// When true, the detail popup is shown for the selected draft chunk. pub draft_detail_open: bool, + /// Scroll offset, in rendered rows, of the draft detail popup body. + pub draft_detail_scroll: usize, + /// Total rows of detail-popup content (set by the draw pass). + pub draft_detail_rows: usize, + /// Visible rows in the detail-popup body, excluding the pinned hint row + /// (set by the draw pass). + pub draft_detail_body_height: usize, /// Per-sandbox count of pending draft recommendations (parallel to `sandbox_names`). pub sandbox_draft_counts: Vec, @@ -963,7 +981,7 @@ impl App { all_workspaces: false, workspace_names: Vec::new(), pending_workspace_refresh: false, - providers_v2_enabled: false, + provider_profiles: Vec::new(), provider_entries: Vec::new(), provider_names: Vec::new(), provider_types: Vec::new(), @@ -1032,6 +1050,9 @@ impl App { draft_scroll: 0, draft_viewport_height: 0, draft_detail_open: false, + draft_detail_scroll: 0, + draft_detail_rows: 0, + draft_detail_body_height: 0, sandbox_draft_counts: Vec::new(), pending_draft_approve: false, pending_draft_reject: false, @@ -1386,6 +1407,9 @@ impl App { self.input_mode = InputMode::Command; self.command_input.clear(); } + KeyCode::Char('w') => { + self.cycle_workspace(); + } KeyCode::Char('j') | KeyCode::Down => { if self.provider_count > 0 && self.provider_selected < self.provider_count - 1 { self.provider_selected += 1; @@ -1400,7 +1424,7 @@ impl App { self.overflow_focus_up(); } } - KeyCode::Char('c') if !self.providers_v2_enabled => { + KeyCode::Char('c') => { if self.all_workspaces { self.status_text = "Switch to a specific workspace to create providers.".to_string(); @@ -1413,10 +1437,10 @@ impl App { self.pending_provider_get = true; } // Open update form for the selected provider. - KeyCode::Char('u') if self.provider_count > 0 && !self.providers_v2_enabled => { + KeyCode::Char('u') if self.provider_count > 0 => { self.open_update_provider_form(); } - KeyCode::Char('d') if self.provider_count > 0 && !self.providers_v2_enabled => { + KeyCode::Char('d') if self.provider_count > 0 => { self.confirm_provider_delete = true; } KeyCode::Char('h' | 'l') | KeyCode::Left | KeyCode::Right => { @@ -1648,6 +1672,7 @@ impl App { KeyCode::Esc => { self.cancel_log_stream(); self.draft_detail_open = false; + self.draft_detail_scroll = 0; self.sandbox_policy_tab = SandboxPolicyTab::Policy; self.screen = Screen::Dashboard; self.focus = Focus::Sandboxes; @@ -1856,6 +1881,25 @@ impl App { } } + /// Largest useful scroll offset for the draft detail popup. + fn draft_detail_max_scroll(&self) -> usize { + self.draft_detail_rows + .saturating_sub(self.draft_detail_body_height) + } + + /// One screenful of the draft detail popup body. + fn draft_detail_page(&self) -> isize { + isize::try_from(self.draft_detail_body_height.max(1)).unwrap_or(1) + } + + /// Move the draft detail popup by `delta` rows, clamped to the content. + fn scroll_draft_detail(&mut self, delta: isize) { + let max = isize::try_from(self.draft_detail_max_scroll()).unwrap_or(isize::MAX); + let current = isize::try_from(self.draft_detail_scroll).unwrap_or(0); + let next = current.saturating_add(delta).clamp(0, max); + self.draft_detail_scroll = usize::try_from(next).unwrap_or(0); + } + fn handle_draft_key(&mut self, key: KeyEvent) { // Approve-all confirmation modal intercepts all keys when open. if self.approve_all_confirm_open { @@ -1880,6 +1924,7 @@ impl App { match key.code { KeyCode::Esc | KeyCode::Enter => { self.draft_detail_open = false; + self.draft_detail_scroll = 0; } // Allow approve/reject toggle from within the popup. KeyCode::Char('a') => { @@ -1893,6 +1938,7 @@ impl App { if st == "pending" || st == "rejected" { self.pending_draft_approve = true; self.draft_detail_open = false; + self.draft_detail_scroll = 0; } } } @@ -1908,10 +1954,27 @@ impl App { if st == "pending" || st == "approved" { self.pending_draft_reject = true; self.draft_detail_open = false; + self.draft_detail_scroll = 0; } } } } + // Scroll the detail body; long rejection guidance and rationales + // can exceed the fixed popup height. + KeyCode::Down | KeyCode::Char('j') => self.scroll_draft_detail(1), + KeyCode::Up | KeyCode::Char('k') => self.scroll_draft_detail(-1), + KeyCode::PageDown => { + let page = self.draft_detail_page(); + self.scroll_draft_detail(page); + } + KeyCode::PageUp => { + let page = self.draft_detail_page(); + self.scroll_draft_detail(-page); + } + KeyCode::Home | KeyCode::Char('g') => self.draft_detail_scroll = 0, + KeyCode::End | KeyCode::Char('G') => { + self.draft_detail_scroll = self.draft_detail_max_scroll(); + } _ => {} } return; @@ -1937,6 +2000,7 @@ impl App { } KeyCode::Enter if !self.draft_chunks.is_empty() => { self.draft_detail_open = true; + self.draft_detail_scroll = 0; } KeyCode::Char('j') | KeyCode::Down => { if total == 0 { @@ -2333,15 +2397,45 @@ impl App { // ------------------------------------------------------------------ fn open_create_provider_form(&mut self) { - let known = openshell_providers::ProviderRegistry::new().known_types(); - let types: Vec = known.into_iter().map(String::from).collect(); + let types = self.available_provider_profile_types(); self.create_provider_form = Some(CreateProviderForm { types, + status: self.provider_profiles.is_empty().then(|| { + "Provider profiles unavailable. Wait for refresh, then retry.".to_string() + }), ..CreateProviderForm::default() }); } + fn available_provider_profile_types(&self) -> Vec { + let mut types = self + .provider_profiles + .iter() + .map(|profile| profile.id.clone()) + .collect::>(); + types.sort(); + types.dedup(); + types + } + + pub(crate) fn sync_create_provider_types(&mut self) { + let types = self.available_provider_profile_types(); + let Some(form) = self.create_provider_form.as_mut() else { + return; + }; + if form.phase != CreateProviderPhase::SelectType { + return; + } + + form.types = types; + form.type_cursor = form.type_cursor.min(form.types.len().saturating_sub(1)); + form.status = form + .types + .is_empty() + .then(|| "Provider profiles unavailable. Wait for refresh, then retry.".to_string()); + } + fn handle_create_provider_key(&mut self, key: KeyEvent) { let Some(form) = self.create_provider_form.as_mut() else { return; @@ -2359,27 +2453,53 @@ impl App { form.type_cursor = form.type_cursor.saturating_sub(1); } KeyCode::Enter => { - let selected = form.types[form.type_cursor].clone(); - let registry = openshell_providers::ProviderRegistry::new(); - let env_vars = registry.credential_env_vars(&selected); - form.is_generic = env_vars.is_empty(); - - // Populate credential rows from all known env vars. - form.credentials = env_vars + let Some(selected) = form.types.get(form.type_cursor).cloned() else { + form.status = Some( + "Provider profiles unavailable. Wait for refresh, then retry." + .to_string(), + ); + return; + }; + let Some(profile) = self + .provider_profiles .iter() - .map(|s| (s.to_string(), String::new())) + .find(|profile| profile.id == selected) + .cloned() + else { + form.status = Some(format!("Provider profile '{selected}' is unavailable")); + return; + }; + let profile = ProviderTypeProfile::from_proto(&profile); + let mut credential_keys = Vec::new(); + for key in profile + .credentials + .iter() + .flat_map(|credential| credential.accepted_stored_keys()) + { + if !credential_keys.iter().any(|existing| existing == key) { + credential_keys.push(key.to_string()); + } + } + form.is_generic = credential_keys.is_empty(); + form.allows_empty_credentials = profile.allows_empty_provider_credentials(); + + // Populate credential rows from all accepted storage keys, + // including logical names for broker-only credentials. + form.credentials = credential_keys + .into_iter() + .map(|key| (key, String::new())) .collect(); form.cred_cursor = 0; // Auto-generate a unique name. form.name = unique_provider_name(&selected, &self.provider_names); - if form.is_generic { - // No known env vars — skip straight to manual entry. - form.phase = CreateProviderPhase::EnterKey; - form.key_field = ProviderKeyField::Name; - form.status = None; - form.warning = None; + if form.credentials.is_empty() { + // Credential-less profiles can be created directly. + form.discovered_credentials = Some(HashMap::new()); + form.phase = CreateProviderPhase::Creating; + form.anim_start = Some(Instant::now()); + self.pending_provider_create = true; } else { form.phase = CreateProviderPhase::ChooseMethod; form.method_cursor = 0; @@ -2400,10 +2520,18 @@ impl App { KeyCode::Enter => { let ptype = form.types[form.type_cursor].clone(); if form.method_cursor == 0 { - // Autodetect — synchronous since we only check env vars now. - let registry = openshell_providers::ProviderRegistry::new(); - if let Ok(Some(discovered)) = registry.discover_existing(&ptype) { - form.discovered_credentials = Some(discovered.credentials); + let discovered = self + .provider_profiles + .iter() + .find(|profile| profile.id == ptype) + .map(ProviderTypeProfile::from_proto) + .and_then(|profile| { + discover_from_profile(&profile, &RealDiscoveryContext) + .ok() + .flatten() + }); + if let Some(discovered) = discovered { + apply_discovered_provider(form, discovered); if form.name.is_empty() { form.name = unique_provider_name(&ptype, &self.provider_names); } @@ -2415,7 +2543,12 @@ impl App { form.phase = CreateProviderPhase::EnterKey; form.key_field = ProviderKeyField::Name; form.warning = Some( - "No credentials found in environment. Enter manually.".to_string(), + if form.allows_empty_credentials { + "No credentials found in environment. Enter credentials or submit without them." + } else { + "No credentials found in environment. Enter manually." + } + .to_string(), ); form.status = None; } @@ -2739,7 +2872,7 @@ impl App { creds.insert(name.clone(), value.clone()); } } - if creds.is_empty() { + if creds.is_empty() && !form.allows_empty_credentials { form.status = Some("At least one credential is required.".to_string()); return; @@ -2843,13 +2976,19 @@ impl App { }) .unwrap_or_default(); - // If we don't know the credential key, derive from registry. + // If we don't know the credential key, derive it from the profile. let key = if cred_key.is_empty() { - let registry = openshell_providers::ProviderRegistry::new(); - registry - .credential_env_vars(&ptype) - .first() - .map_or(String::new(), ToString::to_string) + self.provider_entries + .get(self.provider_selected) + .and_then(|entry| entry.profile.as_ref()) + .map(ProviderTypeProfile::from_proto) + .and_then(|profile| { + profile + .credential_env_vars() + .first() + .map(ToString::to_string) + }) + .unwrap_or_default() } else { cred_key }; @@ -3286,7 +3425,7 @@ impl App { ); let raw_profile_yaml = profile.and_then(|profile| { - let dto = openshell_providers::ProviderTypeProfile::from_proto(profile); + let dto = ProviderTypeProfile::from_proto(profile); openshell_providers::profile_to_yaml(&dto).ok() }); @@ -3385,7 +3524,7 @@ impl App { self.global_policy_active = false; self.global_policy_version = 0; // Reset provider state too. - self.providers_v2_enabled = false; + self.provider_profiles.clear(); self.provider_entries.clear(); self.provider_names.clear(); self.provider_types.clear(); @@ -3451,22 +3590,115 @@ mod tests { ) } + fn provider_profile( + id: &str, + credentials: Vec, + ) -> openshell_core::proto::ProviderProfile { + openshell_core::proto::ProviderProfile { + id: id.to_string(), + credentials, + ..Default::default() + } + } + + fn credential( + name: &str, + env_vars: &[&str], + required: bool, + runtime_resolvable: bool, + ) -> openshell_core::proto::ProviderProfileCredential { + openshell_core::proto::ProviderProfileCredential { + name: name.to_string(), + env_vars: env_vars.iter().map(ToString::to_string).collect(), + required, + token_grant: runtime_resolvable.then(Default::default), + ..Default::default() + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + #[tokio::test] - async fn global_settings_do_not_override_provider_api_capability() { + async fn create_provider_enter_with_empty_profile_catalog_is_recoverable() { let mut app = test_app(); - app.providers_v2_enabled = true; - let mut values = HashMap::new(); - values.insert( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - openshell_core::proto::SettingValue { - value: Some(setting_value::Value::BoolValue(false)), - }, + app.open_create_provider_form(); + + app.handle_create_provider_key(key(KeyCode::Enter)); + + let form = app + .create_provider_form + .as_ref() + .expect("form remains open"); + assert_eq!(form.phase, CreateProviderPhase::SelectType); + assert!( + form.status + .as_deref() + .is_some_and(|status| status.contains("profiles unavailable")) ); + assert!(!app.pending_provider_create); + + app.provider_profiles = vec![provider_profile("recovered", Vec::new())]; + app.sync_create_provider_types(); + let form = app + .create_provider_form + .as_ref() + .expect("form remains open"); + assert_eq!(form.types, vec!["recovered"]); + assert!(form.status.is_none()); + } + + #[tokio::test] + async fn create_provider_accepts_empty_credentials_when_profile_allows_them() { + for profile in [ + provider_profile("policy-only", Vec::new()), + provider_profile( + "optional-static", + vec![credential("api_key", &["API_KEY"], false, false)], + ), + provider_profile( + "runtime-token", + vec![credential("access_token", &["ACCESS_TOKEN"], true, true)], + ), + ] { + let mut app = test_app(); + app.provider_profiles = vec![profile]; + app.open_create_provider_form(); + app.handle_create_provider_key(key(KeyCode::Enter)); + + let form = app.create_provider_form.as_mut().expect("form"); + if form.phase != CreateProviderPhase::Creating { + form.phase = CreateProviderPhase::EnterKey; + form.key_field = ProviderKeyField::Submit; + app.handle_create_provider_key(key(KeyCode::Enter)); + } + + let form = app.create_provider_form.as_ref().expect("form"); + assert_eq!(form.phase, CreateProviderPhase::Creating); + assert_eq!(form.discovered_credentials, Some(HashMap::new())); + assert!(app.pending_provider_create); + } + } - app.apply_global_settings(values, 7); + #[tokio::test] + async fn create_provider_uses_logical_key_for_broker_only_credential() { + let mut app = test_app(); + app.provider_profiles = vec![provider_profile( + "token-exchange", + vec![credential("subject_token", &[], true, false)], + )]; + app.open_create_provider_form(); + + app.handle_create_provider_key(key(KeyCode::Enter)); - assert!(app.providers_v2_enabled); - assert_eq!(app.global_settings_revision, 7); + let form = app.create_provider_form.as_ref().expect("form"); + assert_eq!(form.phase, CreateProviderPhase::ChooseMethod); + assert_eq!( + form.credentials, + vec![("subject_token".to_string(), String::new())] + ); + assert!(!form.allows_empty_credentials); } #[tokio::test] @@ -3596,6 +3828,32 @@ mod tests { assert_eq!(gateway.source_label(), "unknown"); } + #[tokio::test] + async fn providers_workspace_shortcut_cycles_scope_and_resets_selections() { + let mut app = test_app(); + app.screen = Screen::Dashboard; + app.focus = Focus::Providers; + app.workspace_names = vec!["default".to_string(), "team-b".to_string()]; + app.provider_selected = 3; + app.sandbox_selected = 4; + + app.handle_key(key(KeyCode::Char('w'))); + + assert_eq!(app.current_workspace, "team-b"); + assert!(!app.all_workspaces); + assert_eq!(app.provider_selected, 0); + assert_eq!(app.sandbox_selected, 0); + assert!(app.pending_workspace_refresh); + + app.handle_key(key(KeyCode::Char('w'))); + assert!(app.all_workspaces); + assert_eq!(app.workspace_display(), "all"); + + app.handle_key(key(KeyCode::Char('w'))); + assert!(!app.all_workspaces); + assert_eq!(app.current_workspace, "default"); + } + // -- selected_sandbox_workspace ---------------------------------------- #[test] @@ -3881,4 +4139,34 @@ mod tests { ); assert_eq!(config.get("FOO"), Some(&"bar".to_string())); } + + #[test] + fn autodetected_provider_configuration_is_preserved_in_create_form() { + let mut form = CreateProviderForm::default(); + apply_discovered_provider( + &mut form, + DiscoveredProvider { + credentials: HashMap::from([("VERTEX_AI_TOKEN".to_string(), "token".to_string())]), + config: HashMap::from([ + ("VERTEX_AI_PROJECT_ID".to_string(), "project-a".to_string()), + ("VERTEX_AI_REGION".to_string(), "us-central1".to_string()), + ]), + }, + ); + + assert_eq!( + form.discovered_credentials + .as_ref() + .and_then(|credentials| credentials.get("VERTEX_AI_TOKEN")), + Some(&"token".to_string()) + ); + assert_eq!( + form.config.get("VERTEX_AI_PROJECT_ID"), + Some(&"project-a".to_string()) + ); + assert_eq!( + form.config.get("VERTEX_AI_REGION"), + Some(&"us-central1".to_string()) + ); + } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 819d254dc1..174f9910d0 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -8,6 +8,7 @@ pub mod theme; mod ui; use std::collections::HashMap; +use std::future::Future; use std::io; use std::path::PathBuf; use std::time::Duration; @@ -35,6 +36,7 @@ use event::{Event, EventHandler}; /// Duration to show the splash screen before auto-dismissing. const SPLASH_DURATION: Duration = Duration::from_secs(3); const PROVIDER_PROFILE_SCOPE_WORKSPACE: &str = "workspace"; +const PROVIDER_PROFILE_PAGE_SIZE: u32 = 100; type ProviderProfileCache = HashMap<(String, String), openshell_core::proto::ProviderProfile>; @@ -78,7 +80,6 @@ pub async fn run( let mut events = EventHandler::new(Duration::from_secs(2)); - fetch_providers_v2_setting(&mut app).await; refresh_gateway_list(&mut app); refresh_data(&mut app).await; @@ -500,10 +501,6 @@ async fn handle_gateway_switch(app: &mut App) { app.gateway_name = name; app.endpoint = endpoint; app.reset_sandbox_state(); - // Re-fetch the providers_v2 capability for the new gateway - // before refreshing data, so provider CRUD controls reflect - // the correct mode. - fetch_providers_v2_setting(app).await; refresh_data(app).await; } Err(e) => { @@ -1407,6 +1404,8 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { labels: HashMap::new(), annotations: HashMap::new(), workspace: workspace.clone(), + await_main_process_attachment: false, + workload_template_name: String::new(), }; let sandbox_name = @@ -1687,6 +1686,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { config: config.clone(), credential_expires_at_ms: HashMap::default(), profile_workspace: workspace.clone(), + credential_handles: HashMap::default(), }), workspace: workspace.clone(), }; @@ -1800,6 +1800,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { config, credential_expires_at_ms: HashMap::default(), profile_workspace: String::new(), + credential_handles: HashMap::default(), }), credential_expires_at_ms: HashMap::default(), workspace, @@ -1860,8 +1861,8 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { None => return, }; let abs = app.draft_scroll + app.draft_selected; - let chunk_id = match app.draft_chunks.get(abs) { - Some(c) => c.id.clone(), + let (chunk_id, review_token) = match app.draft_chunks.get(abs) { + Some(c) => (c.id.clone(), c.review_token.clone()), None => return, }; let rule_name = app @@ -1875,6 +1876,7 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { name, chunk_id, workspace, + review_token, }; match tokio::time::timeout(Duration::from_secs(5), client.approve_draft_chunk(req)).await { Ok(Ok(resp)) => { @@ -1947,7 +1949,7 @@ fn spawn_draft_reject(app: &App, tx: mpsc::UnboundedSender) { /// modal count display but is not iterated for per-chunk approval. fn spawn_draft_approve_all( app: &App, - _snapshot: Vec, + snapshot: Vec, tx: mpsc::UnboundedSender, ) { let mut client = app.client.clone(); @@ -1958,10 +1960,18 @@ fn spawn_draft_approve_all( let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { + let approvals = snapshot + .into_iter() + .map(|chunk| openshell_core::proto::DraftChunkApproval { + chunk_id: chunk.id, + review_token: chunk.review_token, + }) + .collect(); let req = openshell_core::proto::ApproveAllDraftChunksRequest { name, include_security_flagged: false, workspace, + approvals, }; match tokio::time::timeout( Duration::from_secs(30), @@ -1971,17 +1981,7 @@ fn spawn_draft_approve_all( { Ok(Ok(resp)) => { let inner = resp.into_inner(); - let msg = if inner.chunks_skipped > 0 { - format!( - "Approved {} chunks, skipped {} security-flagged -> policy v{}", - inner.chunks_approved, inner.chunks_skipped, inner.policy_version - ) - } else { - format!( - "Approved {} chunks -> policy v{}", - inner.chunks_approved, inner.policy_version - ) - }; + let msg = format_draft_approve_all_result(&inner); let _ = tx.send(Event::DraftActionResult(Ok(msg))); } Ok(Err(e)) => { @@ -1996,34 +1996,26 @@ fn spawn_draft_approve_all( }); } +fn format_draft_approve_all_result( + result: &openshell_core::proto::ApproveAllDraftChunksResponse, +) -> String { + if result.chunks_skipped > 0 { + format!( + "Approved {} chunks, skipped {}; review remaining pending chunks -> policy v{}", + result.chunks_approved, result.chunks_skipped, result.policy_version + ) + } else { + format!( + "Approved {} chunks -> policy v{}", + result.chunks_approved, result.policy_version + ) + } +} + // --------------------------------------------------------------------------- // Data refresh // --------------------------------------------------------------------------- -async fn fetch_providers_v2_setting(app: &mut App) { - let req = openshell_core::proto::GetGatewayConfigRequest {}; - match tokio::time::timeout(Duration::from_secs(5), app.client.get_gateway_config(req)).await { - Ok(Ok(resp)) => { - let response = resp.into_inner(); - let enabled = response - .settings - .get(openshell_core::settings::PROVIDERS_V2_ENABLED_KEY) - .and_then(|s| match &s.value { - Some(openshell_core::proto::setting_value::Value::BoolValue(v)) => Some(*v), - _ => None, - }) - .unwrap_or(false); - app.providers_v2_enabled = enabled; - } - Ok(Err(e)) => { - app.status_text = format!("failed to fetch gateway config: {}", e.message()); - } - Err(_) => { - app.status_text = "gateway config fetch timed out".to_string(); - } - } -} - async fn refresh_data(app: &mut App) { refresh_health(app).await; refresh_global_settings(app).await; @@ -2123,36 +2115,52 @@ async fn refresh_providers(app: &mut App) { }; let providers = response.providers; - let profiles: ProviderProfileCache = if app.providers_v2_enabled { - let workspaces: std::collections::HashSet = providers - .iter() - .map(|provider| provider_profile_query_workspace(provider).to_string()) - // Legacy provider records can decode without an object workspace. Do not - // turn that missing context into a platform-scoped profile request. - .filter(|workspace| !workspace.is_empty()) - .collect(); - let mut all_profiles = HashMap::new(); - for ws in &workspaces { - let req = openshell_core::proto::ListProviderProfilesRequest { - limit: 100, - offset: 0, - workspace: ws.clone(), - }; - if let Ok(Ok(resp)) = tokio::time::timeout( - Duration::from_secs(5), - app.client.list_provider_profiles(req), - ) - .await - { - for profile in resp.into_inner().profiles { - cache_provider_profile(&mut all_profiles, ws, profile); + let mut workspaces: std::collections::HashSet = providers + .iter() + .map(|provider| provider_profile_query_workspace(provider).to_string()) + // Legacy provider records can decode without an object workspace. Do not + // turn that missing context into a platform-scoped profile request. + .filter(|workspace| !workspace.is_empty()) + .collect(); + if !app.all_workspaces { + workspaces.insert(app.current_workspace.clone()); + } + let mut profiles = HashMap::new(); + app.provider_profiles.clear(); + for ws in &workspaces { + let client = app.client.clone(); + let workspace = ws.clone(); + if let Some(listed) = collect_provider_profile_pages(move |offset| { + let mut client = client.clone(); + let workspace = workspace.clone(); + async move { + let req = openshell_core::proto::ListProviderProfilesRequest { + limit: PROVIDER_PROFILE_PAGE_SIZE, + offset, + workspace, + }; + match tokio::time::timeout( + Duration::from_secs(5), + client.list_provider_profiles(req), + ) + .await + { + Ok(Ok(response)) => Some(response.into_inner().profiles), + _ => None, } } + }) + .await + { + if !app.all_workspaces && ws == &app.current_workspace { + app.provider_profiles.clone_from(&listed); + } + for profile in listed { + cache_provider_profile(&mut profiles, ws, profile); + } } - all_profiles - } else { - HashMap::new() - }; + } + app.sync_create_provider_types(); app.provider_count = providers.len(); app.provider_entries = providers @@ -2187,6 +2195,26 @@ async fn refresh_providers(app: &mut App) { } } +async fn collect_provider_profile_pages( + mut fetch_page: F, +) -> Option> +where + F: FnMut(u32) -> Fut, + Fut: Future>>, +{ + let mut profiles = Vec::new(); + let mut offset = 0; + loop { + let page = fetch_page(offset).await?; + let page_len = page.len(); + profiles.extend(page); + if page_len < PROVIDER_PROFILE_PAGE_SIZE as usize { + return Some(profiles); + } + offset = offset.saturating_add(PROVIDER_PROFILE_PAGE_SIZE); + } +} + async fn refresh_global_settings(app: &mut App) { if !app.global_settings_access_denied { let req = openshell_core::proto::GetGatewayConfigRequest {}; @@ -2685,6 +2713,10 @@ fn phase_label(phase: i32) -> String { x if x == SandboxPhase::Ready as i32 => "Ready", x if x == SandboxPhase::Error as i32 => "Error", x if x == SandboxPhase::Deleting as i32 => "Deleting", + x if x == SandboxPhase::Stopping as i32 => "Stopping", + x if x == SandboxPhase::Stopped as i32 => "Stopped", + x if x == SandboxPhase::Starting as i32 => "Starting", + x if x == SandboxPhase::Completed as i32 => "Completed", _ => "Unknown", } .to_string() @@ -2714,6 +2746,42 @@ fn format_age(epoch_ms: i64) -> String { } } +#[cfg(test)] +mod draft_approve_all_message_tests { + use super::*; + + #[test] + fn skipped_chunks_are_not_assumed_to_be_security_flagged() { + let message = format_draft_approve_all_result( + &openshell_core::proto::ApproveAllDraftChunksResponse { + policy_version: 7, + chunks_approved: 2, + chunks_skipped: 1, + ..Default::default() + }, + ); + + assert_eq!( + message, + "Approved 2 chunks, skipped 1; review remaining pending chunks -> policy v7" + ); + assert!(!message.contains("security-flagged")); + } +} + +#[cfg(test)] +mod phase_label_tests { + use super::*; + + #[test] + fn phase_label_covers_stop_and_start_lifecycle() { + assert_eq!(phase_label(SandboxPhase::Stopping as i32), "Stopping"); + assert_eq!(phase_label(SandboxPhase::Stopped as i32), "Stopped"); + assert_eq!(phase_label(SandboxPhase::Starting as i32), "Starting"); + assert_eq!(phase_label(SandboxPhase::Completed as i32), "Completed"); + } +} + /// Format epoch milliseconds as a human-readable UTC timestamp: `YYYY-MM-DD HH:MM`. fn format_timestamp(epoch_ms: i64) -> String { if epoch_ms <= 0 { @@ -2805,3 +2873,48 @@ mod provider_profile_workspace_tests { } } } + +#[cfg(test)] +mod provider_profile_pagination_tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[tokio::test] + async fn profile_fetch_continues_until_page_two_is_collected() { + let requested_offsets = Arc::new(Mutex::new(Vec::new())); + let offsets = Arc::clone(&requested_offsets); + + let profiles = collect_provider_profile_pages(move |offset| { + let offsets = Arc::clone(&offsets); + async move { + offsets.lock().unwrap().push(offset); + match offset { + 0 => Some( + (0..PROVIDER_PROFILE_PAGE_SIZE) + .map(|index| openshell_core::proto::ProviderProfile { + id: format!("profile-{index}"), + ..Default::default() + }) + .collect(), + ), + PROVIDER_PROFILE_PAGE_SIZE => { + Some(vec![openshell_core::proto::ProviderProfile { + id: "page-two-profile".to_string(), + ..Default::default() + }]) + } + _ => panic!("unexpected profile page offset {offset}"), + } + } + }) + .await + .expect("all pages should load"); + + assert_eq!( + *requested_offsets.lock().unwrap(), + vec![0, PROVIDER_PROFILE_PAGE_SIZE] + ); + assert_eq!(profiles.len(), PROVIDER_PROFILE_PAGE_SIZE as usize + 1); + assert_eq!(profiles.last().unwrap().id, "page-two-profile"); + } +} diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index cb39bff341..b68ddf5ea9 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -9,6 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use crate::app::{App, CreateProviderPhase, ProviderKeyField, UpdateProviderField}; use indexmap::IndexMap; +use std::ops::Range; use super::centered_rect; @@ -76,25 +77,48 @@ fn draw_select_type( chunks[0], ); - let lines: Vec> = form - .types - .iter() - .enumerate() - .map(|(i, ty)| { - let is_cursor = i == form.type_cursor; - let marker = if is_cursor { ">" } else { " " }; - let style = if is_cursor { t.accent } else { t.text }; - Line::from(vec![ - Span::styled(format!(" {marker} "), style), - Span::styled(ty.as_str(), style), - ]) - }) - .collect(); + let visible_range = visible_type_range( + form.types.len(), + form.type_cursor, + usize::from(chunks[2].height), + ); + let lines: Vec> = if form.types.is_empty() { + vec![Line::from(Span::styled( + form.status + .as_deref() + .unwrap_or("No provider profiles available."), + t.status_warn, + ))] + } else { + form.types + .iter() + .enumerate() + .skip(visible_range.start) + .take(visible_range.len()) + .map(|(i, ty)| { + let is_cursor = i == form.type_cursor; + let marker = if is_cursor { ">" } else { " " }; + let style = if is_cursor { t.accent } else { t.text }; + Line::from(vec![ + Span::styled(format!(" {marker} "), style), + Span::styled(ty.as_str(), style), + ]) + }) + .collect() + }; frame.render_widget(Paragraph::new(lines), chunks[2]); let hint = Line::from(vec![ Span::styled("[j/k]", t.key_hint), Span::styled(" Navigate ", t.muted), + Span::styled( + format!( + "[{}/{}] ", + form.type_cursor.saturating_add(1).min(form.types.len()), + form.types.len() + ), + t.muted, + ), Span::styled("[Enter]", t.key_hint), Span::styled(" Select ", t.muted), Span::styled("[Esc]", t.key_hint), @@ -103,6 +127,19 @@ fn draw_select_type( frame.render_widget(Paragraph::new(hint), chunks[4]); } +fn visible_type_range(total: usize, cursor: usize, visible_rows: usize) -> Range { + if total == 0 || visible_rows == 0 { + return 0..0; + } + + let visible_rows = visible_rows.min(total); + let cursor = cursor.min(total - 1); + let start = cursor + .saturating_sub(visible_rows - 1) + .min(total - visible_rows); + start..start + visible_rows +} + // --------------------------------------------------------------------------- // Phase 2: Choose method (autodetect vs manual) // --------------------------------------------------------------------------- @@ -1094,3 +1131,23 @@ fn render_status( ); } } + +#[cfg(test)] +mod tests { + use super::visible_type_range; + + #[test] + fn type_list_window_follows_cursor_past_visible_rows() { + assert_eq!(visible_type_range(25, 0, 10), 0..10); + assert_eq!(visible_type_range(25, 9, 10), 0..10); + assert_eq!(visible_type_range(25, 10, 10), 1..11); + assert_eq!(visible_type_range(25, 24, 10), 15..25); + } + + #[test] + fn type_list_window_handles_short_and_empty_lists() { + assert_eq!(visible_type_range(3, 2, 10), 0..3); + assert_eq!(visible_type_range(0, 0, 10), 0..0); + assert_eq!(visible_type_range(3, 2, 0), 0..0); + } +} diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 8df6dd3470..2521184320 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -103,8 +103,13 @@ fn draw_sandbox_screen(frame: &mut Frame<'_>, app: &mut App, area: Rect) { // Draft detail popup renders over the full frame. if app.focus == Focus::SandboxDraft && app.draft_detail_open { let abs = app.draft_scroll + app.draft_selected; - if let Some(chunk) = app.draft_chunks.get(abs) { - sandbox_draft::draw_detail_popup(frame, chunk, frame.size(), &app.theme); + let scroll = app.draft_detail_scroll; + let metrics = app.draft_chunks.get(abs).map(|chunk| { + sandbox_draft::draw_detail_popup(frame, chunk, frame.size(), &app.theme, scroll) + }); + if let Some(metrics) = metrics { + app.draft_detail_rows = metrics.total_rows; + app.draft_detail_body_height = metrics.body_height; } } @@ -138,10 +143,8 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { .find(|gateway| gateway.name == app.gateway_name) .map_or("unknown", app::GatewayEntry::source_label); - let mut parts: Vec> = vec![ - Span::styled(" >_ OpenShell ", t.accent_bold), - Span::styled(" ALPHA ", t.badge), - Span::styled(" | ", t.muted), + let mut parts: Vec> = title_bar_brand_spans(t); + parts.extend([ Span::styled("Current Gateway: ", t.text), Span::styled(&app.gateway_name, t.heading), Span::styled(" [", t.muted), @@ -150,7 +153,7 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { status_span, Span::styled(")", t.muted), Span::styled(" | ", t.muted), - ]; + ]); parts.push(Span::styled("Workspace: ", t.text)); parts.push(Span::styled(app.workspace_display(), t.heading)); @@ -175,6 +178,14 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { frame.render_widget(Paragraph::new(title).style(t.title_bar), area); } +fn title_bar_brand_spans(theme: &Theme) -> Vec> { + vec![ + Span::styled(" >_ OpenShell ", theme.accent_bold), + Span::styled(format!("v{}", openshell_core::VERSION), theme.muted), + Span::styled(" | ", theme.muted), + ] +} + fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { let t = &app.theme; let spans = match app.screen { @@ -202,27 +213,6 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("[q]", t.muted), Span::styled(" Quit", t.muted), ], - Focus::Providers if app.providers_v2_enabled => vec![ - Span::styled(" ", t.text), - Span::styled("[Tab]", t.key_hint), - Span::styled(" Switch Panel", t.text), - Span::styled(" ", t.text), - Span::styled("[h/l]", t.key_hint), - Span::styled(" Switch Tab", t.text), - Span::styled(" ", t.text), - Span::styled("[j/k]", t.key_hint), - Span::styled(" Navigate", t.text), - Span::styled(" ", t.text), - Span::styled("[Enter]", t.key_hint), - Span::styled(" Detail", t.text), - Span::styled(" ", t.text), - Span::styled("read-only", t.muted), - Span::styled(" | ", t.border), - Span::styled("[:]", t.muted), - Span::styled(" Command ", t.muted), - Span::styled("[q]", t.muted), - Span::styled(" Quit", t.muted), - ], Focus::Providers => vec![ Span::styled(" ", t.text), Span::styled("[Tab]", t.key_hint), @@ -237,14 +227,11 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("[Enter]", t.key_hint), Span::styled(" Detail", t.text), Span::styled(" ", t.text), - Span::styled("[c]", t.key_hint), - Span::styled(" Create", t.text), + Span::styled("[c/u/d]", t.key_hint), + Span::styled(" Create/Update/Delete", t.text), Span::styled(" ", t.text), - Span::styled("[u]", t.key_hint), - Span::styled(" Update", t.text), - Span::styled(" ", t.text), - Span::styled("[d]", t.key_hint), - Span::styled(" Delete", t.text), + Span::styled("[w]", t.key_hint), + Span::styled(" Workspace", t.text), Span::styled(" | ", t.border), Span::styled("[:]", t.muted), Span::styled(" Command ", t.muted), @@ -684,3 +671,70 @@ pub fn centered_popup(percent_x: u16, height: u16, area: Rect) -> Rect { ]) .split(vert[1])[1] } + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::auth::EdgeAuthInterceptor; + use openshell_core::proto::open_shell_client::OpenShellClient; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn test_app() -> App { + let channel = tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy(); + let client = OpenShellClient::with_interceptor(channel, EdgeAuthInterceptor::noop()); + let mut app = App::new( + client, + "test".to_string(), + "http://127.0.0.1:1".to_string(), + "default".to_string(), + Theme::dark(), + ); + app.screen = Screen::Dashboard; + app.focus = Focus::Providers; + app + } + + #[tokio::test] + async fn providers_navigation_advertises_workspace_shortcut() { + let app = test_app(); + let mut terminal = Terminal::new(TestBackend::new(180, 1)).unwrap(); + + terminal + .draw(|frame| draw_nav_bar(frame, &app, frame.size())) + .unwrap(); + + let text: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!(text.contains("[w] Workspace"), "nav bar was: {text:?}"); + } + + #[test] + fn title_bar_brand_renders_resolved_version_without_alpha_badge() { + let expected = format!(" >_ OpenShell v{} | ", openshell_core::VERSION); + let width = u16::try_from(expected.len()).unwrap(); + let backend = TestBackend::new(width, 1); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new(Line::from(title_bar_brand_spans(&Theme::dark()))), + frame.size(), + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let rendered = (0..width) + .map(|x| buffer.get(x, 0).symbol()) + .collect::(); + assert_eq!(rendered, expected); + assert!(!rendered.contains("ALPHA")); + } +} diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index e4f4d2d0ba..3bf5c7f94c 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -9,12 +9,12 @@ use ratatui::widgets::{Block, Borders, Cell, Padding, Row, Table}; use crate::app::App; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { - let t = &app.theme; - if app.providers_v2_enabled { - draw_v2(frame, app, area, focused); - return; - } + draw_v2(frame, app, area, focused); +} +#[allow(dead_code)] +fn draw_legacy(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let t = &app.theme; let show_ws = app.all_workspaces; let mut header_cells = Vec::new(); diff --git a/crates/openshell-tui/src/ui/sandbox_detail.rs b/crates/openshell-tui/src/ui/sandbox_detail.rs index f212f21b06..4ff78617f0 100644 --- a/crates/openshell-tui/src/ui/sandbox_detail.rs +++ b/crates/openshell-tui/src/ui/sandbox_detail.rs @@ -22,16 +22,17 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let age = app.sandbox_ages.get(idx).map_or("-", String::as_str); let phase_style = match phase { - "Ready" => t.status_ok, - "Provisioning" => t.status_warn, + "Ready" | "Completed" => t.status_ok, + "Provisioning" | "Stopping" | "Starting" => t.status_warn, "Error" => t.status_err, _ => t.muted, }; let status_indicator = match phase { "Ready" => "●", - "Provisioning" => "◐", - "Error" => "○", + "Completed" => "✓", + "Provisioning" | "Stopping" | "Starting" => "◐", + "Error" | "Stopped" => "○", _ => "…", }; diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index 5fd11cb997..470463b7d4 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -6,10 +6,10 @@ use crate::app::App; use openshell_core::proto::{L7Allow, L7DenyRule, L7QueryMatcher, NetworkEndpoint, PolicyChunk}; use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::Modifier; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use super::centered_rect; @@ -150,6 +150,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { if chunk.hit_count > 1 { spans.push(Span::styled(format!(" {}x", chunk.hit_count), t.accent)); } + if let Some(reason) = rejection_guidance(chunk) { + spans.push(Span::styled( + format!(" \"{}\"", truncate_display(reason, 32)), + t.muted, + )); + } let mut line = Line::from(spans); if is_selected { @@ -172,12 +178,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { // Detail popup (Enter key) // --------------------------------------------------------------------------- +/// What `draw_detail_popup` actually laid out, so the app can clamp its scroll +/// offset to content that exists. +pub struct DetailMetrics { + /// Total rows of content, after wrapping. + pub total_rows: usize, + /// Rows visible in the scrollable body, excluding the pinned hint row. + pub body_height: usize, +} + pub fn draw_detail_popup( frame: &mut Frame<'_>, chunk: &PolicyChunk, area: Rect, theme: &crate::theme::Theme, -) { + scroll: usize, +) -> DetailMetrics { let t = theme; let popup_width = (area.width * 4 / 5).min(area.width.saturating_sub(4)); let popup_height = 22u16.min(area.height.saturating_sub(4)); @@ -198,6 +214,11 @@ pub fn draw_detail_popup( .border_style(t.accent) .padding(Padding::new(1, 1, 0, 0)); + // Text columns inside the borders and the one-column padding on each side. + // Content is wrapped to this width here rather than by `Wrap`, so the row + // count below is exactly what renders and the scroll clamp stays honest. + let text_width = usize::from(popup_width).saturating_sub(4).max(1); + let mut lines: Vec> = vec![ Line::from(vec![ Span::styled("Status: ", t.muted), @@ -215,57 +236,100 @@ pub fn draw_detail_popup( ApprovalAnnotationKind::RequiresReview => t.status_warn.add_modifier(Modifier::BOLD), ApprovalAnnotationKind::Reviewed => t.muted, }; - lines.push(Line::from(vec![ - Span::styled("Review: ", t.muted), - Span::styled(annotation.detail_label, annotation_style), - ])); + push_wrapped( + &mut lines, + "Review: ", + t.muted, + &annotation.detail_label, + annotation_style, + text_width, + ); + } + + // Reviewer's persisted rejection guidance. The reason is free-form and has + // no server-side length cap, so it wraps and the popup scrolls instead of + // clipping the tail. + if let Some(reason) = rejection_guidance(chunk) { + push_wrapped( + &mut lines, + "Guidance: ", + t.muted, + reason, + t.status_err, + text_width, + ); } // Binary (denormalized from the denial). if !chunk.binary.is_empty() { - lines.push(Line::from(vec![ - Span::styled("Binary: ", t.muted), - Span::styled(&chunk.binary, t.text), - ])); + push_wrapped( + &mut lines, + "Binary: ", + t.muted, + &chunk.binary, + t.text, + text_width, + ); } - // Hit count (accumulated real denial count) and first/last seen. - lines.push(Line::from(vec![ - Span::styled("Denied: ", t.muted), - Span::styled( - format!( - "{} connection{}", - chunk.hit_count, - if chunk.hit_count == 1 { "" } else { "s" } - ), + // Hit count (accumulated real denial count) and first/last seen. Kept on one + // row while it fits, so a narrow terminal wraps it instead of losing the tail. + let denied_label = "Denied: "; + let denied_count = format!( + "{} connection{}", + chunk.hit_count, + if chunk.hit_count == 1 { "" } else { "s" } + ); + let denied_seen = format!( + "(first {} / last {})", + format_short_time(chunk.first_seen_ms), + format_short_time(chunk.last_seen_ms), + ); + let denied_width = display_width(denied_label) + + display_width(&denied_count) + + 2 + + display_width(&denied_seen); + if denied_width <= text_width { + lines.push(Line::from(vec![ + Span::styled(denied_label, t.muted), + Span::styled(denied_count, t.accent), + Span::styled(format!(" {denied_seen}"), t.muted), + ])); + } else { + push_wrapped( + &mut lines, + denied_label, + t.muted, + &denied_count, t.accent, - ), - Span::styled( - format!( - " (first {} / last {})", - format_short_time(chunk.first_seen_ms), - format_short_time(chunk.last_seen_ms), - ), + text_width, + ); + push_wrapped( + &mut lines, + &" ".repeat(display_width(denied_label)), t.muted, - ), - ])); + &denied_seen, + t.muted, + text_width, + ); + } // Endpoints. if let Some(ref rule) = chunk.proposed_rule { lines.push(Line::from("")); lines.push(Line::from(Span::styled("Endpoints:", t.muted))); for ep in &rule.endpoints { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled("-> ", t.muted), - Span::styled(format_endpoint_summary(ep), t.accent), - ])); + push_wrapped( + &mut lines, + " -> ", + t.muted, + &format_endpoint_summary(ep), + t.accent, + text_width, + ); for detail in format_endpoint_details(ep) { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(detail, t.text), - ])); + push_wrapped(&mut lines, " ", t.text, &detail, t.text, text_width); } } @@ -274,10 +338,7 @@ pub fn draw_detail_popup( lines.push(Line::from("")); lines.push(Line::from(Span::styled("Binaries:", t.muted))); for b in &rule.binaries { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(&b.path, t.text), - ])); + push_wrapped(&mut lines, " ", t.text, &b.path, t.text, text_width); } } } @@ -285,59 +346,86 @@ pub fn draw_detail_popup( // Rationale. if !chunk.rationale.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("Rationale: ", t.muted), - Span::styled(&chunk.rationale, t.text), - ])); + push_wrapped( + &mut lines, + "Rationale: ", + t.muted, + &chunk.rationale, + t.text, + text_width, + ); } // Security notes. if !chunk.security_notes.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - format!("! {}", chunk.security_notes), - t.status_warn.add_modifier(Modifier::BOLD), - )])); + let warn = t.status_warn.add_modifier(Modifier::BOLD); + push_wrapped( + &mut lines, + "! ", + warn, + &chunk.security_notes, + warn, + text_width, + ); } - // Action hints — state-aware toggle keys. - lines.push(Line::from("")); - let mut hint_spans: Vec> = Vec::new(); - match chunk.status.as_str() { - "pending" => { - hint_spans.extend([ - Span::styled("[a]", t.key_hint), - Span::styled(" Approve ", t.text), - Span::styled("[x]", t.key_hint), - Span::styled(" Reject ", t.text), - ]); - } - "approved" => { - hint_spans.extend([ - Span::styled("[x]", t.key_hint), - Span::styled(" Revoke ", t.text), - ]); - } - "rejected" => { - hint_spans.extend([ - Span::styled("[a]", t.key_hint), - Span::styled(" Approve ", t.text), - ]); + // Split the inner area into a scrollable body and pinned hint rows, so the + // action and close controls stay on screen however long the content is. The + // hints need a second row on a narrow terminal and that shrinks the body, so + // settle the two together. + let inner = block.inner(popup_area); + let inner_height = usize::from(inner.height); + let total_rows = lines.len(); + let max_footer = inner_height.saturating_sub(1).max(1); + + let mut footer_rows = 1usize; + let mut hint_rows = Vec::new(); + for _ in 0..2 { + let body = inner_height.saturating_sub(footer_rows); + let scrollable = total_rows.saturating_sub(body) > 0; + hint_rows = pack_hints(&hint_units(chunk, t, scrollable), text_width); + let needed = hint_rows.len().clamp(1, max_footer); + if needed == footer_rows { + break; } - _ => {} + footer_rows = needed; } - hint_spans.extend([ - Span::styled("[Esc]", t.muted), - Span::styled(" Close", t.muted), - ]); - lines.push(Line::from(hint_spans)); + let parts = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(u16::try_from(footer_rows).unwrap_or(1)), + ]) + .split(inner); + let body_height = usize::from(parts[0].height); + let max_scroll = total_rows.saturating_sub(body_height); + let scroll = scroll.min(max_scroll); + + let block = if max_scroll > 0 { + block.title_bottom( + Line::from(Span::styled( + format!(" {}/{} ", scroll + 1, total_rows), + t.muted, + )) + .right_aligned(), + ) + } else { + block + }; + + frame.render_widget(block, popup_area); frame.render_widget( - Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }), - popup_area, + Paragraph::new(lines).scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), + parts[0], ); + frame.render_widget(Paragraph::new(hint_rows), parts[1]); + + DetailMetrics { + total_rows, + body_height, + } } // --------------------------------------------------------------------------- @@ -460,6 +548,188 @@ fn truncate_str(s: &str, max_len: usize) -> String { } } +/// Terminal display width of `text` in columns. +/// +/// Uses the same measurement ratatui applies when it lays cells out, so a +/// double-width glyph such as CJK counts as the two columns it will occupy. +/// Counting `chars` here instead would let CJK text render past the popup edge +/// with no way to scroll to the missing tail. +fn display_width(text: &str) -> usize { + Span::raw(text).width() +} + +/// Word-wrap `text` into rows of at most `width` display columns. +/// +/// A word wider than `width` is hard-broken rather than allowed to overflow. +/// Always returns at least one row so callers can index the first row safely. +fn wrap_value(text: &str, width: usize) -> Vec { + if width == 0 { + return vec![text.to_string()]; + } + let mut rows: Vec = Vec::new(); + let mut cur = String::new(); + let mut cur_width = 0usize; + let mut buf = [0u8; 4]; + for word in text.split_whitespace() { + let word_width = display_width(word); + if word_width > width { + if cur_width > 0 { + rows.push(std::mem::take(&mut cur)); + cur_width = 0; + } + for ch in word.chars() { + let ch_width = display_width(ch.encode_utf8(&mut buf)); + if cur_width + ch_width > width && cur_width > 0 { + rows.push(std::mem::take(&mut cur)); + cur_width = 0; + } + cur.push(ch); + cur_width += ch_width; + } + continue; + } + let needed = if cur_width == 0 { + word_width + } else { + cur_width + 1 + word_width + }; + if needed > width { + rows.push(std::mem::take(&mut cur)); + cur_width = 0; + } + if cur_width > 0 { + cur.push(' '); + cur_width += 1; + } + cur.push_str(word); + cur_width += word_width; + } + if cur_width > 0 || rows.is_empty() { + rows.push(cur); + } + rows +} + +/// Truncate `text` to `max_columns` display columns, appending `...` when cut. +/// +/// `truncate_str` counts chars, which is right for its existing callers but +/// would let a CJK value take twice its budget on the list row. +fn truncate_display(text: &str, max_columns: usize) -> String { + if display_width(text) <= max_columns { + return text.to_string(); + } + if max_columns <= 3 { + return ".".repeat(max_columns); + } + let budget = max_columns - 3; + let mut out = String::new(); + let mut width = 0usize; + let mut buf = [0u8; 4]; + for ch in text.chars() { + let ch_width = display_width(ch.encode_utf8(&mut buf)); + if width + ch_width > budget { + break; + } + out.push(ch); + width += ch_width; + } + out.push_str("..."); + out +} + +/// Push `text` wrapped to `width`, with `prefix` on the first row and a matching +/// indent on continuation rows so the label column stays aligned. +fn push_wrapped( + lines: &mut Vec>, + prefix: &str, + prefix_style: Style, + text: &str, + text_style: Style, + width: usize, +) { + let indent = display_width(prefix); + let available = width.saturating_sub(indent).max(1); + for (i, row) in wrap_value(text, available).into_iter().enumerate() { + let head = if i == 0 { + Span::styled(prefix.to_string(), prefix_style) + } else { + Span::raw(" ".repeat(indent)) + }; + lines.push(Line::from(vec![head, Span::styled(row, text_style)])); + } +} + +/// One footer hint: the spans that render it, and its display width. +type HintUnit = (Vec>, usize); + +/// State-aware hints for the detail popup footer. +fn hint_units(chunk: &PolicyChunk, t: &crate::theme::Theme, scrollable: bool) -> Vec { + let mut units: Vec = Vec::new(); + { + let mut add = |key: &str, label: &str, key_style: Style, label_style: Style| { + units.push(( + vec![ + Span::styled(key.to_string(), key_style), + Span::styled(label.to_string(), label_style), + ], + display_width(key) + display_width(label), + )); + }; + match chunk.status.as_str() { + "pending" => { + add("[a]", " Approve ", t.key_hint, t.text); + add("[x]", " Reject ", t.key_hint, t.text); + } + "approved" => add("[x]", " Revoke ", t.key_hint, t.text), + "rejected" => add("[a]", " Approve ", t.key_hint, t.text), + _ => {} + } + if scrollable { + add("[j/k]", " Scroll ", t.key_hint, t.text); + } + add("[Esc]", " Close", t.muted, t.muted); + } + units +} + +/// Pack hint units into rows no wider than `width`, never splitting a unit. +/// +/// A narrow terminal would otherwise clip the trailing hints, and `[Esc] Close` +/// is the last one. +fn pack_hints(units: &[HintUnit], width: usize) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut current: Vec> = Vec::new(); + let mut current_width = 0usize; + for (spans, unit_width) in units { + if current_width + unit_width > width && !current.is_empty() { + rows.push(Line::from(std::mem::take(&mut current))); + current_width = 0; + } + current.extend(spans.iter().cloned()); + current_width += unit_width; + } + if !current.is_empty() { + rows.push(Line::from(current)); + } + if rows.is_empty() { + rows.push(Line::from(String::new())); + } + rows +} + +/// The reviewer's persisted note for a rejected chunk. +/// +/// Gated on status rather than on the field alone: the gateway's +/// `update_draft_chunk_status` leaves `rejection_reason` untouched when a chunk +/// is later approved, so an approved chunk can still carry a stale note. +fn rejection_guidance(chunk: &PolicyChunk) -> Option<&str> { + if chunk.status != "rejected" { + return None; + } + let reason = chunk.rejection_reason.trim(); + (!reason.is_empty()).then_some(reason) +} + #[derive(Clone, Copy)] enum ApprovalAnnotationKind { AutoApproved, @@ -474,6 +744,14 @@ struct ApprovalAnnotation { } fn approval_annotation(chunk: &PolicyChunk) -> Option { + let application_error = chunk.application_error.trim(); + if !application_error.is_empty() { + return Some(ApprovalAnnotation { + kind: ApprovalAnnotationKind::RequiresReview, + short_label: "application blocked".to_string(), + detail_label: format!("candidate cannot be applied: {application_error}"), + }); + } let validation = chunk.validation_result.trim(); if validation.is_empty() { return None; @@ -693,3 +971,396 @@ fn format_short_time(epoch_ms: i64) -> String { let seconds = time_of_day % 60; format!("{hours:02}:{minutes:02}:{seconds:02}") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// Column width computed independently of the production `display_width`. + /// + /// Asserting with the helper under test would be tautological: if it + /// regressed to counting chars, the assertion would regress with it. + fn expected_columns(text: &str) -> usize { + text.chars() + .map(|c| { + let wide = ('\u{1100}'..='\u{115f}').contains(&c) + || ('\u{2e80}'..='\u{a4cf}').contains(&c) + || ('\u{ac00}'..='\u{d7a3}').contains(&c) + || ('\u{f900}'..='\u{faff}').contains(&c) + || ('\u{fe30}'..='\u{fe6f}').contains(&c) + || ('\u{ff00}'..='\u{ff60}').contains(&c) + || ('\u{ffe0}'..='\u{ffe6}').contains(&c); + usize::from(wide) + 1 + }) + .sum() + } + + fn make_chunk(status: &str, rejection_reason: &str) -> PolicyChunk { + PolicyChunk { + status: status.to_string(), + rejection_reason: rejection_reason.to_string(), + ..Default::default() + } + } + + #[test] + fn rejected_chunk_exposes_its_reason() { + let chunk = make_chunk("rejected", "too broad: allows any port"); + assert_eq!( + rejection_guidance(&chunk), + Some("too broad: allows any port") + ); + } + + #[test] + fn approved_chunk_hides_stale_reason() { + // Approving passes None for the reason, which leaves the stored value in + // place, so a chunk rejected and later approved still carries the note. + let chunk = make_chunk("approved", "too broad: allows any port"); + assert_eq!(rejection_guidance(&chunk), None); + } + + #[test] + fn pending_chunk_has_no_guidance() { + assert_eq!(rejection_guidance(&make_chunk("pending", "")), None); + } + + #[test] + fn blank_reason_is_dropped() { + assert_eq!(rejection_guidance(&make_chunk("rejected", "")), None); + assert_eq!(rejection_guidance(&make_chunk("rejected", " \n")), None); + } + + #[test] + fn reason_is_trimmed() { + let chunk = make_chunk("rejected", " needs a narrower host "); + assert_eq!(rejection_guidance(&chunk), Some("needs a narrower host")); + } + + #[test] + fn long_reason_truncates_for_the_list_row() { + let reason = "rejected because the endpoint list is far too permissive"; + let shown = truncate_display(reason, 32); + assert_eq!(display_width(&shown), 32); + assert!(shown.ends_with("...")); + assert!(shown.starts_with("rejected because")); + } + + #[test] + fn short_reason_is_not_truncated() { + assert_eq!(truncate_str("too broad", 32), "too broad"); + } + + // --- wrapping --------------------------------------------------------- + + #[test] + fn wrap_value_breaks_on_word_boundaries() { + assert_eq!( + wrap_value("alpha beta gamma", 11), + vec!["alpha beta", "gamma"] + ); + } + + #[test] + fn wrap_value_hard_breaks_a_word_longer_than_the_width() { + assert_eq!(wrap_value("abcdefghij", 4), vec!["abcd", "efgh", "ij"]); + } + + #[test] + fn wrap_value_always_returns_at_least_one_row() { + assert_eq!(wrap_value("", 10), vec![String::new()]); + } + + #[test] + fn wrap_value_never_exceeds_the_width() { + let text = "reject this rule because the endpoint list is far too permissive"; + for row in wrap_value(text, 17) { + assert!(display_width(&row) <= 17, "row too wide: {row:?}"); + } + } + + #[test] + fn wrap_value_measures_display_columns_not_chars() { + // CJK glyphs occupy two columns each, and with no spaces the whole + // string takes the hard-break path. + let cjk = "这条规则的范围太广".repeat(20); + for row in wrap_value(&cjk, 40) { + assert!( + expected_columns(&row) <= 40, + "row is {} columns: {row:?}", + expected_columns(&row) + ); + } + + // Mixed script exercises the word-packing path instead. + let mixed = "scope 这条规则 to docs 路径 only ".repeat(20); + for row in wrap_value(&mixed, 33) { + assert!( + expected_columns(&row) <= 33, + "row is {} columns: {row:?}", + expected_columns(&row) + ); + } + } + + #[test] + fn truncate_display_counts_columns_not_chars() { + let cjk = "这条规则的范围太广".repeat(10); + let shown = truncate_display(&cjk, 32); + assert!( + expected_columns(&shown) <= 32, + "truncated value is {} columns", + expected_columns(&shown) + ); + assert!(shown.ends_with("...")); + assert_eq!(truncate_display("too broad", 32), "too broad"); + } + + // --- rendering -------------------------------------------------------- + + fn render(chunk: &PolicyChunk, width: u16, height: u16, scroll: usize) -> (String, usize) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut max_scroll = 0usize; + terminal + .draw(|frame| { + let metrics = draw_detail_popup( + frame, + chunk, + Rect::new(0, 0, width, height), + &Theme::dark(), + scroll, + ); + max_scroll = metrics.total_rows.saturating_sub(metrics.body_height); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + (text, max_scroll) + } + + /// A rejection reason has no server-side length cap, so the popup has to keep + /// a paragraph-length one reachable rather than clipping its tail. + fn long_reason() -> String { + let mut reason = String::from("PREFIX_MARKER "); + while reason.len() < 1980 { + reason.push_str("this rule is far too broad and must be narrowed; "); + } + reason.push_str(" SUFFIX_MARKER"); + reason + } + + #[test] + fn long_guidance_head_and_tail_are_both_reachable() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: long_reason(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + + let (top, max_scroll) = render(&chunk, 80, 24, 0); + assert!(max_scroll > 0, "content should overflow an 80x24 popup"); + assert!( + top.contains("PREFIX_MARKER"), + "head not visible at scroll 0" + ); + + let (bottom, _) = render(&chunk, 80, 24, max_scroll); + assert!( + bottom.contains("SUFFIX_MARKER"), + "tail not reachable at max scroll" + ); + } + + #[test] + fn action_hints_stay_visible_at_every_scroll_position() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: long_reason(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + + let (top, max_scroll) = render(&chunk, 80, 24, 0); + let (bottom, _) = render(&chunk, 80, 24, max_scroll); + for (label, screen) in [("top", &top), ("bottom", &bottom)] { + assert!(screen.contains("Close"), "close hint missing at {label}"); + assert!( + screen.contains("Approve"), + "approve hint missing at {label}" + ); + assert!(screen.contains("Scroll"), "scroll hint missing at {label}"); + } + } + + #[test] + fn scrolling_past_the_end_is_clamped_to_the_last_page() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: long_reason(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + let (_, max_scroll) = render(&chunk, 80, 24, 0); + let (clamped, _) = render(&chunk, 80, 24, max_scroll + 500); + let (last, _) = render(&chunk, 80, 24, max_scroll); + assert_eq!( + clamped, last, + "over-scrolling should clamp to the last page" + ); + } + + /// The same overflow already affected `rationale` before this change, so the + /// scrollable body has to fix that case too. + #[test] + fn long_rationale_tail_is_reachable_on_a_pending_chunk() { + let mut rationale = String::from("PREFIX_MARKER "); + while rationale.len() < 1980 { + rationale.push_str("the agent needs broad network access; "); + } + rationale.push_str(" SUFFIX_MARKER"); + let chunk = PolicyChunk { + status: "pending".to_string(), + rule_name: "allow-github".to_string(), + rationale, + ..Default::default() + }; + + let (_, max_scroll) = render(&chunk, 80, 24, 0); + let (bottom, _) = render(&chunk, 80, 24, max_scroll); + assert!(bottom.contains("SUFFIX_MARKER")); + assert!(bottom.contains("Close")); + } + + /// Double-width guidance must stay reachable too. Counting chars rather than + /// columns made every wrapped row about twice as wide as the popup, so the + /// right half of each row was clipped with nowhere to scroll. Markers are + /// interleaved through the text rather than appended, because a trailing + /// marker lands on its own short row either way and would not detect this. + #[test] + fn cjk_guidance_is_fully_reachable_at_80x24() { + use std::fmt::Write as _; + + const MARKERS: usize = 16; + let mut reason = String::new(); + for i in 0..MARKERS { + let _ = write!(reason, "M{i:02}"); + reason.push_str(&"这条规则范围太广".repeat(4)); + } + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: reason, + rule_name: "allow-github".to_string(), + ..Default::default() + }; + + let (_, max_scroll) = render(&chunk, 80, 24, 0); + assert!(max_scroll > 0, "content should overflow an 80x24 popup"); + + let mut seen = String::new(); + for scroll in 0..=max_scroll { + seen.push_str(&render(&chunk, 80, 24, scroll).0); + } + for i in 0..MARKERS { + let marker = format!("M{i:02}"); + assert!( + seen.contains(&marker), + "guidance marker {marker} was clipped and is unreachable at every scroll offset" + ); + } + } + + #[test] + fn short_content_does_not_scroll() { + let chunk = PolicyChunk { + status: "rejected".to_string(), + rejection_reason: "too broad".to_string(), + rule_name: "allow-github".to_string(), + ..Default::default() + }; + let (screen, max_scroll) = render(&chunk, 80, 24, 0); + assert_eq!(max_scroll, 0, "short content should not be scrollable"); + assert!(screen.contains("too broad")); + assert!( + !screen.contains("Scroll"), + "scroll hint should be hidden when everything fits" + ); + } + + fn denied_chunk(status: &str, reason: &str) -> PolicyChunk { + PolicyChunk { + status: status.to_string(), + rejection_reason: reason.to_string(), + rule_name: "allow-github".to_string(), + confidence: 0.82, + hit_count: 3, + first_seen_ms: 1_700_000_000_000, + last_seen_ms: 1_700_000_100_000, + ..Default::default() + } + } + + /// Dropping `Wrap` means anything not routed through `push_wrapped` clips + /// horizontally, so the denial timestamps have to wrap on a narrow popup. + #[test] + fn denied_timestamps_survive_a_narrow_popup() { + for width in [60u16, 70u16, 80u16] { + let chunk = denied_chunk("rejected", "scope this to docs/ paths only"); + let (screen, _) = render(&chunk, width, 24, 0); + assert!( + screen.contains("22:13:20"), + "first-seen lost at width {width}" + ); + assert!( + screen.contains("22:15:00"), + "last-seen lost at width {width}" + ); + } + } + + /// The close hint is the last one, so a narrow footer would drop it first. + #[test] + fn close_hint_survives_a_narrow_popup_with_scrollable_content() { + for width in [60u16, 70u16, 80u16] { + let chunk = denied_chunk("pending", ""); + let chunk = PolicyChunk { + rationale: long_reason(), + ..chunk + }; + let (screen, max_scroll) = render(&chunk, width, 24, 0); + assert!(max_scroll > 0, "content should overflow at width {width}"); + assert!(screen.contains("Close"), "close hint lost at width {width}"); + assert!( + screen.contains("Approve"), + "approve hint lost at width {width}" + ); + assert!( + screen.contains("Reject"), + "reject hint lost at width {width}" + ); + } + } + + #[test] + fn hints_pack_onto_one_row_when_they_fit() { + let theme = Theme::dark(); + let chunk = denied_chunk("pending", ""); + let rows = pack_hints(&hint_units(&chunk, &theme, true), 200); + assert_eq!(rows.len(), 1); + } + + #[test] + fn hints_spill_onto_a_second_row_when_they_do_not_fit() { + let theme = Theme::dark(); + let chunk = denied_chunk("pending", ""); + let rows = pack_hints(&hint_units(&chunk, &theme, true), 30); + assert!(rows.len() > 1, "hints should wrap at 30 columns"); + } +} diff --git a/crates/openshell-tui/src/ui/sandboxes.rs b/crates/openshell-tui/src/ui/sandboxes.rs index 4d239241b9..b1d3edc1fe 100644 --- a/crates/openshell-tui/src/ui/sandboxes.rs +++ b/crates/openshell-tui/src/ui/sandboxes.rs @@ -40,8 +40,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let draft_count = app.sandbox_draft_counts.get(i).copied().unwrap_or(0); let phase_style = match phase { - "Ready" => t.status_ok, - "Provisioning" => t.status_warn, + "Ready" | "Completed" => t.status_ok, + "Provisioning" | "Stopping" | "Starting" => t.status_warn, "Error" => t.status_err, _ => t.muted, }; diff --git a/crates/openshell-tui/src/ui/splash.rs b/crates/openshell-tui/src/ui/splash.rs index 7f889f9951..a46667fb32 100644 --- a/crates/openshell-tui/src/ui/splash.rs +++ b/crates/openshell-tui/src/ui/splash.rs @@ -104,16 +104,11 @@ pub fn draw(frame: &mut Frame<'_>, area: Rect, theme: &crate::theme::Theme) { frame.render_widget(Paragraph::new(content_lines), chunks[0]); - // -- Footer: version + ALPHA badge on line 1, prompt on line 2 -- + // -- Footer: version on line 1, prompt on line 2 -- let version = format!("v{}", openshell_core::VERSION); - let alpha_badge = "ALPHA"; let footer = Paragraph::new(vec![ - Line::from(vec![ - Span::styled(version, t.accent), - Span::styled(" ", t.muted), - Span::styled(alpha_badge, t.title_bar), - ]), + Line::from(Span::styled(version, t.accent)), Line::from(Span::styled("press any key ░", t.muted)), ]); diff --git a/crates/openshell-vfio/BUILD.bazel b/crates/openshell-vfio/BUILD.bazel deleted file mode 100644 index 1e08c0bb93..0000000000 --- a/crates/openshell-vfio/BUILD.bazel +++ /dev/null @@ -1,29 +0,0 @@ -load("@crates//:defs.bzl", "aliases", "all_crate_deps") -load("@rules_rs//rs:rust_binary.bzl", "rust_binary") -load("@rules_rs//rs:rust_library.bzl", "rust_library") -load("@rules_rs//rs:rust_test.bzl", "rust_test") -load("@rules_rust//rust:defs.bzl", "rustfmt_test") - -rust_library( - name = "openshell-vfio", - srcs = glob(["src/**/*.rs"]), - aliases = aliases(), - target_compatible_with = ["@platforms//os:linux"], - deps = all_crate_deps(normal = True), -) - -rust_test( - name = "openshell-vfio_test", - crate = ":openshell-vfio", - target_compatible_with = ["@platforms//os:linux"], - deps = all_crate_deps(normal_dev = True), -) - -rustfmt_test( - name = "rustfmt_test", - targets = [ - ":openshell-vfio", - ":openshell-vfio_test", - ], - visibility = ["//crates:__pkg__"], -) diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000000..c0b4db8c1b --- /dev/null +++ b/deny.toml @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# cargo-deny configuration +# https://embarkstudios.github.io/cargo-deny/ + +[graph] +all-features = true +no-default-features = false + +# -- Advisories (RustSec + NVD) ------------------------------------------------ +[advisories] +yanked = "warn" +unmaintained = "workspace" +maximum-db-staleness = "P30D" +ignore = [ + # Pre-existing advisories acknowledged at onboarding. Each should be + # resolved by upgrading the affected transitive dependency and then + # removing the ignore entry. + { id = "RUSTSEC-2026-0190", reason = "anyhow unsoundness in downcast_mut — awaiting upstream fix" }, + { id = "RUSTSEC-2026-0204", reason = "crossbeam-epoch pointer deref — transitive via metrics/quanta" }, + { id = "RUSTSEC-2023-0071", reason = "rsa Marvin attack — transitive via spiffe, no direct exposure" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile unmaintained — transitive via older kube/hyper" }, + { id = "RUSTSEC-2025-0068", reason = "serde_yml unsound+unmaintained — direct dep, no maintained alternative yet" }, +] + +# -- Licenses ------------------------------------------------------------------ +[licenses] +confidence-threshold = 0.8 +unused-allowed-license = "allow" + +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "MIT-0", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "Zlib", + "0BSD", + "CC0-1.0", + "Unlicense", + "Unicode-3.0", + "CDLA-Permissive-2.0", +] + +[licenses.private] +ignore = true +registries = [] + +# -- Bans ---------------------------------------------------------------------- +[bans] +multiple-versions = "warn" +wildcards = "allow" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" + +# -- Sources ------------------------------------------------------------------- +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/deploy/docker/Dockerfile.gateway b/deploy/docker/Dockerfile.gateway index 62a55334ba..cf68dc967a 100644 --- a/deploy/docker/Dockerfile.gateway +++ b/deploy/docker/Dockerfile.gateway @@ -3,21 +3,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Gateway image build. -# -# The Rust binary is built natively before this image build runs and staged at: +# The GNU-linked gateway binary is staged at: # deploy/docker/.build/prebuilt-binaries//openshell-gateway # -# Use tasks/scripts/docker-build-image.sh gateway (or `mise run build:docker:gateway`) -# to stage the binary and build the image in one step. CI builds the binary -# per-architecture via the `rust-native-build.yml` workflow and uploads it as -# an artifact, which is downloaded into the same staging directory before the -# image build job runs. -# -# The runtime is distroless Debian 13, which provides glibc and the dynamic -# loader needed by the GNU-linked gateway binary while keeping the attack -# surface small. The default digest currently carries Debian glibc -# 2.41-12+deb13u3. +# Distroless Debian provides the glibc runtime required by the binary. ARG GATEWAY_BASE_IMAGE=gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 FROM ${GATEWAY_BASE_IMAGE} AS gateway diff --git a/deploy/docker/Dockerfile.gateway-macos b/deploy/docker/Dockerfile.gateway-macos index 122f16eba8..c7d526a039 100644 --- a/deploy/docker/Dockerfile.gateway-macos +++ b/deploy/docker/Dockerfile.gateway-macos @@ -53,6 +53,7 @@ ENV BINDGEN_EXTRA_CLANG_ARGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ - COPY Cargo.toml Cargo.lock ./ COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml +COPY crates/openshell-gateway/Cargo.toml crates/openshell-gateway/Cargo.toml COPY crates/openshell-driver-kubernetes/Cargo.toml crates/openshell-driver-kubernetes/Cargo.toml COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml @@ -61,39 +62,42 @@ COPY crates/openshell-server/Cargo.toml crates/openshell-server/Cargo.toml COPY crates/openshell-core/build.rs crates/openshell-core/build.rs COPY proto/ proto/ -RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-server", "crates/openshell-core", "crates/openshell-driver-kubernetes", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-router"]|' Cargo.toml +RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-gateway", "crates/openshell-server", "crates/openshell-core", "crates/openshell-driver-kubernetes", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-router"]|' Cargo.toml RUN mkdir -p crates/openshell-core/src \ + crates/openshell-gateway/src \ crates/openshell-driver-kubernetes/src \ crates/openshell-policy/src \ crates/openshell-prover/src \ crates/openshell-router/src \ crates/openshell-server/src && \ touch crates/openshell-core/src/lib.rs && \ + touch crates/openshell-gateway/src/lib.rs && \ + printf 'fn main() {}\n' > crates/openshell-gateway/src/main.rs && \ touch crates/openshell-driver-kubernetes/src/lib.rs && \ printf 'fn main() {}\n' > crates/openshell-driver-kubernetes/src/main.rs && \ touch crates/openshell-policy/src/lib.rs && \ touch crates/openshell-prover/src/lib.rs && \ touch crates/openshell-router/src/lib.rs && \ - touch crates/openshell-server/src/lib.rs && \ - printf 'fn main() {}\n' > crates/openshell-server/src/main.rs + touch crates/openshell-server/src/lib.rs RUN --mount=type=cache,id=cargo-registry-gateway-macos,sharing=locked,target=/root/.cargo/registry \ --mount=type=cache,id=cargo-git-gateway-macos,sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-gateway-macos-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-server --features bundled-z3 2>/dev/null || true + cargo build --release --target aarch64-apple-darwin -p openshell-gateway --features bundled-z3 2>/dev/null || true COPY crates/ crates/ COPY providers/ providers/ RUN touch crates/openshell-core/src/lib.rs \ + crates/openshell-gateway/src/lib.rs \ + crates/openshell-gateway/src/main.rs \ crates/openshell-driver-kubernetes/src/lib.rs \ crates/openshell-driver-kubernetes/src/main.rs \ crates/openshell-policy/src/lib.rs \ crates/openshell-prover/src/lib.rs \ crates/openshell-router/src/lib.rs \ crates/openshell-server/src/lib.rs \ - crates/openshell-server/src/main.rs \ crates/openshell-core/build.rs \ proto/*.proto @@ -105,7 +109,7 @@ RUN --mount=type=cache,id=cargo-registry-gateway-macos,sharing=locked,target=/ro if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - cargo build --release --target aarch64-apple-darwin -p openshell-server --features bundled-z3 && \ + cargo build --release --target aarch64-apple-darwin -p openshell-gateway --features bundled-z3 && \ cp target/aarch64-apple-darwin/release/openshell-gateway /openshell-gateway FROM scratch AS binary diff --git a/deploy/docker/Dockerfile.python-wheels b/deploy/docker/Dockerfile.python-wheels deleted file mode 100644 index fe58e3fcc7..0000000000 --- a/deploy/docker/Dockerfile.python-wheels +++ /dev/null @@ -1,115 +0,0 @@ -# syntax=docker/dockerfile:1.6 - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - - -ARG PYTHON_VERSION=3.12 - -FROM --platform=$BUILDPLATFORM python:${PYTHON_VERSION}-slim AS base - -ENV PATH="/root/.cargo/bin:${PATH}" - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - curl \ - gcc \ - libc6-dev \ - libclang-dev \ - pkg-config \ - libssl-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0 -RUN pip install --no-cache-dir maturin - -COPY deploy/docker/cross-build.sh /usr/local/bin/ - -FROM base AS builder - -ARG TARGETARCH -ARG BUILDARCH -ARG CARGO_TARGET_CACHE_SCOPE=default - -ARG SCCACHE_MEMCACHED_ENDPOINT - -WORKDIR /build - -RUN . cross-build.sh && install_cross_toolchain && install_sccache && add_rust_target - -# Copy dependency manifests first for better caching. -COPY Cargo.toml Cargo.lock ./ -COPY crates/openshell-cli/Cargo.toml crates/openshell-cli/Cargo.toml -COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml -COPY crates/openshell-ocsf/Cargo.toml crates/openshell-ocsf/Cargo.toml -COPY crates/openshell-providers/Cargo.toml crates/openshell-providers/Cargo.toml -COPY crates/openshell-router/Cargo.toml crates/openshell-router/Cargo.toml -COPY crates/openshell-sandbox/Cargo.toml crates/openshell-sandbox/Cargo.toml -COPY crates/openshell-server/Cargo.toml crates/openshell-server/Cargo.toml -COPY crates/openshell-bootstrap/Cargo.toml crates/openshell-bootstrap/Cargo.toml -COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml -COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml -COPY crates/openshell-tui/Cargo.toml crates/openshell-tui/Cargo.toml -COPY crates/openshell-core/build.rs crates/openshell-core/build.rs -COPY proto/ proto/ - -# Create dummy source files to build dependencies. -RUN mkdir -p crates/openshell-cli/src crates/openshell-core/src crates/openshell-ocsf/src crates/openshell-policy/src crates/openshell-providers/src crates/openshell-prover/src crates/openshell-router/src crates/openshell-sandbox/src crates/openshell-server/src crates/openshell-bootstrap/src crates/openshell-tui/src && \ - echo "fn main() {}" > crates/openshell-cli/src/main.rs && \ - echo "fn main() {}" > crates/openshell-sandbox/src/main.rs && \ - echo "fn main() {}" > crates/openshell-server/src/main.rs && \ - touch crates/openshell-core/src/lib.rs && \ - touch crates/openshell-ocsf/src/lib.rs && \ - touch crates/openshell-providers/src/lib.rs && \ - touch crates/openshell-router/src/lib.rs && \ - touch crates/openshell-bootstrap/src/lib.rs && \ - touch crates/openshell-policy/src/lib.rs && \ - touch crates/openshell-prover/src/lib.rs && \ - touch crates/openshell-tui/src/lib.rs - -# Build dependencies only (cached unless Cargo.toml/lock changes). -# sccache uses memcached in CI or the local disk cache mount for local dev. -# The cargo-target mount gives cargo a persistent target/ dir for incremental rebuilds. -RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - --mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \ - . cross-build.sh && cargo_cross_build --release -p openshell-cli 2>/dev/null || true - -# Copy actual source code and Python packaging files. -COPY crates/ crates/ -COPY pyproject.toml README.md ./ -COPY python/ python/ - -# Touch source files to ensure they're rebuilt (not the cached dummy). -RUN touch crates/openshell-cli/src/main.rs \ - crates/openshell-cli/src/lib.rs \ - crates/openshell-bootstrap/src/lib.rs \ - crates/openshell-core/src/lib.rs \ - crates/openshell-providers/src/lib.rs \ - crates/openshell-router/src/lib.rs \ - crates/openshell-sandbox/src/main.rs \ - crates/openshell-server/src/main.rs \ - crates/openshell-core/build.rs \ - proto/*.proto - -# Declare version ARGs here (not earlier) so the git-hash-bearing values do not -# invalidate the expensive dependency-build layers above on every commit. -ARG OPENSHELL_CARGO_VERSION -ARG OPENSHELL_IMAGE_TAG -RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - --mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \ - . cross-build.sh && \ - export_cross_env && \ - export RUSTC_WRAPPER=sccache && \ - export CARGO_BUILD_TARGET="$(rust_target)" && \ - if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ - fi && \ - maturin build --release --target "${CARGO_BUILD_TARGET}" --out /wheels - -FROM scratch AS wheels -COPY --from=builder /wheels/*.whl / diff --git a/deploy/docker/Dockerfile.python-wheels-macos b/deploy/docker/Dockerfile.python-wheels-macos deleted file mode 100644 index 1825dd6276..0000000000 --- a/deploy/docker/Dockerfile.python-wheels-macos +++ /dev/null @@ -1,126 +0,0 @@ -# syntax=docker/dockerfile:1.6 - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - - -ARG OSXCROSS_IMAGE=ghcr.io/crazy-max/osxcross:latest -ARG PYTHON_IMAGE=public.ecr.aws/docker/library/python -ARG PYTHON_VERSION=3.12 - -FROM ${OSXCROSS_IMAGE} AS osxcross - -FROM ${PYTHON_IMAGE}:${PYTHON_VERSION}-slim AS builder - -ARG TARGETARCH -ARG CARGO_TARGET_CACHE_SCOPE=default - -ENV PATH="/root/.cargo/bin:/usr/local/bin:/osxcross/bin:${PATH}" -ENV LD_LIBRARY_PATH="/osxcross/lib" - -COPY --from=osxcross /osxcross /osxcross - -RUN SDKROOT="$(echo /osxcross/SDK/MacOSX*.sdk)" && ln -sfn "${SDKROOT}" /osxcross/SDK/MacOSX.sdk - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - clang \ - cmake \ - curl \ - libclang-dev \ - libssl-dev \ - pkg-config \ - && rm -rf /var/lib/apt/lists/* - -# aws-lc-sys probes with --target=arm64-apple-macosx and clang then looks for -# arm64-apple-macosx-ld. Provide a linker alias to osxcross ld64. -RUN ln -sf /osxcross/bin/arm64-apple-darwin25.1-ld /usr/local/bin/arm64-apple-macosx-ld - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0 -RUN rustup target add aarch64-apple-darwin -RUN pip install --no-cache-dir maturin - -WORKDIR /build - -ENV CC_aarch64_apple_darwin=oa64-clang -ENV CXX_aarch64_apple_darwin=oa64-clang++ -ENV AR_aarch64_apple_darwin=aarch64-apple-darwin25.1-ar -ENV CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=oa64-clang -ENV CARGO_TARGET_AARCH64_APPLE_DARWIN_AR=aarch64-apple-darwin25.1-ar -ENV SDKROOT=/osxcross/SDK/MacOSX.sdk -ENV MACOSX_DEPLOYMENT_TARGET=13.3 -ENV CFLAGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ -mmacosx-version-min=13.3 -ENV CXXFLAGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ -mmacosx-version-min=13.3 -ENV BINDGEN_EXTRA_CLANG_ARGS_aarch64_apple_darwin=--target=arm64-apple-macosx\ -isysroot\ ${SDKROOT} - -# Copy dependency manifests first for better caching. -COPY Cargo.toml Cargo.lock ./ -COPY crates/openshell-cli/Cargo.toml crates/openshell-cli/Cargo.toml -COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml -COPY crates/openshell-ocsf/Cargo.toml crates/openshell-ocsf/Cargo.toml -COPY crates/openshell-providers/Cargo.toml crates/openshell-providers/Cargo.toml -COPY crates/openshell-router/Cargo.toml crates/openshell-router/Cargo.toml -COPY crates/openshell-sandbox/Cargo.toml crates/openshell-sandbox/Cargo.toml -COPY crates/openshell-server/Cargo.toml crates/openshell-server/Cargo.toml -COPY crates/openshell-bootstrap/Cargo.toml crates/openshell-bootstrap/Cargo.toml -COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml -COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml -COPY crates/openshell-tui/Cargo.toml crates/openshell-tui/Cargo.toml -COPY crates/openshell-core/build.rs crates/openshell-core/build.rs -COPY proto/ proto/ - -# Create dummy source files to build dependencies. -RUN mkdir -p crates/openshell-cli/src crates/openshell-core/src crates/openshell-ocsf/src crates/openshell-policy/src crates/openshell-providers/src crates/openshell-prover/src crates/openshell-router/src crates/openshell-sandbox/src crates/openshell-server/src crates/openshell-bootstrap/src crates/openshell-tui/src && \ - echo "fn main() {}" > crates/openshell-cli/src/main.rs && \ - echo "fn main() {}" > crates/openshell-sandbox/src/main.rs && \ - echo "fn main() {}" > crates/openshell-server/src/main.rs && \ - touch crates/openshell-core/src/lib.rs && \ - touch crates/openshell-ocsf/src/lib.rs && \ - touch crates/openshell-providers/src/lib.rs && \ - touch crates/openshell-router/src/lib.rs && \ - touch crates/openshell-bootstrap/src/lib.rs && \ - touch crates/openshell-policy/src/lib.rs && \ - touch crates/openshell-prover/src/lib.rs && \ - touch crates/openshell-tui/src/lib.rs - -# Build dependencies only (cached unless Cargo.toml/lock changes). -RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli 2>/dev/null || true - -# Copy actual source code and Python packaging files. -COPY crates/ crates/ -COPY providers/ providers/ -COPY pyproject.toml README.md ./ -COPY python/ python/ - -# Touch source files to ensure they're rebuilt (not the cached dummy). -# Touch build.rs and proto files to force proto code regeneration when the -# cargo target cache mount retains stale OUT_DIR artifacts from prior builds. -RUN touch crates/openshell-cli/src/main.rs \ - crates/openshell-cli/src/lib.rs \ - crates/openshell-bootstrap/src/lib.rs \ - crates/openshell-core/src/lib.rs \ - crates/openshell-providers/src/lib.rs \ - crates/openshell-router/src/lib.rs \ - crates/openshell-sandbox/src/main.rs \ - crates/openshell-server/src/main.rs \ - crates/openshell-core/build.rs \ - proto/*.proto - -# Declare version ARGs here (not earlier) so the git-hash-bearing values do not -# invalidate the expensive dependency-build layers above on every commit. -ARG OPENSHELL_CARGO_VERSION -ARG OPENSHELL_IMAGE_TAG -RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ - --mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ - --mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ - sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ - fi && \ - maturin build --release --target aarch64-apple-darwin --out /wheels - -FROM scratch AS wheels -COPY --from=builder /wheels/*.whl / diff --git a/deploy/docker/Dockerfile.supervisor b/deploy/docker/Dockerfile.supervisor index c760bbc890..d515fd70b1 100644 --- a/deploy/docker/Dockerfile.supervisor +++ b/deploy/docker/Dockerfile.supervisor @@ -3,21 +3,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Supervisor image build. -# -# The final image carries the static `openshell-sandbox` binary used by Docker -# extraction, Podman image volumes, and the Kubernetes init container copy-self -# path. It also includes nftables so the Kubernetes supervisor sidecar can -# install pod-namespace egress enforcement rules. -# -# The Rust binary is built natively before this image build runs and staged at: +# The static sandbox binary is staged at: # deploy/docker/.build/prebuilt-binaries//openshell-sandbox # -# Use tasks/scripts/docker-build-image.sh supervisor (or `mise run build:docker:supervisor`) -# to stage the binary and build the image in one step. CI builds the binary -# per-architecture via the `rust-native-build.yml` workflow (with the musl -# target) and uploads it as an artifact, which is downloaded into the same -# staging directory before the image build job runs. +# Alpine supplies nftables and iptables for pod-namespace egress enforcement. FROM alpine:3.22 AS supervisor @@ -25,10 +14,8 @@ ARG TARGETARCH RUN apk add --no-cache nftables iptables iptables-legacy -# --chmod=0555 restores execute bits after the actions/upload-artifact + -# download-artifact roundtrip strips them. Ownership stays root (0:0) for -# Podman image-volume mounts, while world-execute lets the Kubernetes -# network sidecar run this binary as the dedicated non-root proxy UID. +# Keep the binary root-owned for Podman image-volume mounts and executable by +# the Kubernetes network sidecar's non-root proxy UID. COPY --chmod=0555 deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-sandbox /openshell-sandbox ENTRYPOINT ["/openshell-sandbox"] diff --git a/deploy/helm/openshell-workspace/Chart.yaml b/deploy/helm/openshell-workspace/Chart.yaml new file mode 100644 index 0000000000..03a3919d46 --- /dev/null +++ b/deploy/helm/openshell-workspace/Chart.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v2 +name: openshell-workspace +description: Namespace-scoped prerequisites for OpenShell Kubernetes sandboxes +type: application +version: 0.0.0 +appVersion: "0.0.0" diff --git a/deploy/helm/openshell-workspace/README.md b/deploy/helm/openshell-workspace/README.md new file mode 100644 index 0000000000..96ae4ca6e4 --- /dev/null +++ b/deploy/helm/openshell-workspace/README.md @@ -0,0 +1,47 @@ +# OpenShell Workspace Helm Chart + + + +> **Experimental** - the shared-gateway, multi-namespace deployment path is +> under active design. + +This chart installs the namespace-scoped ServiceAccount, RBAC, and NetworkPolicy +needed for OpenShell Kubernetes sandboxes. Install it once in every +platform-managed workspace namespace. It does not create a namespace or deploy +an OpenShell gateway. + +Install the gateway chart with `workspaceResources.enabled=false`, then install +this chart with the gateway ServiceAccount identity. Configure the gateway's +Kubernetes driver in `operator` workspace mode when it serves more than one +pre-provisioned workspace namespace: + +```shell +helm install openshell-workspace ./deploy/helm/openshell-workspace \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell +``` + +Keep `sandboxServiceAccount.name` aligned with the gateway chart's +`sandboxServiceAccount.name`. The defaults for both charts are +`openshell-sandbox`. + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| fullnameOverride | string | `""` | Override the full generated resource name. | +| gateway.networkPolicy.podSelector | object | `{"app.kubernetes.io/instance":"openshell","app.kubernetes.io/name":"openshell"}` | Labels selecting gateway pods allowed to reach sandbox SSH. | +| gateway.serviceAccount.name | string | `"openshell"` | Name of the shared gateway ServiceAccount. | +| gateway.serviceAccount.namespace | string | `"openshell"` | Namespace containing the shared gateway ServiceAccount. | +| nameOverride | string | `""` | Override the chart name used in generated resource names. | +| networkPolicy.enabled | bool | `true` | Restrict sandbox SSH ingress to the shared gateway pods. | +| sandboxServiceAccount.annotations | object | `{}` | Annotations added to the generated sandbox ServiceAccount. | +| sandboxServiceAccount.create | bool | `true` | Create the ServiceAccount assigned to sandbox pods. | +| sandboxServiceAccount.name | string | `"openshell-sandbox"` | Sandbox ServiceAccount name. | + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/deploy/helm/openshell-workspace/README.md.gotmpl b/deploy/helm/openshell-workspace/README.md.gotmpl new file mode 100644 index 0000000000..a233eef165 --- /dev/null +++ b/deploy/helm/openshell-workspace/README.md.gotmpl @@ -0,0 +1,33 @@ +# OpenShell Workspace Helm Chart + + + +> **Experimental** - the shared-gateway, multi-namespace deployment path is +> under active design. + +This chart installs the namespace-scoped ServiceAccount, RBAC, and NetworkPolicy +needed for OpenShell Kubernetes sandboxes. Install it once in every +platform-managed workspace namespace. It does not create a namespace or deploy +an OpenShell gateway. + +Install the gateway chart with `workspaceResources.enabled=false`, then install +this chart with the gateway ServiceAccount identity. Configure the gateway's +Kubernetes driver in `operator` workspace mode when it serves more than one +pre-provisioned workspace namespace: + +```shell +helm install openshell-workspace ./deploy/helm/openshell-workspace \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell +``` + +Keep `sandboxServiceAccount.name` aligned with the gateway chart's +`sandboxServiceAccount.name`. The defaults for both charts are +`openshell-sandbox`. + +{{ template "chart.valuesSection" . }} +{{ template "helm-docs.versionFooter" . }} diff --git a/deploy/helm/openshell-workspace/templates/_helpers.tpl b/deploy/helm/openshell-workspace/templates/_helpers.tpl new file mode 100644 index 0000000000..eb948b9907 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/_helpers.tpl @@ -0,0 +1,44 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "openshell-workspace.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "openshell-workspace.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "openshell-workspace.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +app.kubernetes.io/name: {{ include "openshell-workspace.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Validate required cross-namespace gateway identity values. +*/}} +{{- define "openshell-workspace.validateValues" -}} +{{- $gatewayServiceAccountName := required "gateway.serviceAccount.name is required" .Values.gateway.serviceAccount.name -}} +{{- $gatewayServiceAccountNamespace := required "gateway.serviceAccount.namespace is required" .Values.gateway.serviceAccount.namespace -}} +{{- $sandboxServiceAccountName := required "sandboxServiceAccount.name is required" .Values.sandboxServiceAccount.name -}} +{{- end }} diff --git a/deploy/helm/openshell-workspace/templates/networkpolicy.yaml b/deploy/helm/openshell-workspace/templates/networkpolicy.yaml new file mode 100644 index 0000000000..f88cf84d04 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openshell-workspace.fullname" . }}-sandbox-ssh + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + openshell.ai/managed-by: openshell + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.gateway.serviceAccount.namespace }} + podSelector: + matchLabels: + {{- toYaml .Values.gateway.networkPolicy.podSelector | nindent 14 }} + ports: + - protocol: TCP + port: 2222 +{{- end }} diff --git a/deploy/helm/openshell-workspace/templates/role.yaml b/deploy/helm/openshell-workspace/templates/role.yaml new file mode 100644 index 0000000000..45b3beb831 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/role.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "openshell-workspace.fullname" . }}-sandbox + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} +rules: + - apiGroups: + - agents.x-k8s.io + resources: + - sandboxes + - sandboxes/status + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - events + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - pods + verbs: + - get diff --git a/deploy/helm/openshell-workspace/templates/rolebinding.yaml b/deploy/helm/openshell-workspace/templates/rolebinding.yaml new file mode 100644 index 0000000000..669ec47628 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/rolebinding.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "openshell-workspace.fullname" . }}-sandbox + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "openshell-workspace.fullname" . }}-sandbox +subjects: + - kind: ServiceAccount + name: {{ .Values.gateway.serviceAccount.name }} + namespace: {{ .Values.gateway.serviceAccount.namespace }} diff --git a/deploy/helm/openshell-workspace/templates/serviceaccount.yaml b/deploy/helm/openshell-workspace/templates/serviceaccount.yaml new file mode 100644 index 0000000000..20bb263b54 --- /dev/null +++ b/deploy/helm/openshell-workspace/templates/serviceaccount.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- include "openshell-workspace.validateValues" . }} +{{- if .Values.sandboxServiceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.sandboxServiceAccount.name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell-workspace.labels" . | nindent 4 }} + {{- with .Values.sandboxServiceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/openshell-workspace/tests/workspace_test.yaml b/deploy/helm/openshell-workspace/tests/workspace_test.yaml new file mode 100644 index 0000000000..2f71920eaf --- /dev/null +++ b/deploy/helm/openshell-workspace/tests/workspace_test.yaml @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: workspace namespace resources +templates: + - templates/serviceaccount.yaml + - templates/role.yaml + - templates/rolebinding.yaml + - templates/networkpolicy.yaml +release: + name: tenant-a + namespace: app-a + +tests: + - it: creates the workspace role in the release namespace + template: templates/role.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.namespace + value: app-a + + - it: binds the shared gateway service account + template: templates/rolebinding.yaml + set: + gateway.serviceAccount.name: shared-gateway + gateway.serviceAccount.namespace: openshell-system + asserts: + - equal: + path: metadata.namespace + value: app-a + - equal: + path: subjects[0].name + value: shared-gateway + - equal: + path: subjects[0].namespace + value: openshell-system + + - it: selects gateway pods in the gateway namespace + template: templates/networkpolicy.yaml + set: + gateway.serviceAccount.namespace: openshell-system + gateway.networkPolicy.podSelector: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: central + asserts: + - equal: + path: spec.ingress[0].from[0].namespaceSelector.matchLabels["kubernetes.io/metadata.name"] + value: openshell-system + - equal: + path: spec.ingress[0].from[0].podSelector.matchLabels["app.kubernetes.io/instance"] + value: central + + - it: supports a pre-existing sandbox service account + template: templates/serviceaccount.yaml + set: + sandboxServiceAccount.create: false + asserts: + - hasDocuments: + count: 0 diff --git a/deploy/helm/openshell-workspace/values.yaml b/deploy/helm/openshell-workspace/values.yaml new file mode 100644 index 0000000000..2c52b0460d --- /dev/null +++ b/deploy/helm/openshell-workspace/values.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# -- Override the chart name used in generated resource names. +nameOverride: "" +# -- Override the full generated resource name. +fullnameOverride: "" + +gateway: + serviceAccount: + # -- Name of the shared gateway ServiceAccount. + name: openshell + # -- Namespace containing the shared gateway ServiceAccount. + namespace: openshell + networkPolicy: + # -- Labels selecting gateway pods allowed to reach sandbox SSH. + podSelector: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: openshell + +sandboxServiceAccount: + # -- Create the ServiceAccount assigned to sandbox pods. + create: true + # -- Sandbox ServiceAccount name. + name: openshell-sandbox + # -- Annotations added to the generated sandbox ServiceAccount. + annotations: {} + +networkPolicy: + # -- Restrict sandbox SSH ingress to the shared gateway pods. + enabled: true diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index d4310cb9a7..fb3161a604 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -9,6 +9,14 @@ Edit README.md.gotmpl and values.yaml, then run `mise run helm:docs`. This chart deploys the OpenShell gateway into a Kubernetes cluster. It is published as an OCI artifact to GHCR at `oci://ghcr.io/nvidia/openshell/helm-chart`. +By default, this chart also creates the namespace-scoped resources needed by +sandboxes. For a shared-gateway deployment, install it with +`workspaceResources.enabled=false`, then install the +`deploy/helm/openshell-workspace` chart in every pre-provisioned workspace +namespace. The gateway and workspace releases can then be upgraded and removed +independently. Use Kubernetes `operator` workspace mode when one gateway serves +multiple pre-provisioned workspace namespaces. + ## Prerequisites The Kubernetes Agent Sandbox CRDs and controller must be installed on the cluster before deploying OpenShell. Install them with: @@ -17,6 +25,13 @@ The Kubernetes Agent Sandbox CRDs and controller must be installed on the cluste kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml ``` +The chart does not install this cluster-scoped dependency. By default, it +fails before creating gateway resources when the cluster serves neither +supported Sandbox API (`agents.x-k8s.io/v1beta1` or +`agents.x-k8s.io/v1alpha1`). Disable the check with +`agentSandbox.preflight.enabled=false` for offline `helm template` rendering, +where Helm cannot discover cluster APIs. + ## Install on Kubernetes ```shell @@ -101,6 +116,18 @@ gateways. `workload.kind=statefulset` is still available for single-replica SQLite installs and for operators who explicitly need StatefulSet identity or storage semantics. +### Credential storage + +By default, the chart uses the gateway's encrypted database credential storage. +The gateway writes encrypted provider credential envelopes to the OpenShell +database. The chart creates a retained Kubernetes Secret with the shared +key-encryption key and injects that key into every gateway pod, so the same +default works for single-replica and external database-backed HA deployments. + +Use `kubernetes-secrets` or `vault` instead when credentials should live in a +cluster or external secret backend. Enabling one external credential driver +disables the default credential-storage key-encryption key Secret and env injection. + #### OpenShift Append these flags to any of the PostgreSQL commands above for OpenShift: @@ -127,27 +154,34 @@ JWT signing Secret. ## SPIFFE/SPIRE provider token grants -Set `server.providerTokenGrants.spiffe.enabled=true` to let sandbox supervisors -use SPIFFE JWT-SVIDs for dynamic provider token grants. The chart keeps -supervisor-to-gateway authentication on gateway-minted sandbox JWTs and passes -the SPIFFE Workload API socket path to the Kubernetes driver so sandbox pods can -mount the SPIFFE CSI socket. +Set `server.providerTokenGrants.spiffe.enabled=true` to let the gateway and +sandbox supervisors use SPIFFE JWT-SVIDs for dynamic provider token grants. The +chart keeps supervisor-to-gateway authentication on gateway-minted sandbox JWTs, +mounts the SPIFFE CSI socket into the gateway pod, exports +`OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`, and passes the socket path to +the Kubernetes driver so sandbox pods can mount the same socket. For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and add `ci/values-spire.yaml` to the OpenShell release values files. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this path does not require access to the SPIRE OIDC +discovery endpoint or its TLS CA. + ## Values | Key | Type | Default | Description | |-----|------|---------|-------------| | affinity | object | `{}` | Affinity rules for the gateway pod. | +| agentSandbox.preflight.enabled | bool | `true` | Check the live cluster for a supported Agent Sandbox API before rendering gateway resources. Disable only for offline rendering and linting. | | certManager.caSecretName | string | `"openshell-ca-tls"` | Secret created for the intermediate CA (Certificate with isCA: true). | | certManager.certificateDuration | string | `"8760h"` | Duration for cert-manager-issued certificates. | | certManager.certificateRenewBefore | string | `"720h"` | Renewal window for cert-manager-issued certificates. | -| certManager.clientCaFromServerTlsSecret | bool | `true` | Mount gateway client CA from the server TLS secret's ca.crt (populated by cert-manager for certs issued by a CA Issuer). Avoids a separate openshell-server-client-ca Secret. | +| certManager.clientCaFromServerTlsSecret | bool | `true` | Mount gateway client CA from the internal server TLS secret's ca.crt. The internal server certificate is always signed by the chart CA — the same CA that signs the client (mTLS) certificate — so the default (true) is correct for all configurations, including when serverIssuerRef is set. Only set to false if you mount the client CA from a separate secret via server.tls.clientCaSecretName. | | certManager.enabled | bool | `false` | Create cert-manager Issuer and Certificate resources. When enabled, cert-manager owns TLS and the chart runs a JWT-only certgen hook to create the sandbox JWT signing Secret that cert-manager does not manage. | | certManager.serverDnsNames | list | `["openshell","openshell.openshell.svc","openshell.openshell.svc.cluster.local","localhost","openshell.localhost","*.openshell.localhost","host.docker.internal"]` | DNS SANs on the cert-manager-issued server certificate. | | certManager.serverIpAddresses | list | `["127.0.0.1"]` | IP SANs on the cert-manager-issued server certificate. | +| certManager.serverIssuerRef | object | `{"group":"","kind":"","name":""}` | Override the issuerRef for the external server Certificate (e.g. a real ACME ClusterIssuer for a publicly-trusted cert on an external hostname). When set, the chart creates a second server certificate from this issuer with only the hostnames in serverDnsNames; the internal server certificate is always signed by the chart's own CA. Leave name empty to use the chart CA for all server certificates (default). Requires certManager.enabled=true. | | fullnameOverride | string | `""` | Override the full generated resource name. | | grpcRoute.enabled | bool | `false` | Create a Gateway API GRPCRoute for the gateway service. | | grpcRoute.gateway.className | string | `"eg"` | GatewayClass to reference. Envoy Gateway installs one named "eg". | @@ -164,8 +198,11 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | image.tag | string | `""` | Gateway image tag. Defaults to the chart appVersion when empty. | | imagePullSecrets | list | `[]` | Image pull secrets attached to gateway and helper pods. | | nameOverride | string | `"openshell"` | Override the chart name used in generated resource names. | -| networkPolicy.enabled | bool | `true` | Create a NetworkPolicy restricting SSH ingress on sandbox pods to the gateway. | +| networkPolicy.enabled | bool | `true` | Restrict SSH ingress on sandbox pods to the gateway. In managed mode, the driver applies the equivalent policy to each workspace namespace. | | nodeSelector | object | `{}` | Node selector for the gateway pod. | +| openshiftRoute.annotations | object | `{}` | Extra annotations on the Route (e.g. haproxy.router.openshift.io/*). | +| openshiftRoute.enabled | bool | `false` | Create an OpenShift Route with TLS passthrough. | +| openshiftRoute.host | string | `""` | Hostname for the Route. Must match a SAN on the gateway's server cert. | | pkiInitJob.enabled | bool | `true` | Run a pre-install/pre-upgrade Job that creates gateway and client mTLS Secrets. When certManager.enabled=true, cert-manager owns TLS and this same hook runs in JWT-only mode even if pkiInitJob.enabled remains true. | | pkiInitJob.serverDnsNames | list | `[]` | Extra DNS SANs to append to the server certificate. | | pkiInitJob.serverIpAddresses | list | `[]` | Extra IP SANs to append to the server certificate. | @@ -195,9 +232,27 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | securityContext.runAsUser | int | `1000` | UID assigned to the gateway container. | | server.appArmorProfile | string | `"Unconfined"` | Kubernetes AppArmor profile requested for sandbox agent containers. Default Unconfined avoids runtime/default AppArmor blocking the supervisor's network namespace mount setup on AppArmor-enabled nodes. Set to "" to omit the field, "RuntimeDefault" to force the runtime default profile, or "Localhost/profile-name" for an operator-managed localhost profile. | | server.auth.allowUnauthenticatedUsers | bool | `false` | UNSAFE: accept unauthenticated CLI/user requests as a local developer principal. Intended only for trusted local Skaffold/k3d development or a fully trusted fronting proxy. Leave false for shared or production clusters. | +| server.credentialDrivers.kubernetesSecrets.allowReferenceNamespace | bool | `false` | Deprecated compatibility field. Credential storage no longer supports user-authored namespace references. | +| server.credentialDrivers.kubernetesSecrets.enabled | bool | `false` | Enable the in-tree Kubernetes Secret credential driver. WARNING: The RBAC Role grants read/write access to ALL Secrets in the configured namespace. Use a dedicated namespace to limit blast radius. | +| server.credentialDrivers.kubernetesSecrets.namespace | string | `""` | Namespace where OpenShell-managed provider Secret objects are stored. Empty = Helm release namespace. A dedicated namespace is RECOMMENDED to isolate OpenShell-managed Secrets from other workloads. | +| server.credentialDrivers.kubernetesSecrets.rbac.create | bool | `true` | Create a Role/RoleBinding granting the gateway ServiceAccount read/write access to managed provider Secrets. | +| server.credentialDrivers.vault.address | string | `""` | Vault service base URL, for example http://vault.vault.svc.cluster.local:8200. | +| server.credentialDrivers.vault.authMethod | string | `"kubernetes"` | Authentication method. Use "kubernetes" in-cluster or "token_file" for local/dev validation. | +| server.credentialDrivers.vault.enabled | bool | `false` | Enable the in-tree Vault credential driver. | +| server.credentialDrivers.vault.kubernetesAuthMount | string | `"kubernetes"` | Vault Kubernetes auth mount. | +| server.credentialDrivers.vault.kvVersion | string | `"2"` | Default KV engine version. Use "1" or "2". | +| server.credentialDrivers.vault.mount | string | `"secret"` | Default KV mount name. | +| server.credentialDrivers.vault.role | string | `""` | Vault Kubernetes auth role when authMethod is kubernetes. | +| server.credentialDrivers.vault.serviceAccountTokenPath | string | `"/var/run/secrets/kubernetes.io/serviceaccount/token"` | ServiceAccount token path used for Kubernetes auth. | +| server.credentialDrivers.vault.timeoutSecs | string | `""` | HTTP request timeout in seconds. Empty = driver default. | +| server.credentialDrivers.vault.tokenPath | string | `""` | Mounted token file path when authMethod is token_file. | +| server.credentialStorage.existingSecret | string | `""` | Name of a pre-existing Secret containing the key-encryption key. When set, the chart does NOT generate a new Secret; it references this one instead. The Secret must contain a key named "key-encryption-key" with a base64-encoded 32-byte value. Required for GitOps workflows that render manifests with `helm template` (where `lookup` is unavailable). | | server.dbUrl | string | `"sqlite:/var/openshell/openshell.db"` | Gateway database URL (used for the default SQLite backend). | | server.defaultRuntimeClassName | string | `""` | Default Kubernetes runtimeClassName for sandbox pods. Applied when a CreateSandbox request does not specify one. Empty (default) = omit the field, using the cluster's default RuntimeClass. Set to a RuntimeClass name (e.g. "kata-containers", "nvidia") to apply it to all sandboxes that don't explicitly override it. | | server.disableTls | bool | `false` | Disable TLS entirely - the server listens on plaintext HTTP. Set to true when a reverse proxy / tunnel terminates TLS at the edge. | +| server.drivers.kubernetes.operatorNamespaceFile | string | `""` | Path to a JSON file containing an array of namespace names allowed in operator mode. Hot-reloaded on change. | +| server.drivers.kubernetes.operatorNamespaceLabel | string | `""` | K8s label selector for namespace discovery in operator mode. The driver watches namespaces matching this label. | +| server.drivers.kubernetes.workspaceMode | string | `"shared"` | How workspaces map to Kubernetes namespaces. "shared" (default): all sandboxes in a single namespace. "managed": auto-creates per-workspace namespaces. "operator": uses pre-provisioned namespaces. | | server.enableLoopbackServiceHttp | bool | `true` | Enable plaintext HTTP routing for loopback sandbox service URLs on TLS-enabled gateways. | | server.enableUserNamespaces | bool | `false` | Enable Kubernetes user namespace isolation (hostUsers: false) for sandbox pods. Requires Kubernetes 1.33+ with user namespace support available (beta through 1.35, GA in 1.36+), plus a supporting container runtime and Linux 5.12+. When enabled, container UID 0 maps to an unprivileged host UID and capabilities become namespaced. | | server.externalDbSecret | string | `""` | Name of a pre-existing Opaque Secret containing a PostgreSQL connection URI (key: uri). When set, the gateway reads OPENSHELL_DB_URL from this Secret instead of using dbUrl. The Secret must contain a `uri` key, e.g. postgresql://user:pass@host:5432/dbname. | @@ -206,17 +261,20 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | server.grpcRateLimit.windowSeconds | int | `0` | gRPC rate-limit window length in seconds. Must be positive (alongside requests) to enable rate limiting; 0 (default) disables it. | | server.hostGatewayIP | string | `""` | Host gateway IP for sandbox pod hostAliases. When set, sandbox pods get hostAliases entries mapping host.docker.internal and host.openshell.internal to this IP, allowing them to reach services running on the Docker host. Auto-detected by the cluster entrypoint script. | | server.logLevel | string | `"info"` | Gateway log level. | +| server.name | string | `""` | Operator-facing gateway name. Defaults to the chart fullname so all replicas in one installation share an identity. Set explicitly when one telemetry collector receives spans from multiple namespaces or clusters. | | server.oidc.adminRole | string | `""` | Role name for admin access. Leave empty (with userRole also empty) for authentication-only mode. Both must be set or both empty. | | server.oidc.audience | string | `"openshell-cli"` | Expected audience claim for the API resource server. This should match the server's --oidc-audience, NOT the CLI client ID. | | server.oidc.caConfigMapName | string | `""` | Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) for verifying the OIDC issuer's TLS certificate. Required when the issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). | | server.oidc.issuer | string | `""` | OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell). | -| server.oidc.jwksTtl | int | `3600` | JWKS key cache TTL in seconds. | +| server.oidc.jwksTtl | int | `3600` | JWKS key cache TTL in seconds. Must be greater than zero. | | server.oidc.rolesClaim | string | `""` | Dot-separated path to the roles array in the JWT claims. Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". | | server.oidc.scopesClaim | string | `""` | Dot-separated path to the scopes array in the JWT claims. | | server.oidc.userRole | string | `""` | Role name for standard user access. | +| server.otlp.endpoint | string | `""` | OTLP/gRPC collector endpoint, conventionally using port 4317. | +| server.otlp.serviceName | string | `""` | Gateway OpenTelemetry service name. Empty uses openshell-gateway. | | server.policyValidationFailureMode | string | `"fail_closed"` | Posture when a candidate sandbox policy fails validation. `fail_closed` deactivates the previous policy; `retain_last_valid` keeps it active. | -| server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. | -| server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into sandbox pods. | +| server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. | +| server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. | | server.sandboxImage | string | `"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"` | Default sandbox image used when requests do not specify one. | | server.sandboxImagePullPolicy | string | `""` | Kubernetes imagePullPolicy for sandbox pods. Empty = Kubernetes default (Always for :latest, IfNotPresent otherwise). Set to "Always" for dev clusters so new images are picked up without manual eviction. | | server.sandboxImagePullSecrets | list | `[]` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | @@ -226,8 +284,9 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | server.sandboxJwt.signingSecretName | string | `""` | Name of the Opaque Secret holding the signing key material. Empty falls back to the chart fullname with "-jwt-keys" appended. | | server.sandboxJwt.ttlSecs | int | `3600` | Token TTL in seconds. Defaults to 3600 (1h). | | server.sandboxNamespace | string | `""` | Namespace where sandbox pods are created. Defaults to the Helm release namespace (.Release.Namespace) when left empty. | +| server.telemetryEnabled | bool | `true` | Enable anonymous OpenShell telemetry from the gateway and the sandbox supervisors it launches. | | server.tls.certSecretName | string | `"openshell-server-tls"` | K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. | -| server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). | +| server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). Do not set to null; omit the key to use the default secret name above. | | server.tls.clientTlsSecretName | string | `"openshell-client-tls"` | K8s secret mounted into sandbox pods for mTLS to the server. | | server.workspaceDefaultStorageSize | string | `""` | Default storage size for the workspace PVC in sandbox pods. Uses Kubernetes quantity syntax (e.g. "2Gi", "10Gi", "500Mi"). Empty = built-in default (2Gi). | | server.workspaceStorageClass | string | `""` | Kubernetes StorageClass for the workspace PVC in sandbox pods. Empty (default) = omit storageClassName, using the cluster's default StorageClass. Set this on clusters with no default StorageClass, otherwise the workspace PVC stays Pending and the sandbox never starts. | @@ -246,8 +305,16 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | supervisor.sideloadMethod | string | `""` | How the supervisor binary is delivered into sandbox pods. Empty (default) = auto-detect from cluster version: K8s >= v1.35 -> "image-volume" (ImageVolume enabled by default; GA in v1.36) K8s < v1.35 -> "init-container" (copies via init container + emptyDir) On K8s v1.33-v1.34 with the ImageVolume feature gate manually enabled, set this to "image-volume" explicitly. | | supervisor.topology | string | `"combined"` | Supervisor pod topology for Kubernetes sandboxes. "combined" runs the current single supervisor container in the agent pod. "sidecar" runs network enforcement in a dedicated sidecar and the process supervisor as a low-capability wrapper in the agent container. | | tolerations | list | `[]` | Tolerations for the gateway pod. | +| upstreamProxy | object | `{"authAllowInsecure":false,"authSecret":{"key":"","name":""},"connectByHostname":false,"noProxy":"","url":""}` | Operator-owned corporate forward proxy for policy-approved TLS egress from Kubernetes sandboxes. The workload cannot select or override it. | +| upstreamProxy.authAllowInsecure | bool | `false` | Required when authSecret is configured because Basic auth to an HTTP proxy is cleartext. | +| upstreamProxy.authSecret.key | string | `""` | Secret key containing the proxy credential. | +| upstreamProxy.authSecret.name | string | `""` | Existing Secret in the sandbox namespace containing a user:pass value. | +| upstreamProxy.connectByHostname | bool | `false` | Last-resort option for hostname-filtering proxy ACLs. It lets the proxy resolve CONNECT targets. | +| upstreamProxy.noProxy | string | `""` | Comma-separated destinations that bypass only the corporate proxy. | +| upstreamProxy.url | string | `""` | HTTP proxy URL in http://host:port form. HTTPS-to-proxy is not supported. | | workload.allowMultiReplicaStatefulSet | bool | `false` | Allow replicaCount > 1 while rendering a StatefulSet. Prefer workload.kind=deployment for external database-backed multi-replica gateways; this override exists for operators who explicitly require StatefulSet identity or storage semantics. | | workload.kind | string | `"statefulset"` | Gateway workload controller kind. Use `statefulset` for the default SQLite database, or `deployment` when server.externalDbSecret points at an external database. | +| workspaceResources.enabled | bool | `true` | Create the sandbox ServiceAccount, Role, RoleBinding, and NetworkPolicy from this chart. Disable for a gateway-only release. | ---------------------------------------------- Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/deploy/helm/openshell/README.md.gotmpl b/deploy/helm/openshell/README.md.gotmpl index e247842a1a..cf8677741e 100644 --- a/deploy/helm/openshell/README.md.gotmpl +++ b/deploy/helm/openshell/README.md.gotmpl @@ -9,6 +9,14 @@ Edit README.md.gotmpl and values.yaml, then run `mise run helm:docs`. This chart deploys the OpenShell gateway into a Kubernetes cluster. It is published as an OCI artifact to GHCR at `oci://ghcr.io/nvidia/openshell/helm-chart`. +By default, this chart also creates the namespace-scoped resources needed by +sandboxes. For a shared-gateway deployment, install it with +`workspaceResources.enabled=false`, then install the +`deploy/helm/openshell-workspace` chart in every pre-provisioned workspace +namespace. The gateway and workspace releases can then be upgraded and removed +independently. Use Kubernetes `operator` workspace mode when one gateway serves +multiple pre-provisioned workspace namespaces. + ## Prerequisites The Kubernetes Agent Sandbox CRDs and controller must be installed on the cluster before deploying OpenShell. Install them with: @@ -17,6 +25,13 @@ The Kubernetes Agent Sandbox CRDs and controller must be installed on the cluste kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml ``` +The chart does not install this cluster-scoped dependency. By default, it +fails before creating gateway resources when the cluster serves neither +supported Sandbox API (`agents.x-k8s.io/v1beta1` or +`agents.x-k8s.io/v1alpha1`). Disable the check with +`agentSandbox.preflight.enabled=false` for offline `helm template` rendering, +where Helm cannot discover cluster APIs. + ## Install on Kubernetes ```shell @@ -101,6 +116,18 @@ gateways. `workload.kind=statefulset` is still available for single-replica SQLite installs and for operators who explicitly need StatefulSet identity or storage semantics. +### Credential storage + +By default, the chart uses the gateway's encrypted database credential storage. +The gateway writes encrypted provider credential envelopes to the OpenShell +database. The chart creates a retained Kubernetes Secret with the shared +key-encryption key and injects that key into every gateway pod, so the same +default works for single-replica and external database-backed HA deployments. + +Use `kubernetes-secrets` or `vault` instead when credentials should live in a +cluster or external secret backend. Enabling one external credential driver +disables the default credential-storage key-encryption key Secret and env injection. + #### OpenShift Append these flags to any of the PostgreSQL commands above for OpenShift: @@ -127,14 +154,19 @@ JWT signing Secret. ## SPIFFE/SPIRE provider token grants -Set `server.providerTokenGrants.spiffe.enabled=true` to let sandbox supervisors -use SPIFFE JWT-SVIDs for dynamic provider token grants. The chart keeps -supervisor-to-gateway authentication on gateway-minted sandbox JWTs and passes -the SPIFFE Workload API socket path to the Kubernetes driver so sandbox pods can -mount the SPIFFE CSI socket. +Set `server.providerTokenGrants.spiffe.enabled=true` to let the gateway and +sandbox supervisors use SPIFFE JWT-SVIDs for dynamic provider token grants. The +chart keeps supervisor-to-gateway authentication on gateway-minted sandbox JWTs, +mounts the SPIFFE CSI socket into the gateway pod, exports +`OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`, and passes the socket path to +the Kubernetes driver so sandbox pods can mount the same socket. For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and add `ci/values-spire.yaml` to the OpenShell release values files. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this path does not require access to the SPIRE OIDC +discovery endpoint or its TLS CA. + {{ template "chart.valuesSection" . }} {{ template "helm-docs.versionFooter" . }} diff --git a/deploy/helm/openshell/ci/values-corporate-proxy-e2e.yaml b/deploy/helm/openshell/ci/values-corporate-proxy-e2e.yaml new file mode 100644 index 0000000000..35532440b2 --- /dev/null +++ b/deploy/helm/openshell/ci/values-corporate-proxy-e2e.yaml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# The Kubernetes corporate-proxy e2e wrapper supplies the generated proxy URL +# and creates `openshell-e2e-proxy-auth` before Helm installs the gateway. +supervisor: + topology: sidecar + +upstreamProxy: + authSecret: + name: openshell-e2e-proxy-auth + key: proxy-auth + authAllowInsecure: true diff --git a/deploy/helm/openshell/ci/values-credential-driver-kubernetes-secrets.yaml b/deploy/helm/openshell/ci/values-credential-driver-kubernetes-secrets.yaml new file mode 100644 index 0000000000..096ce46e29 --- /dev/null +++ b/deploy/helm/openshell/ci/values-credential-driver-kubernetes-secrets.yaml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Local Kubernetes Secrets credential-driver validation overlay. +# +# Use with: +# skaffold run -p credential-driver-kubernetes-secrets +# +server: + credentialDrivers: + kubernetesSecrets: + enabled: true + namespace: openshell diff --git a/deploy/helm/openshell/ci/values-credential-driver-vault.yaml b/deploy/helm/openshell/ci/values-credential-driver-vault.yaml new file mode 100644 index 0000000000..6cae3fdb50 --- /dev/null +++ b/deploy/helm/openshell/ci/values-credential-driver-vault.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Local Vault credential-driver validation overlay. +# +# Use with: +# skaffold run -p credential-driver-vault +# +# The profile assumes another process has already deployed a Vault-compatible +# backend. Local e2e validation deploys OpenBao in the `openbao` namespace with +# a Kubernetes auth role named `openshell-gateway` bound to the OpenShell +# gateway ServiceAccount in the `openshell` namespace. + +server: + credentialDrivers: + vault: + enabled: true + address: http://openbao.openbao.svc.cluster.local:8200 + role: openshell-gateway diff --git a/deploy/helm/openshell/ci/values-openshift-route-cert-manager.yaml b/deploy/helm/openshell/ci/values-openshift-route-cert-manager.yaml new file mode 100644 index 0000000000..e434e98b8e --- /dev/null +++ b/deploy/helm/openshell/ci/values-openshift-route-cert-manager.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Render-coverage overlay for cert-manager issuing the server certificate from +# an external Issuer/ClusterIssuer (e.g. a real ACME issuer), plus an +# OpenShift Route with TLS passthrough. Merge after values.yaml: +# helm lint deploy/helm/openshell -f ci/values-openshift-route-cert-manager.yaml +# +# The ClusterIssuer name below is a placeholder for render coverage; a real +# deployment must reference an Issuer/ClusterIssuer that's actually installed +# and Ready in the target cluster. See docs/kubernetes/managing-certificates.mdx. +# +# clientCaFromServerTlsSecret defaults to true and is correct here: the +# internal server certificate is always signed by the chart CA, so its +# ca.crt is exactly the CA that signed the client (mTLS) certificate. + +server: + disableTls: false + +certManager: + enabled: true + serverIssuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + serverDnsNames: + - openshell.example.com + +openshiftRoute: + enabled: true + host: openshell.example.com diff --git a/deploy/helm/openshell/ci/values-skaffold.yaml b/deploy/helm/openshell/ci/values-skaffold.yaml index 795c056f68..15ff87554e 100644 --- a/deploy/helm/openshell/ci/values-skaffold.yaml +++ b/deploy/helm/openshell/ci/values-skaffold.yaml @@ -4,6 +4,8 @@ # Merge with values.yaml for Skaffold-driven local image builds (see skaffold.yaml). server: sandboxImagePullPolicy: IfNotPresent + otlp: + endpoint: http://openshell-collector.observability.svc.cluster.local:4317 # Comment out to enforce mTLS (uses PKI secrets generated by pkiInitJob). disableTls: true auth: diff --git a/deploy/helm/openshell/ci/values-spire-stack.yaml b/deploy/helm/openshell/ci/values-spire-stack.yaml index b55f7cfc57..8a1e648829 100644 --- a/deploy/helm/openshell/ci/values-spire-stack.yaml +++ b/deploy/helm/openshell/ci/values-spire-stack.yaml @@ -15,7 +15,7 @@ spire-server: clusterSPIFFEIDs: openshell-sandboxes: enabled: true - spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/sandbox/{{ index .PodMeta.Annotations "openshell.io/sandbox-id" }}' + spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/sandbox/{{ index .PodMeta.Annotations "openshell.ai/sandbox-id" }}' namespaceSelector: matchLabels: kubernetes.io/metadata.name: openshell diff --git a/deploy/helm/openshell/ci/values-workspace-managed.yaml b/deploy/helm/openshell/ci/values-workspace-managed.yaml new file mode 100644 index 0000000000..e9f88846c4 --- /dev/null +++ b/deploy/helm/openshell/ci/values-workspace-managed.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# E2E overlay: deploy the gateway in managed workspace mode. +# Sandbox namespaces are auto-created as openshell-{gateway_id}-{workspace}. +server: + sandboxImagePullSecrets: + - name: e2e-regcred + drivers: + kubernetes: + workspaceMode: "managed" diff --git a/deploy/helm/openshell/ci/values-workspace-operator.yaml b/deploy/helm/openshell/ci/values-workspace-operator.yaml new file mode 100644 index 0000000000..8d895e4e98 --- /dev/null +++ b/deploy/helm/openshell/ci/values-workspace-operator.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# E2E overlay: deploy the gateway in operator workspace mode. +# Namespaces must be pre-provisioned and labeled before sandbox creation. +server: + drivers: + kubernetes: + workspaceMode: "operator" + operatorNamespaceLabel: "openshell.ai/e2e-operator-workspace=true" diff --git a/deploy/helm/openshell/skaffold.yaml b/deploy/helm/openshell/skaffold.yaml index 119adf086b..ce32c72132 100644 --- a/deploy/helm/openshell/skaffold.yaml +++ b/deploy/helm/openshell/skaffold.yaml @@ -143,3 +143,13 @@ profiles: path: /deploy/helm/releases/0/setValues value: server.disableTls: "false" + - name: credential-driver-kubernetes-secrets + patches: + - op: add + path: /deploy/helm/releases/0/valuesFiles/- + value: ci/values-credential-driver-kubernetes-secrets.yaml + - name: credential-driver-vault + patches: + - op: add + path: /deploy/helm/releases/0/valuesFiles/- + value: ci/values-credential-driver-vault.yaml diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 5931047e5f..54d9e1ff99 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -50,6 +50,13 @@ spec: - {{ .Values.server.dbUrl | quote }} {{- end }} env: + {{- if not (or .Values.server.credentialDrivers.kubernetesSecrets.enabled .Values.server.credentialDrivers.vault.enabled) }} + - name: {{ include "openshell.credentialStorageKeyEncryptionKeyEnvName" . }} + valueFrom: + secretKeyRef: + name: {{ include "openshell.credentialStorageKeyEncryptionKeySecretName" . }} + key: {{ include "openshell.credentialStorageKeyEncryptionKeySecretKey" . }} + {{- end }} {{- if .Values.server.externalDbSecret }} - name: OPENSHELL_DB_URL valueFrom: @@ -57,10 +64,10 @@ spec: name: {{ .Values.server.externalDbSecret }} key: uri {{- end }} - # All gateway settings live in the ConfigMap-backed TOML file - # mounted at /etc/openshell/gateway.toml. The only env var below - # is a process-level setting consumed by libraries outside - # gateway code (currently just SSL_CERT_FILE for OIDC issuer TLS). + # Most gateway settings live in the ConfigMap-backed TOML file + # mounted at /etc/openshell/gateway.toml. Secret-bearing settings use + # env vars that the TOML references by name. Some process-level + # settings consumed by libraries outside gateway code also remain here. {{- if and .Values.server.oidc.issuer .Values.server.oidc.caConfigMapName }} # OIDC issuer custom-CA: rustls/reqwest read SSL_CERT_FILE for # outbound TLS verification. This is a process-level env var @@ -69,6 +76,12 @@ spec: - name: SSL_CERT_FILE value: /etc/openshell-tls/oidc-ca/ca.crt {{- end }} + - name: OPENSHELL_TELEMETRY_ENABLED + value: {{ .Values.server.telemetryEnabled | quote }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + value: {{ .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} + {{- end }} volumeMounts: {{- if eq (include "openshell.workloadKind" .) "statefulset" }} - name: openshell-data @@ -84,7 +97,12 @@ spec: - name: tls-cert mountPath: /etc/openshell-tls/server readOnly: true - {{- if or .Values.server.tls.clientCaSecretName (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} + {{- if .Values.certManager.serverIssuerRef.name }} + - name: tls-external-cert + mountPath: /etc/openshell-tls/server-external + readOnly: true + {{- end }} + {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} - name: tls-client-ca mountPath: /etc/openshell-tls/client-ca readOnly: true @@ -95,6 +113,11 @@ spec: mountPath: /etc/openshell-tls/oidc-ca readOnly: true {{- end }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: spiffe-workload-api + mountPath: {{ dir .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} + readOnly: true + {{- end }} ports: - name: grpc containerPort: {{ .Values.service.port }} @@ -144,7 +167,12 @@ spec: - name: tls-cert secret: secretName: {{ .Values.server.tls.certSecretName }} - {{- if or .Values.server.tls.clientCaSecretName (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} + {{- if .Values.certManager.serverIssuerRef.name }} + - name: tls-external-cert + secret: + secretName: {{ include "openshell.fullname" . }}-server-external-tls + {{- end }} + {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} - name: tls-client-ca secret: {{- if or (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} @@ -162,6 +190,12 @@ spec: configMap: name: {{ .Values.server.oidc.caConfigMapName }} {{- end }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: spiffe-workload-api + csi: + driver: csi.spiffe.io + readOnly: true + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 4 }} diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index 1b4598088f..3d9f2f3e0b 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -70,6 +70,19 @@ Create the name of the service account assigned to sandbox pods {{- end }} {{- end }} +{{/* +Whether this chart owns workspace-scoped resources. Missing legacy values +default to enabled so upgrades with --reuse-values preserve the old topology. +*/}} +{{- define "openshell.workspaceResourcesEnabled" -}} +{{- $workspaceResources := .Values.workspaceResources | default dict -}} +{{- $enabled := true -}} +{{- if hasKey $workspaceResources "enabled" -}} +{{- $enabled = get $workspaceResources "enabled" -}} +{{- end -}} +{{- if $enabled -}}true{{- end -}} +{{- end }} + {{/* Gateway image reference. Uses image.tag when set; falls back to .Chart.AppVersion so a released chart automatically pulls the matching image without extra overrides. @@ -83,6 +96,20 @@ so a released chart automatically pulls the matching image without extra overrid ghcr.io/nvidia/openshell/supervisor {{- end }} +{{/* +Whether the gateway listener should verify client certificates (mTLS). +An explicit empty server.tls.clientCaSecretName disables client-CA wiring in +both gateway.toml and the workload, overriding built-in PKI and cert-manager +defaults. +*/}} +{{- define "openshell.gatewayClientCaEnabled" -}} +{{- if .Values.server.disableTls -}} +{{- else if eq .Values.server.tls.clientCaSecretName "" -}} +{{- else if or .Values.server.tls.clientCaSecretName (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) -}} +true +{{- end -}} +{{- end -}} + {{/* Whether Helm must propagate a supervisor image override into gateway.toml. The chart's documented repository and empty tag are the gateway-owned default. @@ -119,6 +146,40 @@ Namespace where sandbox pods are created. An explicit {{- .Values.server.sandboxNamespace | default .Release.Namespace -}} {{- end }} +{{/* +Namespace where Kubernetes Secret-backed provider credentials live. +*/}} +{{- define "openshell.credentialKubernetesSecretsNamespace" -}} +{{- .Values.server.credentialDrivers.kubernetesSecrets.namespace | default .Release.Namespace -}} +{{- end }} + +{{/* +Name of the Secret holding the default credential storage key-encryption key. +When server.credentialStorage.existingSecret is set, returns that name instead +of the chart-generated name (for GitOps / helm-template workflows). +*/}} +{{- define "openshell.credentialStorageKeyEncryptionKeySecretName" -}} +{{- if .Values.server.credentialStorage.existingSecret -}} +{{- .Values.server.credentialStorage.existingSecret -}} +{{- else -}} +{{- printf "%s-credential-storage-key-encryption-key" (include "openshell.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end }} + +{{/* +Key inside the default credential storage key-encryption key Secret. +*/}} +{{- define "openshell.credentialStorageKeyEncryptionKeySecretKey" -}} +key-encryption-key +{{- end }} + +{{/* +Gateway environment variable used to pass the default credential storage key-encryption key. +*/}} +{{- define "openshell.credentialStorageKeyEncryptionKeyEnvName" -}} +OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY +{{- end }} + {{/* Name of the Secret holding gateway-minted sandbox JWT signing material. */}} @@ -213,4 +274,21 @@ Validate chart values that Helm would otherwise accept silently. {{- if and (eq $workloadKind "statefulset") (gt $replicaCount 1) (not (get $workload "allowMultiReplicaStatefulSet" | default false)) -}} {{- fail "replicaCount > 1 with workload.kind=statefulset requires workload.allowMultiReplicaStatefulSet=true; use workload.kind=deployment for external database-backed multi-replica gateways." -}} {{- end -}} +{{- $workspaceMode := .Values.server.drivers.kubernetes.workspaceMode | default "shared" -}} +{{- if not (has $workspaceMode (list "shared" "managed" "operator")) -}} +{{- fail "server.drivers.kubernetes.workspaceMode must be one of: shared, managed, operator." -}} +{{- end -}} +{{- $credentialDrivers := list -}} +{{- if .Values.server.credentialDrivers.kubernetesSecrets.enabled -}} +{{- $credentialDrivers = append $credentialDrivers "kubernetes-secrets" -}} +{{- end -}} +{{- if .Values.server.credentialDrivers.vault.enabled -}} +{{- $credentialDrivers = append $credentialDrivers "vault" -}} +{{- end -}} +{{- if gt (len $credentialDrivers) 1 -}} +{{- fail "only one external server.credentialDrivers backend can be enabled at a time." -}} +{{- end -}} +{{- if kindIs "invalid" .Values.server.tls.clientCaSecretName -}} +{{- fail "server.tls.clientCaSecretName cannot be null; omit the key to use the chart default (openshell-server-client-ca), or set to \"\" to disable client certificate verification for HTTPS-only mode" -}} +{{- end -}} {{- end }} diff --git a/deploy/helm/openshell/templates/agent-sandbox-preflight.yaml b/deploy/helm/openshell/templates/agent-sandbox-preflight.yaml new file mode 100644 index 0000000000..83a742f670 --- /dev/null +++ b/deploy/helm/openshell/templates/agent-sandbox-preflight.yaml @@ -0,0 +1,11 @@ +{{/* +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +*/}} +{{- if .Values.agentSandbox.preflight.enabled }} +{{- $v1beta1 := or (.Capabilities.APIVersions.Has "agents.x-k8s.io/v1beta1") (.Capabilities.APIVersions.Has "agents.x-k8s.io/v1beta1/Sandbox") }} +{{- $v1alpha1 := or (.Capabilities.APIVersions.Has "agents.x-k8s.io/v1alpha1") (.Capabilities.APIVersions.Has "agents.x-k8s.io/v1alpha1/Sandbox") }} +{{- if not (or $v1beta1 $v1alpha1) }} +{{- fail "Agent Sandbox is required but neither agents.x-k8s.io/v1beta1 nor agents.x-k8s.io/v1alpha1 is served by this cluster. Install the Agent Sandbox CRDs and controller before deploying OpenShell; see deploy/helm/openshell/README.md. Set agentSandbox.preflight.enabled=false only for offline rendering." }} +{{- end }} +{{- end }} diff --git a/deploy/helm/openshell/templates/cert-manager-pki.yaml b/deploy/helm/openshell/templates/cert-manager-pki.yaml index fdd702a305..838534e62e 100644 --- a/deploy/helm/openshell/templates/cert-manager-pki.yaml +++ b/deploy/helm/openshell/templates/cert-manager-pki.yaml @@ -1,7 +1,6 @@ +{{- if .Values.certManager.enabled }} # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 - -{{- if .Values.certManager.enabled }} apiVersion: cert-manager.io/v1 kind: Issuer metadata: @@ -42,6 +41,24 @@ spec: ca: secretName: {{ .Values.certManager.caSecretName | quote }} --- +{{- $externalServerIssuer := .Values.certManager.serverIssuerRef.name }} +{{- if and (not .Values.certManager.clientCaFromServerTlsSecret) (eq .Values.server.tls.clientCaSecretName "openshell-server-client-ca") }} +{{- fail "certManager.clientCaFromServerTlsSecret is false but server.tls.clientCaSecretName is still the default (openshell-server-client-ca), which nothing creates when cert-manager owns TLS. Set server.tls.clientCaSecretName to the secret containing the client CA (e.g. the value of certManager.caSecretName, which defaults to openshell-ca-tls), or set it to empty to disable mTLS client verification." }} +{{- end }} +{{- if $externalServerIssuer }} +{{- if not .Values.certManager.serverDnsNames }} +{{- fail "certManager.serverIssuerRef.name is set but certManager.serverDnsNames is empty — the external certificate requires at least one externally-resolvable DNS name." }} +{{- end }} +{{- range .Values.certManager.serverDnsNames }} +{{- /* Single-label names (e.g. "openshell") are also rejected by ACME CAs but are intentionally not checked here — the guard targets recognisable internal-network patterns. */ -}} +{{- if or (eq . "localhost") (hasSuffix ".localhost" .) (hasSuffix ".svc.cluster.local" .) (hasSuffix ".svc" .) (eq . "host.docker.internal") (eq . "host.containers.internal") }} +{{- fail (printf "certManager.serverIssuerRef.name is set (external issuer) but certManager.serverDnsNames contains %q — external CAs (e.g. ACME / Let's Encrypt) reject internal-only names per CA/Browser Forum baseline requirements. Override certManager.serverDnsNames with your externally-resolvable hostname(s)." .) }} +{{- end }} +{{- end }} +{{- end }} +# Internal server certificate — always issued by the chart’s own CA with +# internal SANs. Supervisors connect via internal hostnames and verify +# this cert against the chart CA they already trust. apiVersion: cert-manager.io/v1 kind: Certificate metadata: @@ -53,14 +70,16 @@ spec: secretName: {{ .Values.server.tls.certSecretName | quote }} duration: {{ .Values.certManager.certificateDuration | quote }} renewBefore: {{ .Values.certManager.certificateRenewBefore | quote }} - commonName: openshell-server + commonName: {{ include "openshell.fullname" . }} dnsNames: {{- range (include "openshell.defaultServerDnsNames" . | fromYamlArray) }} - {{ . | quote }} {{- end }} + {{- if not $externalServerIssuer }} {{- range .Values.certManager.serverDnsNames }} - {{ . | quote }} {{- end }} + {{- end }} {{- if .Values.certManager.serverIpAddresses }} ipAddresses: {{- toYaml .Values.certManager.serverIpAddresses | nindent 4 }} @@ -76,6 +95,41 @@ spec: name: {{ include "openshell.fullname" . }}-ca-issuer kind: Issuer group: cert-manager.io +{{- if $externalServerIssuer }} +--- +# External server certificate — issued by the operator-configured issuer +# (e.g. ACME/Let’s Encrypt) with only externally-resolvable SANs. +# The gateway uses SNI to present this cert for external hostnames. +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "openshell.fullname" . }}-server-external + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell.labels" . | nindent 4 }} +spec: + secretName: {{ include "openshell.fullname" . }}-server-external-tls + duration: {{ .Values.certManager.certificateDuration | quote }} + renewBefore: {{ .Values.certManager.certificateRenewBefore | quote }} + {{- if .Values.certManager.serverDnsNames }} + commonName: {{ first .Values.certManager.serverDnsNames | quote }} + {{- end }} + dnsNames: + {{- range .Values.certManager.serverDnsNames }} + - {{ . | quote }} + {{- end }} + privateKey: + algorithm: ECDSA + size: 256 + usages: + - server auth + - digital signature + - key encipherment + issuerRef: + name: {{ .Values.certManager.serverIssuerRef.name }} + kind: {{ .Values.certManager.serverIssuerRef.kind | default "Issuer" }} + group: {{ .Values.certManager.serverIssuerRef.group | default "cert-manager.io" }} +{{- end }} --- apiVersion: cert-manager.io/v1 kind: Certificate diff --git a/deploy/helm/openshell/templates/clusterrole.yaml b/deploy/helm/openshell/templates/clusterrole.yaml index 073c8835ec..eb1ed8e1d0 100644 --- a/deploy/helm/openshell/templates/clusterrole.yaml +++ b/deploy/helm/openshell/templates/clusterrole.yaml @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +{{- $workspaceMode := .Values.server.drivers.kubernetes.workspaceMode | default "shared" }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -25,9 +26,124 @@ rules: - list - watch # Read namespace annotations for OpenShift SCC UID/GID range resolution. + # Managed/operator modes additionally need list+watch for cluster-wide + # namespace discovery. Managed mode needs create+delete for namespace + # lifecycle. - apiGroups: - "" resources: - namespaces verbs: - get + {{- if ne $workspaceMode "shared" }} + - list + - watch + {{- end }} + {{- if eq $workspaceMode "managed" }} + - create + - delete + {{- end }} + {{- if ne $workspaceMode "shared" }} + # Cluster-wide sandbox CRD access for managed/operator workspace modes. + - apiGroups: + - agents.x-k8s.io + resources: + - sandboxes + - sandboxes/status + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - events + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - pods + verbs: + - get + {{- end }} + {{- $copiedSecretNames := list }} + {{- if and (ne $workspaceMode "shared") (not .Values.server.disableTls) }} + {{- $copiedSecretNames = append $copiedSecretNames .Values.server.tls.clientTlsSecretName }} + {{- end }} + {{- if eq $workspaceMode "managed" }} + {{- range .Values.server.sandboxImagePullSecrets }} + {{- if .name }} + {{- $copiedSecretNames = append $copiedSecretNames .name }} + {{- end }} + {{- end }} + {{- end }} + {{- $copiedSecretNames = uniq $copiedSecretNames }} + {{- if $copiedSecretNames }} + # Copy only explicitly configured TLS and image-pull Secrets into workspace + # namespaces. Server-side apply authorizes these requests as patch operations. + - apiGroups: + - "" + resources: + - secrets + resourceNames: + {{- range $copiedSecretNames }} + - {{ . | quote }} + {{- end }} + verbs: + - get + - patch + # Server-side apply uses PATCH for an existing Secret, but the API server + # additionally authorizes CREATE when the named Secret does not exist yet. + # Kubernetes RBAC cannot restrict CREATE by resourceNames because create + # authorization happens before the object name is available to RBAC. Keep + # reads and mutations name-restricted above; this broader grant is required + # only to create the initial copy in a gateway-owned managed namespace. + - apiGroups: + - "" + resources: + - secrets + verbs: + - create + {{- end }} + {{- if and (ne $workspaceMode "shared") .Values.server.credentialDrivers.kubernetesSecrets.enabled }} + # The kubernetes-secrets credential driver uses dynamic hashed names in + # workspace namespaces, so Kubernetes RBAC cannot restrict resourceNames. + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + - create + - patch + - delete + {{- end }} + {{- if eq $workspaceMode "managed" }} + # ServiceAccount creation in managed namespaces. + - apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - create + - get + {{- if .Values.networkPolicy.enabled }} + # Apply gateway-only SSH ingress isolation in managed namespaces. + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - get + - create + - patch + - update + {{- end }} + {{- end }} diff --git a/deploy/helm/openshell/templates/credential-secrets-role.yaml b/deploy/helm/openshell/templates/credential-secrets-role.yaml new file mode 100644 index 0000000000..f6187c9acb --- /dev/null +++ b/deploy/helm/openshell/templates/credential-secrets-role.yaml @@ -0,0 +1,27 @@ +{{- if and .Values.server.credentialDrivers.kubernetesSecrets.enabled .Values.server.credentialDrivers.kubernetesSecrets.rbac.create }} +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "openshell.fullname" . }}-credential-secrets + namespace: {{ include "openshell.credentialKubernetesSecretsNamespace" . }} + labels: + {{- include "openshell.labels" . | nindent 4 }} +# NOTE: This Role grants access to all Secrets in the namespace because +# OpenShell-managed Secret names are dynamic SHA-256 hashes generated at +# runtime. Kubernetes RBAC does not support label-based or prefix-based +# filtering for resourceNames. To limit blast radius, deploy the gateway +# with a dedicated namespace for credential Secrets +# (server.credentialDrivers.kubernetesSecrets.namespace). +rules: + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + - create + - patch + - delete +{{- end }} diff --git a/deploy/helm/openshell/templates/credential-secrets-rolebinding.yaml b/deploy/helm/openshell/templates/credential-secrets-rolebinding.yaml new file mode 100644 index 0000000000..3a9ee0bddc --- /dev/null +++ b/deploy/helm/openshell/templates/credential-secrets-rolebinding.yaml @@ -0,0 +1,19 @@ +{{- if and .Values.server.credentialDrivers.kubernetesSecrets.enabled .Values.server.credentialDrivers.kubernetesSecrets.rbac.create }} +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "openshell.fullname" . }}-credential-secrets + namespace: {{ include "openshell.credentialKubernetesSecretsNamespace" . }} + labels: + {{- include "openshell.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "openshell.fullname" . }}-credential-secrets +subjects: + - kind: ServiceAccount + name: {{ include "openshell.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/helm/openshell/templates/credential-storage-key-encryption-key-secret.yaml b/deploy/helm/openshell/templates/credential-storage-key-encryption-key-secret.yaml new file mode 100644 index 0000000000..1e53d84bfb --- /dev/null +++ b/deploy/helm/openshell/templates/credential-storage-key-encryption-key-secret.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +{{- if not (or .Values.server.credentialDrivers.kubernetesSecrets.enabled .Values.server.credentialDrivers.vault.enabled) }} +{{- if not .Values.server.credentialStorage.existingSecret }} +{{- $secretName := include "openshell.credentialStorageKeyEncryptionKeySecretName" . -}} +{{- $secretKey := include "openshell.credentialStorageKeyEncryptionKeySecretKey" . -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName -}} +{{- $encodedKeyEncryptionKey := randBytes 32 | b64enc -}} +{{- if $existing -}} +{{- $existingData := get $existing "data" | default dict -}} +{{- if not (hasKey $existingData $secretKey) -}} +{{- fail (printf "existing credential storage key-encryption key Secret %s/%s is missing key %s" .Release.Namespace $secretName $secretKey) -}} +{{- end -}} +{{- $encodedKeyEncryptionKey = index $existingData $secretKey -}} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +type: Opaque +data: + {{ $secretKey }}: {{ $encodedKeyEncryptionKey | quote }} +{{- end }} +{{- end }} diff --git a/deploy/helm/openshell/templates/deployment.yaml b/deploy/helm/openshell/templates/deployment.yaml index e937979370..f94900b136 100644 --- a/deploy/helm/openshell/templates/deployment.yaml +++ b/deploy/helm/openshell/templates/deployment.yaml @@ -1,7 +1,7 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 {{- include "openshell.validateValues" . }} {{- if eq (include "openshell.workloadKind" .) "deployment" }} +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 apiVersion: apps/v1 kind: Deployment metadata: diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 0c2fc3bbd4..083748aee3 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -12,6 +12,17 @@ One value is intentionally NOT rendered here: when server.externalDbSecret is set, otherwise --db-url arg for SQLite */}} +{{- $credentialDrivers := list -}} +{{- $otlp := .Values.server.otlp | default dict -}} +{{- if .Values.server.credentialDrivers.kubernetesSecrets.enabled -}} +{{- $credentialDrivers = append $credentialDrivers "kubernetes-secrets" -}} +{{- end -}} +{{- if .Values.server.credentialDrivers.vault.enabled -}} +{{- $credentialDrivers = append $credentialDrivers "vault" -}} +{{- end -}} +{{- if and .Values.certManager.serverIssuerRef.name (not .Values.certManager.enabled) }} +{{- fail "certManager.serverIssuerRef.name is set but certManager.enabled is false \u2014 the external server certificate, its Secret mount, and the gateway TLS configuration all require cert-manager to be enabled. Set certManager.enabled=true or remove certManager.serverIssuerRef.name." }} +{{- end }} apiVersion: v1 kind: ConfigMap metadata: @@ -24,6 +35,7 @@ data: version = 1 [openshell.gateway] + name = {{ .Values.server.name | default (include "openshell.fullname" .) | quote }} bind_address = "0.0.0.0:{{ .Values.service.port }}" {{- if .Values.service.healthPort }} health_bind_address = "0.0.0.0:{{ .Values.service.healthPort }}" @@ -32,6 +44,9 @@ data: metrics_bind_address = "0.0.0.0:{{ .Values.service.metricsPort }}" {{- end }} log_level = {{ .Values.server.logLevel | quote }} + {{- if $credentialDrivers }} + credential_drivers = [{{- range $i, $driver := $credentialDrivers }}{{ if $i }}, {{ end }}{{ $driver | quote }}{{- end }}] + {{- end }} sandbox_namespace = {{ include "openshell.sandboxNamespace" . | quote }} {{- $policyValidationFailureMode := .Values.server.policyValidationFailureMode }} {{- if not (has $policyValidationFailureMode (list "fail_closed" "retain_last_valid")) }} @@ -75,13 +90,29 @@ data: {{- fail "server.grpcRateLimit requires both requests and windowSeconds to be positive to enable rate limiting, or both 0/unset to disable it" }} {{- end }} + {{- if $otlp.endpoint }} + + [openshell.gateway.otlp] + endpoint = {{ $otlp.endpoint | quote }} + {{- if $otlp.serviceName }} + service_name = {{ $otlp.serviceName | quote }} + {{- end }} + {{- end }} + {{- if not .Values.server.disableTls }} [openshell.gateway.tls] cert_path = "/etc/openshell-tls/server/tls.crt" key_path = "/etc/openshell-tls/server/tls.key" + {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} client_ca_path = "/etc/openshell-tls/client-ca/ca.crt" {{- end }} + {{- if .Values.certManager.serverIssuerRef.name }} + external_cert_path = "/etc/openshell-tls/server-external/tls.crt" + external_key_path = "/etc/openshell-tls/server-external/tls.key" + external_server_names = [{{- range $i, $name := .Values.certManager.serverDnsNames }}{{ if $i }}, {{ end }}{{ $name | quote }}{{- end }}] + {{- end }} + {{- end }} {{- if .Values.server.auth.allowUnauthenticatedUsers }} @@ -117,11 +148,37 @@ data: {{- end }} [openshell.drivers.kubernetes] + workspace_mode = {{ .Values.server.drivers.kubernetes.workspaceMode | default "shared" | quote }} + gateway_id = {{ .Values.server.sandboxJwt.gatewayId | default (include "openshell.fullname" .) | quote }} grpc_endpoint = {{ include "openshell.grpcEndpoint" . | quote }} service_account_name = {{ include "openshell.sandboxServiceAccountName" . | quote }} + {{- if .Values.server.drivers.kubernetes.operatorNamespaceLabel }} + operator_namespace_label = {{ .Values.server.drivers.kubernetes.operatorNamespaceLabel | quote }} + {{- end }} + {{- if .Values.server.drivers.kubernetes.operatorNamespaceFile }} + operator_namespace_file = {{ .Values.server.drivers.kubernetes.operatorNamespaceFile | quote }} + {{- end }} supervisor_sideload_method = {{ include "openshell.supervisorSideloadMethod" . | quote }} topology = {{ .Values.supervisor.topology | default "combined" | quote }} sa_token_ttl_secs = {{ .Values.server.sandboxJwt.k8sSaTokenTtlSecs | default 3600 }} + {{- if .Values.upstreamProxy.url }} + https_proxy = {{ .Values.upstreamProxy.url | quote }} + {{- end }} + {{- if .Values.upstreamProxy.noProxy }} + no_proxy = {{ .Values.upstreamProxy.noProxy | quote }} + {{- end }} + {{- if .Values.upstreamProxy.authSecret.name }} + proxy_auth_secret_name = {{ .Values.upstreamProxy.authSecret.name | quote }} + {{- end }} + {{- if .Values.upstreamProxy.authSecret.key }} + proxy_auth_secret_key = {{ .Values.upstreamProxy.authSecret.key | quote }} + {{- end }} + {{- if and .Values.upstreamProxy.authSecret.name .Values.upstreamProxy.authSecret.key }} + proxy_auth_allow_insecure = {{ .Values.upstreamProxy.authAllowInsecure }} + {{- end }} + {{- if .Values.upstreamProxy.connectByHostname }} + proxy_connect_by_hostname = true + {{- end }} {{- if .Values.server.providerTokenGrants.spiffe.enabled }} provider_spiffe_workload_api_socket_path = {{ .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} {{- end }} @@ -153,6 +210,50 @@ data: supervisor_image_pull_policy = {{ .Values.supervisor.image.pullPolicy | quote }} {{- end }} + [openshell.drivers.kubernetes.managed_ssh_ingress] + enabled = {{ .Values.networkPolicy.enabled }} + gateway_namespace = {{ .Release.Namespace | quote }} + gateway_pod_selector = { "app.kubernetes.io/name" = {{ include "openshell.name" . | quote }}, "app.kubernetes.io/instance" = {{ .Release.Name | quote }} } + [openshell.drivers.kubernetes.sidecar] proxy_uid = {{ .Values.supervisor.sidecar.proxyUid | default 1337 }} process_binary_aware_network_policy = {{ .Values.supervisor.sidecar.processBinaryAwareNetworkPolicy }} + + {{- if not $credentialDrivers }} + + [openshell.gateway.credential_storage] + key_encryption_key_env = {{ include "openshell.credentialStorageKeyEncryptionKeyEnvName" . | quote }} + {{- end }} + + {{- if .Values.server.credentialDrivers.kubernetesSecrets.enabled }} + + [openshell.credential_drivers.kubernetes-secrets] + namespace = {{ include "openshell.credentialKubernetesSecretsNamespace" . | quote }} + allow_reference_namespace = {{ .Values.server.credentialDrivers.kubernetesSecrets.allowReferenceNamespace }} + workspace_mode = {{ .Values.server.drivers.kubernetes.workspaceMode | default "shared" | quote }} + gateway_id = {{ .Values.server.sandboxJwt.gatewayId | default (include "openshell.fullname" .) | quote }} + {{- end }} + + {{- if .Values.server.credentialDrivers.vault.enabled }} + + [openshell.credential_drivers.vault] + address = {{ .Values.server.credentialDrivers.vault.address | quote }} + mount = {{ .Values.server.credentialDrivers.vault.mount | quote }} + kv_version = {{ .Values.server.credentialDrivers.vault.kvVersion | quote }} + auth_method = {{ .Values.server.credentialDrivers.vault.authMethod | quote }} + {{- if .Values.server.credentialDrivers.vault.role }} + role = {{ .Values.server.credentialDrivers.vault.role | quote }} + {{- end }} + {{- if .Values.server.credentialDrivers.vault.kubernetesAuthMount }} + kubernetes_auth_mount = {{ .Values.server.credentialDrivers.vault.kubernetesAuthMount | quote }} + {{- end }} + {{- if .Values.server.credentialDrivers.vault.serviceAccountTokenPath }} + service_account_token_path = {{ .Values.server.credentialDrivers.vault.serviceAccountTokenPath | quote }} + {{- end }} + {{- if .Values.server.credentialDrivers.vault.tokenPath }} + token_path = {{ .Values.server.credentialDrivers.vault.tokenPath | quote }} + {{- end }} + {{- if .Values.server.credentialDrivers.vault.timeoutSecs }} + timeout_secs = {{ .Values.server.credentialDrivers.vault.timeoutSecs }} + {{- end }} + {{- end }} diff --git a/deploy/helm/openshell/templates/gateway.yaml b/deploy/helm/openshell/templates/gateway.yaml index f431ffbbd1..2b78595053 100644 --- a/deploy/helm/openshell/templates/gateway.yaml +++ b/deploy/helm/openshell/templates/gateway.yaml @@ -1,7 +1,6 @@ +{{- if and .Values.grpcRoute.enabled .Values.grpcRoute.gateway.create }} # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 - -{{- if and .Values.grpcRoute.enabled .Values.grpcRoute.gateway.create }} apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: diff --git a/deploy/helm/openshell/templates/grpcroute.yaml b/deploy/helm/openshell/templates/grpcroute.yaml index 8fde5458cd..362067fda3 100644 --- a/deploy/helm/openshell/templates/grpcroute.yaml +++ b/deploy/helm/openshell/templates/grpcroute.yaml @@ -1,7 +1,6 @@ +{{- if .Values.grpcRoute.enabled }} # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 - -{{- if .Values.grpcRoute.enabled }} apiVersion: gateway.networking.k8s.io/v1 kind: GRPCRoute metadata: diff --git a/deploy/helm/openshell/templates/networkpolicy.yaml b/deploy/helm/openshell/templates/networkpolicy.yaml index e85571e5f5..c9e4a760e3 100644 --- a/deploy/helm/openshell/templates/networkpolicy.yaml +++ b/deploy/helm/openshell/templates/networkpolicy.yaml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -{{- if .Values.networkPolicy.enabled }} +{{- if and (include "openshell.workspaceResourcesEnabled" .) .Values.networkPolicy.enabled }} # NetworkPolicy restricting SSH ingress on sandbox pods to the gateway pod. # Sandbox pods are dynamically created by the server and labelled with # openshell.ai/managed-by=openshell. This policy ensures only the gateway diff --git a/deploy/helm/openshell/templates/role.yaml b/deploy/helm/openshell/templates/role.yaml index 5ecc4428ad..dfd6423615 100644 --- a/deploy/helm/openshell/templates/role.yaml +++ b/deploy/helm/openshell/templates/role.yaml @@ -1,6 +1,7 @@ +{{- $workspaceMode := .Values.server.drivers.kubernetes.workspaceMode | default "shared" -}} +{{- if and (eq $workspaceMode "shared") (include "openshell.workspaceResourcesEnabled" .) }} # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 - apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -32,7 +33,7 @@ rules: - watch # Per-sandbox identity: TokenReview authenticates the projected token from # the configured sandbox service account, then the gateway resolves the - # returned pod name and UID to the pod's `openshell.io/sandbox-id` + # returned pod name and UID to the pod's `openshell.ai/sandbox-id` # annotation. patch is intentionally NOT granted — the annotation is set # once at pod create and must remain immutable for the lifetime of the # sandbox. @@ -42,3 +43,4 @@ rules: - pods verbs: - get +{{- end }} diff --git a/deploy/helm/openshell/templates/rolebinding.yaml b/deploy/helm/openshell/templates/rolebinding.yaml index e5233f753c..32f11644bf 100644 --- a/deploy/helm/openshell/templates/rolebinding.yaml +++ b/deploy/helm/openshell/templates/rolebinding.yaml @@ -1,6 +1,7 @@ +{{- $workspaceMode := .Values.server.drivers.kubernetes.workspaceMode | default "shared" -}} +{{- if and (eq $workspaceMode "shared") (include "openshell.workspaceResourcesEnabled" .) }} # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 - apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: @@ -16,3 +17,4 @@ subjects: - kind: ServiceAccount name: {{ include "openshell.serviceAccountName" . }} namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/helm/openshell/templates/route.yaml b/deploy/helm/openshell/templates/route.yaml new file mode 100644 index 0000000000..459a0ac462 --- /dev/null +++ b/deploy/helm/openshell/templates/route.yaml @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- if .Values.openshiftRoute.enabled }} +{{- if .Values.server.disableTls }} +{{- fail "openshiftRoute.enabled=true requires TLS (server.disableTls must be false) \u2014 a passthrough Route forwards encrypted traffic by SNI, so the gateway must terminate its own TLS." }} +{{- end }} +{{- if and .Values.certManager.serverIssuerRef.name (not .Values.openshiftRoute.host) }} +{{- fail "openshiftRoute.enabled=true with certManager.serverIssuerRef requires an explicit openshiftRoute.host \u2014 without one, OpenShift generates a hostname absent from certManager.serverDnsNames, causing the gateway to serve its internal certificate to external clients." }} +{{- end }} +{{- if and .Values.openshiftRoute.host .Values.certManager.serverIssuerRef.name .Values.certManager.serverDnsNames }} +{{- $routeHost := .Values.openshiftRoute.host }} +{{- $hostCovered := false }} +{{- range .Values.certManager.serverDnsNames }} + {{- if eq . $routeHost }} + {{- $hostCovered = true }} + {{- else if hasPrefix "*." . }} + {{- $wildcardSuffix := trimPrefix "*" . }} + {{- $prefix := trimSuffix $wildcardSuffix $routeHost }} + {{- if and (ne $prefix $routeHost) (gt (len $prefix) 0) (not (contains "." $prefix)) }} + {{- $hostCovered = true }} + {{- end }} + {{- end }} +{{- end }} +{{- if not $hostCovered }} +{{- fail (printf "openshiftRoute.host %q is not covered by certManager.serverDnsNames %v \u2014 the Route will forward SNI for a hostname the external certificate does not cover, causing TLS verification failures for CLI clients. Exact names and single-level wildcards (e.g. *.example.com) are checked." $routeHost .Values.certManager.serverDnsNames) }} +{{- end }} +{{- end }} +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: {{ include "openshell.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell.labels" . | nindent 4 }} + {{- with .Values.openshiftRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.openshiftRoute.host }} + host: {{ .Values.openshiftRoute.host | quote }} + {{- end }} + to: + kind: Service + name: {{ include "openshell.fullname" . }} + port: + targetPort: grpc + tls: + termination: passthrough + wildcardPolicy: None +{{- end }} diff --git a/deploy/helm/openshell/templates/serviceaccount.yaml b/deploy/helm/openshell/templates/serviceaccount.yaml index a98ad5363e..1a9245d4dc 100644 --- a/deploy/helm/openshell/templates/serviceaccount.yaml +++ b/deploy/helm/openshell/templates/serviceaccount.yaml @@ -13,10 +13,10 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} {{- end }} -{{- if and .Values.serviceAccount.create .Values.sandboxServiceAccount.create }} +{{- if and .Values.serviceAccount.create (include "openshell.workspaceResourcesEnabled" .) .Values.sandboxServiceAccount.create }} --- {{- end }} -{{- if .Values.sandboxServiceAccount.create }} +{{- if and (include "openshell.workspaceResourcesEnabled" .) .Values.sandboxServiceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: diff --git a/deploy/helm/openshell/tests/agent_sandbox_preflight_test.yaml b/deploy/helm/openshell/tests/agent_sandbox_preflight_test.yaml new file mode 100644 index 0000000000..45ab2e76b9 --- /dev/null +++ b/deploy/helm/openshell/tests/agent_sandbox_preflight_test.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: Agent Sandbox preflight +templates: + - templates/agent-sandbox-preflight.yaml + +tests: + - it: fails clearly by default without a supported Agent Sandbox API + asserts: + - failedTemplate: + errorPattern: "Agent Sandbox is required but neither agents.x-k8s.io/v1beta1 nor agents.x-k8s.io/v1alpha1 is served" diff --git a/deploy/helm/openshell/tests/cert_manager_pki_test.yaml b/deploy/helm/openshell/tests/cert_manager_pki_test.yaml new file mode 100644 index 0000000000..6fb8b8fbe3 --- /dev/null +++ b/deploy/helm/openshell/tests/cert_manager_pki_test.yaml @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: cert-manager PKI issuerRef overrides +templates: + - templates/cert-manager-pki.yaml +release: + name: openshell + namespace: my-namespace + +tests: + - it: defaults both server and client Certificates to the chart's own CA issuer + template: templates/cert-manager-pki.yaml + set: + certManager.enabled: true + asserts: + - equal: + path: spec.issuerRef.name + value: openshell-ca-issuer + documentIndex: 3 + - equal: + path: spec.issuerRef.kind + value: Issuer + documentIndex: 3 + - equal: + path: spec.issuerRef.name + value: openshell-ca-issuer + documentIndex: 4 + - equal: + path: spec.issuerRef.kind + value: Issuer + documentIndex: 4 + + - it: default server Certificate includes internal SANs, IPs, and a fixed commonName + template: templates/cert-manager-pki.yaml + set: + certManager.enabled: true + asserts: + - equal: + path: spec.commonName + value: openshell + documentIndex: 3 + - contains: + path: spec.dnsNames + content: openshell.my-namespace.svc.cluster.local + documentIndex: 3 + - contains: + path: spec.dnsNames + content: localhost + documentIndex: 3 + - equal: + path: spec.ipAddresses[0] + value: 127.0.0.1 + documentIndex: 3 + + - it: internal server cert keeps chart CA issuer and internal SANs when serverIssuerRef is set + template: templates/cert-manager-pki.yaml + set: + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverIssuerRef.kind: ClusterIssuer + certManager.serverDnsNames: + - openshell.example.com + asserts: + # Internal cert (doc 3) stays on chart CA + - equal: + path: spec.issuerRef.name + value: openshell-ca-issuer + documentIndex: 3 + - equal: + path: spec.issuerRef.kind + value: Issuer + documentIndex: 3 + # Internal cert has internal SANs + - equal: + path: spec.commonName + value: openshell + documentIndex: 3 + - contains: + path: spec.dnsNames + content: openshell.my-namespace.svc.cluster.local + documentIndex: 3 + - contains: + path: spec.dnsNames + content: localhost + documentIndex: 3 + # Internal cert does NOT include external-only hostnames + - notContains: + path: spec.dnsNames + content: openshell.example.com + documentIndex: 3 + + - it: creates external server Certificate from serverIssuerRef with external SANs only + template: templates/cert-manager-pki.yaml + set: + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverIssuerRef.kind: ClusterIssuer + certManager.serverDnsNames: + - openshell.example.com + asserts: + # External cert (doc 4) uses the external issuer + - equal: + path: spec.issuerRef.name + value: letsencrypt-prod + documentIndex: 4 + - equal: + path: spec.issuerRef.kind + value: ClusterIssuer + documentIndex: 4 + - equal: + path: spec.issuerRef.group + value: cert-manager.io + documentIndex: 4 + # External cert has only external SANs + - equal: + path: spec.commonName + value: openshell.example.com + documentIndex: 4 + - equal: + path: spec.dnsNames + value: + - openshell.example.com + documentIndex: 4 + # External cert has no IP addresses + - notExists: + path: spec.ipAddresses + documentIndex: 4 + # External cert has no internal names + - notContains: + path: spec.dnsNames + content: localhost + documentIndex: 4 + + - it: fails when serverIssuerRef is set but serverDnsNames contains internal-only names + template: templates/cert-manager-pki.yaml + set: + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + # serverDnsNames is left at the default which contains "openshell.openshell.svc", "localhost", etc. + asserts: + - failedTemplate: + errorMessage: "certManager.serverIssuerRef.name is set (external issuer) but certManager.serverDnsNames contains \"openshell.openshell.svc\" \u2014 external CAs (e.g. ACME / Let's Encrypt) reject internal-only names per CA/Browser Forum baseline requirements. Override certManager.serverDnsNames with your externally-resolvable hostname(s)." + + - it: fails when clientCaFromServerTlsSecret is false but clientCaSecretName is the default + template: templates/cert-manager-pki.yaml + set: + certManager.enabled: true + certManager.clientCaFromServerTlsSecret: false + asserts: + - failedTemplate: + errorMessage: "certManager.clientCaFromServerTlsSecret is false but server.tls.clientCaSecretName is still the default (openshell-server-client-ca), which nothing creates when cert-manager owns TLS. Set server.tls.clientCaSecretName to the secret containing the client CA (e.g. the value of certManager.caSecretName, which defaults to openshell-ca-tls), or set it to empty to disable mTLS client verification." + + - it: client Certificate issuerRef is unaffected by serverIssuerRef + template: templates/cert-manager-pki.yaml + set: + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverIssuerRef.kind: ClusterIssuer + certManager.serverDnsNames: + - openshell.example.com + asserts: + # Client cert is now doc 5 (after internal + external server certs) + - equal: + path: spec.issuerRef.name + value: openshell-ca-issuer + documentIndex: 5 + - equal: + path: spec.issuerRef.kind + value: Issuer + documentIndex: 5 diff --git a/deploy/helm/openshell/tests/clusterrole_test.yaml b/deploy/helm/openshell/tests/clusterrole_test.yaml new file mode 100644 index 0000000000..afecada9b6 --- /dev/null +++ b/deploy/helm/openshell/tests/clusterrole_test.yaml @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: ClusterRole RBAC +templates: + - templates/clusterrole.yaml +release: + name: openshell + namespace: my-namespace + +tests: + - it: grants managed namespace NetworkPolicy apply permissions + set: + server.drivers.kubernetes.workspaceMode: managed + asserts: + - contains: + path: rules + content: + apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["get", "create", "patch", "update"] + + - it: omits managed NetworkPolicy permissions when isolation is disabled + set: + server.drivers.kubernetes.workspaceMode: managed + networkPolicy.enabled: false + asserts: + - notContains: + path: rules + content: + apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + any: true + + - it: grants broad secret access when credential driver is enabled (operator) + set: + server.drivers.kubernetes.workspaceMode: operator + server.credentialDrivers.kubernetesSecrets.enabled: true + asserts: + - contains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "patch", "delete"] + + - it: restricts operator TLS sync to the configured secret + set: + server.drivers.kubernetes.workspaceMode: operator + server.tls.clientTlsSecretName: custom-client-tls + asserts: + - contains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + resourceNames: ["custom-client-tls"] + verbs: ["get", "patch"] + + - it: omits operator secret access when TLS and credential storage are disabled + set: + server.drivers.kubernetes.workspaceMode: operator + server.disableTls: true + asserts: + - notContains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + any: true + + - it: restricts managed copies to TLS and configured image-pull secrets + set: + server.drivers.kubernetes.workspaceMode: managed + server.tls.clientTlsSecretName: custom-client-tls + server.sandboxImagePullSecrets: + - name: registry-one + - name: registry-two + asserts: + - contains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + resourceNames: ["custom-client-tls", "registry-one", "registry-two"] + verbs: ["get", "patch"] + - contains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + verbs: ["create"] + + - it: restricts managed copies to image-pull secrets when TLS is disabled + set: + server.drivers.kubernetes.workspaceMode: managed + server.disableTls: true + server.sandboxImagePullSecrets: + - name: registry-one + asserts: + - contains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + resourceNames: ["registry-one"] + verbs: ["get", "patch"] + - contains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + verbs: ["create"] + + - it: omits secrets rule entirely in shared mode + set: + server.drivers.kubernetes.workspaceMode: shared + asserts: + - notContains: + path: rules + content: + apiGroups: [""] + resources: ["secrets"] + any: true diff --git a/deploy/helm/openshell/tests/credential_drivers_test.yaml b/deploy/helm/openshell/tests/credential_drivers_test.yaml new file mode 100644 index 0000000000..76d8e081a5 --- /dev/null +++ b/deploy/helm/openshell/tests/credential_drivers_test.yaml @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: credential drivers +templates: + - templates/gateway-config.yaml + - templates/credential-storage-key-encryption-key-secret.yaml + - templates/statefulset.yaml + - templates/deployment.yaml + - templates/credential-secrets-role.yaml + - templates/credential-secrets-rolebinding.yaml +release: + name: openshell + namespace: my-namespace + +tests: + - it: renders default encrypted credential storage by default + template: templates/gateway-config.yaml + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'credential_drivers\s*=' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.gateway\.credential_storage\].*?key_encryption_key_env\s*=\s*"OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'key_encryption_key_path\s*=' + + - it: creates a retained default credential storage key-encryption key Secret by default + template: templates/credential-storage-key-encryption-key-secret.yaml + asserts: + - equal: + path: kind + value: Secret + - matchRegex: + path: metadata.name + pattern: 'credential-storage-key-encryption-key$' + - equal: + path: metadata.annotations["helm.sh/resource-policy"] + value: keep + - matchRegex: + path: data["key-encryption-key"] + pattern: '.+' + + - it: injects the default credential storage key-encryption key Secret into the gateway pod by default + template: templates/statefulset.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].env[0].name + value: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY + - matchRegex: + path: spec.template.spec.containers[0].env[0].valueFrom.secretKeyRef.name + pattern: 'credential-storage-key-encryption-key$' + - equal: + path: spec.template.spec.containers[0].env[0].valueFrom.secretKeyRef.key + value: key-encryption-key + + - it: renders Kubernetes Secrets credential driver config + template: templates/gateway-config.yaml + set: + server.credentialDrivers.kubernetesSecrets.enabled: true + server.credentialDrivers.kubernetesSecrets.namespace: provider-secrets + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: 'credential_drivers\s*=\s*\["kubernetes-secrets"\]' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.credential_drivers\.kubernetes-secrets\].*?namespace\s*=\s*"provider-secrets".*?allow_reference_namespace\s*=\s*false' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'transport\s*=\s*"in_tree"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.credential_storage\]' + + - it: renders Vault credential driver config + template: templates/gateway-config.yaml + set: + server.credentialDrivers.vault.enabled: true + server.credentialDrivers.vault.address: http://vault.vault.svc.cluster.local:8200 + server.credentialDrivers.vault.role: openshell-gateway + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: 'credential_drivers\s*=\s*\["vault"\]' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.credential_drivers\.vault\].*?address\s*=\s*"http://vault\.vault\.svc\.cluster\.local:8200".*?auth_method\s*=\s*"kubernetes".*?role\s*=\s*"openshell-gateway"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'transport\s*=\s*"in_tree"' + + - it: rejects multiple enabled credential drivers + template: templates/statefulset.yaml + set: + server.credentialDrivers.kubernetesSecrets.enabled: true + server.credentialDrivers.vault.enabled: true + server.credentialDrivers.vault.address: http://vault.vault.svc.cluster.local:8200 + server.credentialDrivers.vault.role: openshell-gateway + asserts: + - failedTemplate: + errorPattern: "only one external server.credentialDrivers backend can be enabled at a time" + + - it: creates namespaced Kubernetes Secret manager RBAC + template: templates/credential-secrets-role.yaml + set: + server.credentialDrivers.kubernetesSecrets.enabled: true + server.credentialDrivers.kubernetesSecrets.namespace: provider-secrets + asserts: + - equal: + path: metadata.namespace + value: provider-secrets + - equal: + path: rules[0].resources[0] + value: secrets + - equal: + path: rules[0].verbs[0] + value: get + - contains: + path: rules[0].verbs + content: create + - contains: + path: rules[0].verbs + content: patch + - contains: + path: rules[0].verbs + content: delete + + - it: binds Kubernetes Secret manager RBAC to the gateway ServiceAccount + template: templates/credential-secrets-rolebinding.yaml + set: + server.credentialDrivers.kubernetesSecrets.enabled: true + server.credentialDrivers.kubernetesSecrets.namespace: provider-secrets + asserts: + - equal: + path: metadata.namespace + value: provider-secrets + - equal: + path: subjects[0].name + value: openshell + - equal: + path: subjects[0].namespace + value: my-namespace + + - it: allows default credential storage on a Deployment with an external database + template: templates/deployment.yaml + set: + workload.kind: deployment + server.externalDbSecret: openshell-pg + asserts: + - equal: + path: kind + value: Deployment + - equal: + path: spec.template.spec.containers[0].env[0].name + value: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY + + - it: allows default credential storage with multiple replicas and an external database + template: templates/statefulset.yaml + set: + replicaCount: 2 + server.externalDbSecret: openshell-pg + workload.allowMultiReplicaStatefulSet: true + asserts: + - equal: + path: spec.replicas + value: 2 + - equal: + path: spec.template.spec.containers[0].env[0].name + value: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 90e4f9cef0..1380cb18c5 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -11,6 +11,22 @@ release: namespace: my-namespace tests: + - it: identifies the gateway by chart fullname by default + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"openshell"$' + + - it: renders an explicit gateway name + template: templates/gateway-config.yaml + set: + server.name: production-us-west + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"production-us-west"$' + # Regression for Drew's P2: a ConfigMap-only mutation in `helm upgrade` # must roll the StatefulSet, otherwise pods keep running with stale config. - it: annotates the StatefulSet pod template with a ConfigMap checksum @@ -42,6 +58,52 @@ tests: path: spec.template.spec.containers[0].name value: openshell-gateway + - it: enables anonymous telemetry by default + template: templates/statefulset.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_TELEMETRY_ENABLED + value: "true" + + - it: disables anonymous telemetry when configured + template: templates/statefulset.yaml + set: + server.telemetryEnabled: false + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_TELEMETRY_ENABLED + value: "false" + + - it: renders OTLP tracing configuration when configured + template: templates/gateway-config.yaml + set: + server.otlp.endpoint: http://otel-collector.observability.svc:4317 + server.otlp.serviceName: production-gateway + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.gateway\.otlp\].*?endpoint\s*=\s*"http://otel-collector\.observability\.svc:4317".*?service_name\s*=\s*"production-gateway"' + + - it: omits OTLP tracing configuration by default + template: templates/gateway-config.yaml + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.otlp\]' + + - it: treats a null OTLP map as disabled + template: templates/gateway-config.yaml + set: + server.otlp: null + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.otlp\]' + - it: mounts the OIDC CA bundle when TLS is disabled template: templates/statefulset.yaml set: @@ -93,6 +155,35 @@ tests: path: data["gateway.toml"] pattern: 'supervisor[_]topology\s*=' + - it: renders operator-owned upstream proxy settings under the Kubernetes driver + template: templates/gateway-config.yaml + set: + upstreamProxy.url: http://proxy.corp.example:8080 + upstreamProxy.noProxy: .svc.cluster.local,10.96.0.0/12 + upstreamProxy.authSecret.name: corporate-proxy-auth + upstreamProxy.authSecret.key: credentials + upstreamProxy.authAllowInsecure: true + upstreamProxy.connectByHostname: true + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.drivers\.kubernetes\].*?https_proxy\s*=\s*"http://proxy\.corp\.example:8080"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'proxy_auth_secret_name\s*=\s*"corporate-proxy-auth"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'proxy_auth_secret_key\s*=\s*"credentials"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'proxy_auth_allow_insecure\s*=\s*true' + - matchRegex: + path: data["gateway.toml"] + pattern: 'no_proxy\s*=\s*"\.svc\.cluster\.local,10\.96\.0\.0/12"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'proxy_connect_by_hostname\s*=\s*true' + - it: uses the gateway built-in supervisor image by default template: templates/gateway-config.yaml asserts: @@ -159,6 +250,13 @@ tests: path: data["gateway.toml"] pattern: '(?ms)\[openshell\.drivers\.kubernetes\.sidecar\].*?process_binary_aware_network_policy\s*=\s*false' + - it: configures managed SSH isolation with the gateway peer + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.drivers\.kubernetes\.managed_ssh_ingress\].*?enabled\s*=\s*true.*?gateway_namespace\s*=\s*"my-namespace".*?gateway_pod_selector\s*=.*?app\.kubernetes\.io/name.*?openshell' + - it: renders sandbox image pull secrets under [openshell.drivers.kubernetes] template: templates/gateway-config.yaml set: @@ -324,6 +422,94 @@ tests: path: data["gateway.toml"] pattern: '\[openshell\.gateway\.tls\]' + - it: renders client_ca_path for built-in PKI by default + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=\s*"/etc/openshell-tls/client-ca/ca\.crt"' + + - it: omits client_ca_path when clientCaSecretName is empty for HTTPS-only mode + template: templates/gateway-config.yaml + set: + server.tls.clientCaSecretName: "" + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.tls\]' + - matchRegex: + path: data["gateway.toml"] + pattern: 'cert_path\s*=\s*"/etc/openshell-tls/server/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'key_path\s*=\s*"/etc/openshell-tls/server/tls\.key"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + + - it: keeps external server certificate fields when clientCaSecretName is empty + template: templates/gateway-config.yaml + set: + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverDnsNames: + - gateway.example.com + server.tls.clientCaSecretName: "" + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + - matchRegex: + path: data["gateway.toml"] + pattern: 'external_cert_path\s*=\s*"/etc/openshell-tls/server-external/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'external_key_path\s*=\s*"/etc/openshell-tls/server-external/tls\.key"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'external_server_names\s*=\s*\["gateway\.example\.com"\]' + + - it: omits client_ca_path for cert-manager shared CA when clientCaSecretName is empty + template: templates/gateway-config.yaml + set: + certManager.enabled: true + certManager.clientCaFromServerTlsSecret: true + server.tls.clientCaSecretName: "" + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.tls\]' + - matchRegex: + path: data["gateway.toml"] + pattern: 'cert_path\s*=\s*"/etc/openshell-tls/server/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'key_path\s*=\s*"/etc/openshell-tls/server/tls\.key"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + + - it: omits client_ca_path when cert-manager owns TLS and no client CA secret is set + template: templates/gateway-config.yaml + set: + certManager.enabled: true + certManager.clientCaFromServerTlsSecret: false + pkiInitJob.enabled: true + server.tls.clientCaSecretName: "" + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '\[openshell\.gateway\.tls\]' + - matchRegex: + path: data["gateway.toml"] + pattern: 'cert_path\s*=\s*"/etc/openshell-tls/server/tls\.crt"' + - matchRegex: + path: data["gateway.toml"] + pattern: 'key_path\s*=\s*"/etc/openshell-tls/server/tls\.key"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'client_ca_path\s*=' + - it: renders server_sans from certManager.serverDnsNames set: certManager.enabled: true @@ -344,6 +530,14 @@ tests: path: spec.template.spec.containers[0].args content: "sqlite:/var/openshell/openshell.db" + - it: fails when clientCaSecretName is null + template: templates/statefulset.yaml + set: + server.tls.clientCaSecretName: null + asserts: + - failedTemplate: + errorMessage: "server.tls.clientCaSecretName cannot be null; omit the key to use the chart default (openshell-server-client-ca), or set to \"\" to disable client certificate verification for HTTPS-only mode" + - it: fails when legacy postgres.enabled is set template: templates/statefulset.yaml set: @@ -366,6 +560,7 @@ tests: workload.kind: deployment replicaCount: 2 server.externalDbSecret: my-pg-secret + server.credentialDrivers.kubernetesSecrets.enabled: true asserts: - equal: path: kind @@ -413,6 +608,7 @@ tests: replicaCount: 2 server.externalDbSecret: my-pg-secret workload.allowMultiReplicaStatefulSet: true + server.credentialDrivers.kubernetesSecrets.enabled: true asserts: - equal: path: kind @@ -491,11 +687,48 @@ tests: path: data["gateway.toml"] pattern: '\[openshell\.gateway\.spiffe\]' - - it: keeps the gateway sandbox JWT secret mounted when provider SPIFFE grants are enabled + - it: mounts the gateway SPIFFE socket while keeping sandbox JWT auth set: server.providerTokenGrants.spiffe.enabled: true template: templates/statefulset.yaml asserts: - - matchRegex: - path: spec.template.spec.volumes[1].name - pattern: '^sandbox-jwt$' + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + value: /spiffe-workload-api/spire-agent.sock + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: sandbox-jwt + mountPath: /etc/openshell-jwt + readOnly: true + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: spiffe-workload-api + mountPath: /spiffe-workload-api + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: sandbox-jwt + secret: + defaultMode: 256 + secretName: openshell-jwt-keys + - contains: + path: spec.template.spec.volumes + content: + name: spiffe-workload-api + csi: + driver: csi.spiffe.io + readOnly: true + + - it: fails when serverIssuerRef is set but certManager is disabled + template: templates/statefulset.yaml + set: + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.enabled: false + asserts: + - failedTemplate: + errorMessage: "certManager.serverIssuerRef.name is set but certManager.enabled is false \u2014 the external server certificate, its Secret mount, and the gateway TLS configuration all require cert-manager to be enabled. Set certManager.enabled=true or remove certManager.serverIssuerRef.name." diff --git a/deploy/helm/openshell/tests/route_test.yaml b/deploy/helm/openshell/tests/route_test.yaml new file mode 100644 index 0000000000..6de943bee3 --- /dev/null +++ b/deploy/helm/openshell/tests/route_test.yaml @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: OpenShift Route +templates: + - templates/route.yaml +release: + name: openshell + namespace: my-namespace + +tests: + - it: renders nothing by default + asserts: + - hasDocuments: + count: 0 + + - it: renders a passthrough Route when enabled + set: + openshiftRoute.enabled: true + openshiftRoute.host: openshell.apps.example.com + asserts: + - isKind: + of: Route + - equal: + path: apiVersion + value: route.openshift.io/v1 + - equal: + path: spec.host + value: openshell.apps.example.com + - equal: + path: spec.to.kind + value: Service + - equal: + path: spec.to.name + value: openshell + - equal: + path: spec.port.targetPort + value: grpc + - equal: + path: spec.tls.termination + value: passthrough + - equal: + path: spec.wildcardPolicy + value: None + + - it: omits host when not set + set: + openshiftRoute.enabled: true + asserts: + - notExists: + path: spec.host + + - it: fails when passthrough Route is enabled with TLS disabled + set: + openshiftRoute.enabled: true + server.disableTls: true + asserts: + - failedTemplate: + errorMessage: "openshiftRoute.enabled=true requires TLS (server.disableTls must be false) \u2014 a passthrough Route forwards encrypted traffic by SNI, so the gateway must terminate its own TLS." + + - it: fails when external issuer is set but Route host is empty + set: + openshiftRoute.enabled: true + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverDnsNames: + - openshell.example.com + asserts: + - failedTemplate: + errorMessage: "openshiftRoute.enabled=true with certManager.serverIssuerRef requires an explicit openshiftRoute.host \u2014 without one, OpenShift generates a hostname absent from certManager.serverDnsNames, causing the gateway to serve its internal certificate to external clients." + + - it: accepts a Route host covered by a wildcard serverDnsNames entry + set: + openshiftRoute.enabled: true + openshiftRoute.host: gateway.example.com + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverDnsNames: + - "*.example.com" + asserts: + - equal: + path: spec.host + value: gateway.example.com + + - it: rejects a Route host not covered by wildcard or exact serverDnsNames + set: + openshiftRoute.enabled: true + openshiftRoute.host: gateway.other.com + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverDnsNames: + - "*.example.com" + asserts: + - failedTemplate: + errorMessage: "openshiftRoute.host \"gateway.other.com\" is not covered by certManager.serverDnsNames [*.example.com] \u2014 the Route will forward SNI for a hostname the external certificate does not cover, causing TLS verification failures for CLI clients. Exact names and single-level wildcards (e.g. *.example.com) are checked." + + - it: rejects a multi-level subdomain against a single-level wildcard + set: + openshiftRoute.enabled: true + openshiftRoute.host: deep.sub.example.com + certManager.enabled: true + certManager.serverIssuerRef.name: letsencrypt-prod + certManager.serverDnsNames: + - "*.example.com" + asserts: + - failedTemplate: + errorMessage: "openshiftRoute.host \"deep.sub.example.com\" is not covered by certManager.serverDnsNames [*.example.com] \u2014 the Route will forward SNI for a hostname the external certificate does not cover, causing TLS verification failures for CLI clients. Exact names and single-level wildcards (e.g. *.example.com) are checked." + + - it: renders custom annotations + set: + openshiftRoute.enabled: true + openshiftRoute.host: openshell.apps.example.com + openshiftRoute.annotations: + haproxy.router.openshift.io/balance: roundrobin + asserts: + - equal: + path: metadata.annotations["haproxy.router.openshift.io/balance"] + value: roundrobin diff --git a/deploy/helm/openshell/tests/sandbox_namespace_test.yaml b/deploy/helm/openshell/tests/sandbox_namespace_test.yaml index ee89fce53d..864e3a8512 100644 --- a/deploy/helm/openshell/tests/sandbox_namespace_test.yaml +++ b/deploy/helm/openshell/tests/sandbox_namespace_test.yaml @@ -75,3 +75,21 @@ tests: path: metadata.namespace value: other-ns documentIndex: 1 + + - it: omits workspace resources in gateway-only mode + set: + workspaceResources.enabled: false + networkPolicy.enabled: true + asserts: + - hasDocuments: + count: 1 + template: templates/gateway-config.yaml + - hasDocuments: + count: 0 + template: templates/networkpolicy.yaml + - hasDocuments: + count: 0 + template: templates/role.yaml + - hasDocuments: + count: 0 + template: templates/rolebinding.yaml diff --git a/deploy/helm/openshell/tests/sandbox_service_account_test.yaml b/deploy/helm/openshell/tests/sandbox_service_account_test.yaml index c426415823..c9f10868fe 100644 --- a/deploy/helm/openshell/tests/sandbox_service_account_test.yaml +++ b/deploy/helm/openshell/tests/sandbox_service_account_test.yaml @@ -29,3 +29,13 @@ tests: asserts: - hasDocuments: count: 1 + + - it: renders only the gateway service account in gateway-only mode + set: + workspaceResources.enabled: false + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: openshell diff --git a/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml b/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml index a7b02310cf..813e47fbe0 100644 --- a/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml +++ b/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml @@ -53,13 +53,14 @@ tests: certManager.enabled: true certManager.clientCaFromServerTlsSecret: false pkiInitJob.enabled: true + server.tls.clientCaSecretName: openshell-ca-tls asserts: - equal: path: spec.template.spec.volumes[3].name value: tls-client-ca - equal: path: spec.template.spec.volumes[3].secret.secretName - value: openshell-server-client-ca + value: openshell-ca-tls - notExists: path: spec.template.spec.volumes[3].secret.items @@ -83,3 +84,39 @@ tests: name: tls-client-ca mountPath: /etc/openshell-tls/client-ca readOnly: true + + # Explicit HTTPS-only opt-out must suppress built-in PKI client-CA wiring even + # when pkiInitJob remains enabled (the default install path from #2095). + - it: omits the client CA volume for built-in PKI when clientCaSecretName is empty + template: templates/statefulset.yaml + set: + pkiInitJob.enabled: true + certManager.enabled: false + server.tls.clientCaSecretName: "" + asserts: + - lengthEqual: + path: spec.template.spec.volumes + count: 3 + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: tls-client-ca + mountPath: /etc/openshell-tls/client-ca + readOnly: true + + - it: omits the client CA volume when cert-manager shares server CA and clientCaSecretName is empty + template: templates/statefulset.yaml + set: + certManager.enabled: true + certManager.clientCaFromServerTlsSecret: true + server.tls.clientCaSecretName: "" + asserts: + - lengthEqual: + path: spec.template.spec.volumes + count: 3 + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: tls-client-ca + mountPath: /etc/openshell-tls/client-ca + readOnly: true diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 0525ed475d..453654f790 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -62,6 +62,23 @@ supervisor: # policy.binaries. processBinaryAwareNetworkPolicy: true +# -- Operator-owned corporate forward proxy for policy-approved TLS egress +# from Kubernetes sandboxes. The workload cannot select or override it. +upstreamProxy: + # -- HTTP proxy URL in http://host:port form. HTTPS-to-proxy is not supported. + url: "" + # -- Comma-separated destinations that bypass only the corporate proxy. + noProxy: "" + authSecret: + # -- Existing Secret in the sandbox namespace containing a user:pass value. + name: "" + # -- Secret key containing the proxy credential. + key: "" + # -- Required when authSecret is configured because Basic auth to an HTTP proxy is cleartext. + authAllowInsecure: false + # -- Last-resort option for hostname-filtering proxy ACLs. It lets the proxy resolve CONNECT targets. + connectByHostname: false + # -- Image pull secrets attached to gateway and helper pods. imagePullSecrets: [] # -- Override the chart name used in generated resource names. @@ -85,6 +102,14 @@ sandboxServiceAccount: # -- Existing service account name for sandbox pods when sandboxServiceAccount.create is false. name: "" +# Namespace-scoped resources needed to run sandboxes. Disable this when the +# gateway and workspace prerequisites are managed as separate Helm releases +# using the openshell-workspace chart. +workspaceResources: + # -- Create the sandbox ServiceAccount, Role, RoleBinding, and NetworkPolicy + # from this chart. Disable for a gateway-only release. + enabled: true + # -- Extra annotations to add to the gateway pod. podAnnotations: {} # -- Extra labels to add to the gateway pod. @@ -116,6 +141,16 @@ service: # -- Gateway metrics service port. metricsPort: 9090 +# Agent Sandbox is a cluster-scoped prerequisite for the Kubernetes compute +# driver. OpenShell deliberately does not install its CRDs or controller. +# Enable this check for live Helm installs to fail before creating gateway +# resources when neither supported Sandbox API is served. Disable it for +# offline `helm template` and lint workflows, which cannot discover APIs. +agentSandbox: + preflight: + # -- Check the live cluster for a supported Agent Sandbox API before rendering gateway resources. Disable only for offline rendering and linting. + enabled: true + # Pod restart behavior and health probe tuning. podLifecycle: # -- Grace period, in seconds, before Kubernetes terminates the gateway pod. @@ -162,8 +197,21 @@ affinity: {} # Server configuration server: + # -- Operator-facing gateway name. Defaults to the chart fullname so all + # replicas in one installation share an identity. Set explicitly when one + # telemetry collector receives spans from multiple namespaces or clusters. + name: "" # -- Gateway log level. logLevel: info + # OpenTelemetry trace export over OTLP/gRPC. Leave endpoint empty to disable. + otlp: + # -- OTLP/gRPC collector endpoint, conventionally using port 4317. + endpoint: "" + # -- Gateway OpenTelemetry service name. Empty uses openshell-gateway. + serviceName: "" + # -- Enable anonymous OpenShell telemetry from the gateway and the sandbox + # supervisors it launches. + telemetryEnabled: true # -- Namespace where sandbox pods are created. Defaults to the Helm release # namespace (.Release.Namespace) when left empty. sandboxNamespace: "" @@ -223,6 +271,20 @@ server: # the field, "RuntimeDefault" to force the runtime default profile, or # "Localhost/profile-name" for an operator-managed localhost profile. appArmorProfile: "Unconfined" + # Kubernetes compute driver settings. + drivers: + kubernetes: + # -- How workspaces map to Kubernetes namespaces. + # "shared" (default): all sandboxes in a single namespace. + # "managed": auto-creates per-workspace namespaces. + # "operator": uses pre-provisioned namespaces. + workspaceMode: "shared" + # -- K8s label selector for namespace discovery in operator mode. + # The driver watches namespaces matching this label. + operatorNamespaceLabel: "" + # -- Path to a JSON file containing an array of namespace names + # allowed in operator mode. Hot-reloaded on change. + operatorNamespaceFile: "" # -- Disable TLS entirely - the server listens on plaintext HTTP. # Set to true when a reverse proxy / tunnel terminates TLS at the edge. disableTls: false @@ -243,6 +305,58 @@ server: # -- gRPC rate-limit window length in seconds. Must be positive (alongside # requests) to enable rate limiting; 0 (default) disables it. windowSeconds: 0 + # Default credential storage settings (used when no credential driver is + # enabled). The gateway encrypts provider credentials in the database using + # AES-256-GCM with a key-encryption key (KEK). By default, the Helm chart + # generates and retains a KEK Secret. For GitOps / helm-template workflows + # where `lookup` is unavailable, reference a pre-created Secret instead. + credentialStorage: + # -- Name of a pre-existing Secret containing the key-encryption key. + # When set, the chart does NOT generate a new Secret; it references this + # one instead. The Secret must contain a key named "key-encryption-key" + # with a base64-encoded 32-byte value. Required for GitOps workflows that + # render manifests with `helm template` (where `lookup` is unavailable). + existingSecret: "" + # Provider credential drivers store provider credential secret material in an + # external or native backend. When no driver is enabled, the gateway uses its + # default encrypted database credential storage with a retained Kubernetes + # Secret for the shared key-encryption key. + credentialDrivers: + kubernetesSecrets: + # -- Enable the in-tree Kubernetes Secret credential driver. + # WARNING: The RBAC Role grants read/write access to ALL Secrets in the + # configured namespace. Use a dedicated namespace to limit blast radius. + enabled: false + # -- Namespace where OpenShell-managed provider Secret objects are stored. + # Empty = Helm release namespace. A dedicated namespace is RECOMMENDED + # to isolate OpenShell-managed Secrets from other workloads. + namespace: "" + # -- Deprecated compatibility field. Credential storage no longer supports user-authored namespace references. + allowReferenceNamespace: false + rbac: + # -- Create a Role/RoleBinding granting the gateway ServiceAccount read/write access to managed provider Secrets. + create: true + vault: + # -- Enable the in-tree Vault credential driver. + enabled: false + # -- Vault service base URL, for example http://vault.vault.svc.cluster.local:8200. + address: "" + # -- Default KV mount name. + mount: secret + # -- Default KV engine version. Use "1" or "2". + kvVersion: "2" + # -- Authentication method. Use "kubernetes" in-cluster or "token_file" for local/dev validation. + authMethod: kubernetes + # -- Vault Kubernetes auth role when authMethod is kubernetes. + role: "" + # -- Vault Kubernetes auth mount. + kubernetesAuthMount: kubernetes + # -- ServiceAccount token path used for Kubernetes auth. + serviceAccountTokenPath: /var/run/secrets/kubernetes.io/serviceaccount/token + # -- Mounted token file path when authMethod is token_file. + tokenPath: "" + # -- HTTP request timeout in seconds. Empty = driver default. + timeoutSecs: "" auth: # -- UNSAFE: accept unauthenticated CLI/user requests as a local developer # principal. Intended only for trusted local Skaffold/k3d development or a @@ -253,6 +367,7 @@ server: certSecretName: openshell-server-tls # -- K8s secret with ca.crt for client certificate verification (mTLS). # Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). + # Do not set to null; omit the key to use the default secret name above. clientCaSecretName: openshell-server-client-ca # -- K8s secret mounted into sandbox pods for mTLS to the server. clientTlsSecretName: openshell-client-tls @@ -280,15 +395,15 @@ server: # (owner-read only). Override to 0440 or 0444 if the container UID # does not match the volume file owner. secretDefaultMode: "" - # Dynamic provider token grants. When SPIFFE is enabled here, sandbox - # supervisors mount the SPIFFE Workload API socket so provider profiles can - # exchange JWT-SVIDs for upstream access tokens. Supervisor-to-gateway - # authentication still uses gateway-minted sandbox JWTs. + # Dynamic provider token grants. When SPIFFE is enabled here, both the + # gateway and sandbox supervisors mount the SPIFFE Workload API socket so + # token-exchange profiles can use gateway- and sandbox-scoped JWT-SVIDs. + # Supervisor-to-gateway authentication still uses gateway-minted sandbox JWTs. providerTokenGrants: spiffe: - # -- Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. + # -- Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. enabled: false - # -- Path to the SPIFFE Workload API socket mounted into sandbox pods. + # -- Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. workloadApiSocketPath: /spiffe-workload-api/spire-agent.sock # OIDC (OpenID Connect) configuration for JWT-based authentication. # When issuer is set, the server validates Bearer tokens on gRPC requests. @@ -298,7 +413,7 @@ server: # -- Expected audience claim for the API resource server. # This should match the server's --oidc-audience, NOT the CLI client ID. audience: "openshell-cli" - # -- JWKS key cache TTL in seconds. + # -- JWKS key cache TTL in seconds. Must be greater than zero. jwksTtl: 3600 # -- Dot-separated path to the roles array in the JWT claims. # Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". @@ -317,7 +432,8 @@ server: # NetworkPolicy restricting SSH ingress on sandbox pods to the gateway only. networkPolicy: - # -- Create a NetworkPolicy restricting SSH ingress on sandbox pods to the gateway. + # -- Restrict SSH ingress on sandbox pods to the gateway. In managed mode, + # the driver applies the equivalent policy to each workspace namespace. enabled: true # Built-in TLS PKI bootstrap via a pre-install/pre-upgrade hook Job. @@ -345,7 +461,7 @@ pkiInitJob: serverIpAddresses: [] # cert-manager Certificate/Issuer resources (requires cert-manager CRDs in-cluster). -# Uses namespaced Issuers only (no ClusterIssuer). Does not install cert-manager itself. +# Does not install cert-manager itself. certManager: # -- Create cert-manager Issuer and Certificate resources. When enabled, # cert-manager owns TLS and the chart runs a JWT-only certgen hook to create @@ -353,9 +469,22 @@ certManager: enabled: false # -- Secret created for the intermediate CA (Certificate with isCA: true). caSecretName: openshell-ca-tls - # -- Mount gateway client CA from the server TLS secret's ca.crt (populated by - # cert-manager for certs issued by a CA Issuer). Avoids a separate - # openshell-server-client-ca Secret. + # -- Override the issuerRef for the external server Certificate (e.g. a real + # ACME ClusterIssuer for a publicly-trusted cert on an external hostname). + # When set, the chart creates a second server certificate from this issuer + # with only the hostnames in serverDnsNames; the internal server certificate + # is always signed by the chart's own CA. Leave name empty to use the chart + # CA for all server certificates (default). Requires certManager.enabled=true. + serverIssuerRef: + name: "" + kind: "" + group: "" + # -- Mount gateway client CA from the internal server TLS secret's ca.crt. + # The internal server certificate is always signed by the chart CA — the same + # CA that signs the client (mTLS) certificate — so the default (true) is + # correct for all configurations, including when serverIssuerRef is set. + # Only set to false if you mount the client CA from a separate secret via + # server.tls.clientCaSecretName. clientCaFromServerTlsSecret: true # -- Duration for cert-manager-issued certificates. certificateDuration: 8760h @@ -415,3 +544,15 @@ grpcRoute: # or the existing openshell-server-tls Secret (its SANs must include the # external hostname). certificateRefs: [] + +# OpenShift Route with TLS passthrough. The gateway terminates its own +# TLS/mTLS; the router only forwards based on SNI, so it never sees plaintext +# or the client certificate. Requires server.disableTls=false and a server +# cert whose SANs include the Route host (see certManager.serverIssuerRef). +openshiftRoute: + # -- Create an OpenShift Route with TLS passthrough. + enabled: false + # -- Hostname for the Route. Must match a SAN on the gateway's server cert. + host: "" + # -- Extra annotations on the Route (e.g. haproxy.router.openshift.io/*). + annotations: {} diff --git a/deploy/helm/test-split-ownership.sh b/deploy/helm/test-split-ownership.sh new file mode 100755 index 0000000000..30fd7360d4 --- /dev/null +++ b/deploy/helm/test-split-ownership.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +work_dir="$(mktemp -d)" +trap 'rm -rf "${work_dir}"' EXIT + +helm template openshell "${repo_root}/deploy/helm/openshell" \ + --namespace openshell \ + --set agentSandbox.preflight.enabled=false \ + --set workspaceResources.enabled=false \ + >"${work_dir}/gateway.yaml" + +if yq ea -e \ + 'select(.kind == "Role" and .metadata.name == "openshell-sandbox")' \ + "${work_dir}/gateway.yaml" >/dev/null 2>&1; then + echo "gateway chart rendered workspace resources despite workspaceResources.enabled=false" >&2 + exit 1 +fi + +helm template openshell-workspace "${repo_root}/deploy/helm/openshell-workspace" \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell \ + >"${work_dir}/workspace.yaml" + +invalid_workspace_docs="$( + yq ea -N -r \ + 'select(. != null and (.apiVersion == null or .kind == null)) | document_index' \ + "${work_dir}/workspace.yaml" +)" +if [[ -n "${invalid_workspace_docs}" ]]; then + echo "workspace chart rendered documents without apiVersion or kind: ${invalid_workspace_docs}" >&2 + exit 1 +fi + +helm template openshell "${repo_root}/deploy/helm/openshell" \ + --namespace openshell \ + --set agentSandbox.preflight.enabled=false \ + --set-json workspaceResources=null \ + >"${work_dir}/legacy-reuse-values.yaml" + +yq ea -e \ + 'select(.kind == "Role" and .metadata.name == "openshell-sandbox") | + .apiVersion == "rbac.authorization.k8s.io/v1"' \ + "${work_dir}/legacy-reuse-values.yaml" >/dev/null + +yq ea -N -r \ + 'select(.kind != null) | [.apiVersion, .kind, (.metadata.namespace // "openshell"), .metadata.name] | @tsv' \ + "${work_dir}/gateway.yaml" | sort -u >"${work_dir}/gateway.objects" +yq ea -N -r \ + 'select(.kind != null) | [.apiVersion, .kind, (.metadata.namespace // "app-a"), .metadata.name] | @tsv' \ + "${work_dir}/workspace.yaml" | sort -u >"${work_dir}/workspace.objects" + +comm -12 "${work_dir}/gateway.objects" "${work_dir}/workspace.objects" \ + >"${work_dir}/overlap.objects" +if [[ -s "${work_dir}/overlap.objects" ]]; then + echo "gateway and workspace charts claim the same Kubernetes objects:" >&2 + cat "${work_dir}/overlap.objects" >&2 + exit 1 +fi + +echo "gateway and workspace chart object ownership is disjoint" diff --git a/deploy/sbom/resolve_licenses.py b/deploy/sbom/resolve_licenses.py index 237e61979a..2ebb5b59d8 100644 --- a/deploy/sbom/resolve_licenses.py +++ b/deploy/sbom/resolve_licenses.py @@ -345,6 +345,10 @@ def needs_fix(comp: dict) -> bool: if not licenses: return True for entry in licenses: + if "expression" in entry: + if entry["expression"].startswith("sha256:"): + return True + continue lic = entry.get("license", {}) lid = lic.get("id", "") lname = lic.get("name", "") diff --git a/deploy/sbom/resolve_licenses_test.py b/deploy/sbom/resolve_licenses_test.py index 7e8ee0b480..5e46b70010 100644 --- a/deploy/sbom/resolve_licenses_test.py +++ b/deploy/sbom/resolve_licenses_test.py @@ -8,7 +8,39 @@ import time from concurrent.futures import ThreadPoolExecutor -from resolve_licenses import _last_request, _rate_limit, _rate_lock +from resolve_licenses import _last_request, _rate_limit, _rate_lock, needs_fix + + +def test_empty_licenses_needs_fix() -> None: + assert needs_fix({"licenses": []}) + + +def test_no_licenses_key_needs_fix() -> None: + assert needs_fix({}) + + +def test_sha256_in_license_id_needs_fix() -> None: + assert needs_fix({"licenses": [{"license": {"id": "sha256:abc123"}}]}) + + +def test_sha256_in_license_name_needs_fix() -> None: + assert needs_fix({"licenses": [{"license": {"name": "sha256:abc123"}}]}) + + +def test_sha256_expression_needs_fix() -> None: + assert needs_fix({"licenses": [{"expression": "sha256:abc123"}]}) + + +def test_valid_spdx_expression_no_fix() -> None: + assert not needs_fix({"licenses": [{"expression": "MIT OR Apache-2.0"}]}) + + +def test_valid_license_id_no_fix() -> None: + assert not needs_fix({"licenses": [{"license": {"id": "MIT"}}]}) + + +def test_valid_license_name_no_fix() -> None: + assert not needs_fix({"licenses": [{"license": {"name": "MIT"}}]}) def test_same_domain_requests_are_spaced() -> None: diff --git a/docs/CONTRIBUTING.mdx b/docs/CONTRIBUTING.mdx index 9a69c4b445..b7a47ce986 100644 --- a/docs/CONTRIBUTING.mdx +++ b/docs/CONTRIBUTING.mdx @@ -14,10 +14,10 @@ If you use an AI coding agent (Cursor, Claude Code, Codex, etc.), the repo inclu | Skill | What it does | When to use | |---|---|---| -| `update-docs` | Scans recent commits for user-facing changes and drafts doc updates. | After landing features, before a release, or to find doc gaps. | +| `update-docs-from-commits` | Scans recent commits for user-facing changes and drafts doc updates. | After landing features, before a release, or to find doc gaps. | | `build-from-issue` | Plans and implements work from a GitHub issue, including doc updates. | When working from an issue that has doc impact. | -The skills live in `.agents/skills/` and follow the style guide below automatically. To use one, ask your agent to run it (e.g., "catch up the docs for everything merged since v0.2.0"). +These contributor workflows live in `.agents/skills/` and follow the style guide below automatically. They are separate from the public skills in `skills/`, which help users operate OpenShell and can be installed without cloning the repository. To use a contributor skill, ask your repository-aware agent to run it (e.g., "catch up the docs for everything merged since v0.2.0"). ## When to Update Docs diff --git a/docs/_components/jsx.d.ts b/docs/_components/jsx.d.ts index b03bbc0f27..7aceb005a5 100644 --- a/docs/_components/jsx.d.ts +++ b/docs/_components/jsx.d.ts @@ -1 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + declare const React: unknown; \ No newline at end of file diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index 58cec98f37..a733a1b881 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -20,6 +20,12 @@ The script detects your operating system and installs the OpenShell CLI and gate You can also download release artifacts directly from the [OpenShell GitHub Releases](https://github.com/NVIDIA/OpenShell/releases) page. +The `openshell` package on PyPI provides the Python SDK only. It does not install the `openshell` CLI. Add the SDK to a Python project with: + +```shell +uv add openshell +``` + Use `openshell status` to confirm the CLI can reach the gateway. ## Supported Compute Drivers @@ -38,7 +44,7 @@ For detailed driver behavior, refer to [Sandbox Compute Drivers](/reference/sand On macOS, the install script uses Homebrew. The Homebrew package installs the `openshell` CLI, the gateway binary, and a Homebrew-managed gateway service. -The Homebrew service listens on `https://[::1]:17670` and generates a local mTLS bundle on install. The formula creates a Homebrew prefix config, such as `/opt/homebrew/var/openshell/gateway.toml`, with this IPv6 loopback default so Podman can use its separate IPv4 loopback callback listener. The gateway reads `~/.config/openshell/gateway.toml` instead when that file exists. Homebrew preserves existing prefix and user configs during upgrades. +The Homebrew service uses the gateway's built-in `127.0.0.1:17670` listener and generates a local mTLS bundle on install. The installer registers `https://localhost:17670` with the CLI so TLS uses a DNS name covered by the generated certificate. The formula creates a Homebrew prefix config, such as `/opt/homebrew/var/openshell/gateway.toml`, without overriding `bind_address`. Docker Desktop and Podman Machine reuse the primary listener for sandbox callbacks when they can reach it. The gateway reads `~/.config/openshell/gateway.toml` instead when that file exists. Homebrew preserves user-edited prefix and user configs during upgrades; it removes the IPv6 bind only from an unchanged config generated by the affected formula. The CLI reads the client bundle from `~/.config/openshell/gateways/openshell/mtls/`. diff --git a/docs/brand/assets/openshell-banner-dark.png b/docs/brand/assets/openshell-banner-dark.png new file mode 100644 index 0000000000..6c7d11f6e4 Binary files /dev/null and b/docs/brand/assets/openshell-banner-dark.png differ diff --git a/docs/brand/assets/openshell-banner-light.png b/docs/brand/assets/openshell-banner-light.png new file mode 100644 index 0000000000..c2a951a7d7 Binary files /dev/null and b/docs/brand/assets/openshell-banner-light.png differ diff --git a/docs/extensibility/gateway-interceptors.mdx b/docs/extensibility/gateway-interceptors.mdx index 9fe50ef5a7..bf9656a5ed 100644 --- a/docs/extensibility/gateway-interceptors.mdx +++ b/docs/extensibility/gateway-interceptors.mdx @@ -73,7 +73,9 @@ Start the interceptor before the gateway, then register it in gateway TOML: ```toml [[openshell.gateway.interceptors]] name = "policy-governance" -grpc_endpoint = "http://127.0.0.1:18081" +grpc_endpoint = "https://governance.example:18081" +tls_ca_cert_path = "/etc/openshell/governance-ca.pem" +audience = "urn:example:governance" order = 10 failure_policy = "fail_closed" binding_policy = "allowlist" @@ -90,7 +92,15 @@ rpc = "openshell.v1.OpenShell/UpdateConfig" phases = ["validate"] ``` -The gateway supports `http://`, `https://`, and `unix://` interceptor endpoints. It calls `Describe` and builds an immutable execution plan during startup. An unavailable service, invalid manifest, or unauthorized configured binding prevents the gateway from starting. +The gateway supports `http://`, `https://`, and `unix://` interceptor endpoints. When gateway JWT signing is configured, authenticated network interceptors use `https://`; Unix sockets remain available for local integrations. HTTPS uses platform trust roots unless `tls_ca_cert_path` supplies a private CA, and normal hostname verification remains enabled. The gateway calls `Describe` and builds an immutable execution plan during startup. An unavailable service, invalid manifest, missing credential, or unauthorized configured binding prevents the gateway from starting. + +The gateway attaches a short-lived EdDSA bearer token to `Describe`, `Evaluate`, and provider-profile snapshot calls. The token uses the configured `audience` (defaulting to `urn:openshell:extension:interceptor:`) and `caller_kind: gateway`. + +Return your expected audience in the `expected_audience` field of your `Describe` manifest. After authenticated `Describe` succeeds, the gateway compares the advertised value with its operator-configured audience and refuses to start when they differ. This is a post-authentication consistency assertion, not audience discovery: a strict verifier may reject an incorrect audience before returning the manifest, in which case startup reports an authentication failure. Leave the field empty to skip the consistency check. + +Provision the trusted gateway URL, expected gateway ID, and public key or JWKS through the deployment. This operator-provisioned key material is the authoritative cold-start trust anchor. The expected issuer is exactly `openshell-gateway:`; fetching JWKS does not establish that identity by itself. After initial trust is established, `GET /.well-known/openid-configuration` and its `jwks_uri` provide steady-state key refresh and operational convenience. The document is OIDC-shaped rather than OIDC-compliant because `issuer` is the gateway identity rather than the serving URL; compare `iss` against the configured value and fetch updates only over authenticated TLS at the trusted gateway URL. Pin `alg` to `EdDSA`, require `typ` to be exactly `openshell-ext+jwt`, and validate `kid`, signature, expected issuer, exact audience, positive expiry, and caller kind. + +Set `allow_insecure_transport = true` on an interceptor to keep a plaintext `http://` endpoint working with no credential attached. The gateway logs a warning naming the interceptor at every startup, and the service cannot distinguish the gateway from any other client that can reach it. Registration is static. Restart the gateway after adding, removing, or changing an interceptor. See [Gateway Configuration](/reference/gateway-config#gateway-interceptors) for the complete field reference. @@ -163,5 +173,6 @@ The gateway emits structured evaluation logs containing the interceptor name, bi - Only explicitly allowlisted unary write RPCs are interceptable. New gateway RPCs are non-interceptable until added to the allowlist. - `current_state` is available only in the `validate` contract. The gateway does not yet populate it with method-specific state. - Registration changes require a gateway restart. -- Custom TLS roots, client authentication, service health checks, and runtime registration are not available. +- mTLS client authentication, service health checks, runtime registration, and overlapping signing-key rotation are not available. +- Extension tokens and sandbox-to-gateway tokens are signed by the same key, separated by audience and `typ`. The extension credential path cannot yet be rotated or revoked independently of sandbox admission. - Interceptors cannot receive or mutate protobuf fields marked secret. diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index f3bdcac9bb..9f3c723b16 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -3,11 +3,11 @@ # SPDX-License-Identifier: Apache-2.0 title: "Supervisor Middleware" sidebar-title: "Supervisor Middleware" -description: "Configure and operate built-in and operator-run middleware for sandbox HTTP requests." +description: "Configure and operate built-in and operator-run middleware for sandbox HTTP requests and WebSocket messages." keywords: "Generative AI, Cybersecurity, AI Agents, Supervisor Middleware, Extensibility, Request Filtering" --- -Supervisor middleware adds ordered request-processing stages to allowed HTTP egress. Middleware runs after network and L7 policy admit a request and before OpenShell injects provider credentials. A stage can allow or deny the request, replace its body, add approved headers, and report audit-safe findings. +Supervisor middleware adds ordered processing stages to allowed HTTP and WebSocket egress. Middleware runs after network and L7 policy admit traffic and before OpenShell injects provider credentials. A stage can allow or deny an HTTP request or client WebSocket text message, replace its payload, add approved HTTP headers, and report audit-safe findings. Middleware selection is independent of the network policy rule that admitted the request. OpenShell matches middleware by destination host, so the same middleware applies consistently across broad, specific, user-authored, and provider-derived network policies. @@ -22,22 +22,37 @@ For each inspected HTTP request, the supervisor: 5. Re-checks body-aware protocol policy (GraphQL, JSON-RPC, MCP) after each stage that replaces the body. Every middleware receives a payload the policy admits, and a transformation cannot smuggle a denied or unparseable operation to a later stage or the upstream. 6. Applies allowed transformations, injects provider credentials, and forwards the request. +For an RFC 6455 upgrade over `ws://` or `wss://`, the supervisor first finds every host-matched attachment, then selects only implementations that advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It opens one ordered, phase-specific `EvaluateWebSocketSession` stream per selected stage. OpenShell sends `WebSocketSessionEvent` values, while the service returns `WebSocketSessionEventResult` values only for preflight and message events; session start and end are notifications. Future upstream-to-client inspection uses the same RPC with `PRE_RETURN`; an implementation that advertises both phases receives two independent streams for the WebSocket session. An attachment without the selected binding can still inspect the HTTP upgrade request when it advertises the HTTP binding, but it is not a failed WebSocket stage. OpenShell allows post-upgrade traffic and emits an informational `binding_not_selected` coverage event for that attachment. + +1. A preflight before the upgrade is sent upstream. The stage chooses `INSPECT`, voluntary `SKIP`, or authoritative `DENY` and may return a bounded diagnostic reason, stable reason code, findings, and metadata. OpenShell runs selected preflights concurrently; any `DENY` rejects the upgrade regardless of `on_error`. +2. A session-start event after the upstream accepts the upgrade, including the negotiated subprotocol. +3. Complete client-to-upstream text messages in sequence order. OpenShell reassembles fragmented messages and decompresses negotiated `permessage-deflate` messages before evaluation. +4. A best-effort session-end event when the stage stream remains writable. OpenShell attempts at most one terminal event for each opened stream, including streams opened during a preflight that rejects the upgrade before session start. It then half-closes the request stream and briefly drains the response stream so the terminal event can leave the local transport before the RPC closes. Middleware services should finish their response stream after the request stream reaches EOF. + +The protobuf represents each logical message with a `text` or `binary` payload variant. Text uses the protobuf `string` type, so invalid UTF-8 cannot enter the middleware contract. Results use an optional matching replacement variant: absence preserves the input, while presence represents a replacement even when its content is empty. OpenShell rejects attempts to change the message type. Allowed replacements are re-framed, re-compressed when required, and forwarded. Binary messages, control frames, and upstream-to-client traffic remain uninspected. Binary messages pass through under both `on_error` modes. For each active selected stage, OpenShell emits an informational `unsupported_message_type` coverage event and advances the session-global sequence; the next text message can therefore reach the stage with a valid sequence gap. + +The network supervisor reserves process-wide assembly capacity before buffering every parsed WebSocket text message, even when no middleware is selected. At most 32 assemblies run while 64 additional callers wait without buffering payload bytes. When both bounds are full, OpenShell closes the WebSocket with code `1013` before reading the new message payload. A text message may contain at most 4,096 fragments, must make input progress within 30 seconds, and must finish assembly within 2 minutes. Forwarding the completed text frame must finish within another 2 minutes. The assembly budget lasts for the supervisor process lifetime, so policy reloads do not reset its capacity. + +Active middleware sessions additionally reserve shared middleware capacity before buffering WebSocket text, and HTTP middleware reserves the same capacity before buffering request bodies; at most 32 evaluations run and 64 additional unbuffered callers wait for capacity. When both middleware bounds are full, OpenShell sheds an HTTP request with `503 Service Unavailable` before reading its body. Persistent middleware streams use a separate process-wide budget of 32 sessions. WebSocket session admission does not wait: if the budget is full, OpenShell applies each selected config's `on_error` behavior before opening a stream. + Because each transformed body is re-checked before the next stage runs, a middleware hook always receives a request that satisfies the sandbox policy. A stage whose output the policy rejects stops the chain; under `enforcement: audit` the rejection is logged and the request proceeds. If post-transformation policy evaluation itself fails, OpenShell denies the request and emits a high-severity detection finding. This failure is separate from middleware `on_error` because the middleware completed successfully; the sandbox policy could not validate its output. Middleware receives the request before credential injection. Operator-run services cannot inspect OpenShell-managed credentials. Middleware-visible request headers are delivered in wire order and repeated header names are preserved as separate entries. OpenShell filters credential, routing, framing, and hop-by-hop headers before invoking middleware. It rejects malformed request headers and unsupported transfer-coding sequences before middleware or policy dispatch. Headers named by a request's `Connection` field are omitted from middleware input and removed before forwarding, except for the validated WebSocket upgrade pair. +The request context identifies the originating sandbox to operator-run services. It carries the sandbox ID (`sandbox_id`), the sandbox name (`sandbox_name`), and the workspace (`workspace`), letting audit and approval interfaces show a human-readable name and its workspace instead of an opaque ID. `sandbox_name` and `workspace` are for display and logging only: names are workspace-scoped and may be reused for different sandbox instances, so services must use `sandbox_id` for authorization, persistence, durable correlation, and identity. `sandbox_id` is always present on middleware requests. `sandbox_name` and `workspace` are best-effort: a supervisor that cannot resolve a value, or an older supervisor that predates a field, sends an empty string. Services should fall back to the sandbox ID when the name or workspace is empty. + ## Choose a Middleware Type -| Type | Registration | Body limit | Deployment | +| Type | Registration | Payload limit | Deployment | | --- | --- | --- | --- | | Built-in | None | Defined by OpenShell | Runs inside the supervisor | | Operator-run service | Required in gateway TOML | Set by the operator, up to the service capability | Runs as a separate service reachable by the gateway and supervisors | -`openshell/regex` is an example built-in middleware. It replaces only simple, self-contained token patterns in UTF-8 request bodies; the initial pattern recognizes `sk-` tokens. It does not infer values from keyword assignments such as JSON `password` fields. This best-effort text transformation is not parser-aware and does not guarantee that it will detect or fully remove sensitive values. Its `config` accepts one field, `mode: redact`, which is also the default when the field is omitted. Unknown config fields and non-string values are rejected at policy validation. Custom expressions are not configurable yet. +`openshell/regex` is an example built-in middleware. It replaces only simple, self-contained token patterns in UTF-8 HTTP bodies and client WebSocket text messages; the initial pattern recognizes `sk-` tokens. It does not infer values from keyword assignments such as JSON `password` fields. This best-effort text transformation is not parser-aware and does not guarantee that it will detect or fully remove sensitive values. Its `config` accepts one field, `mode: redact`, which is also the default when the field is omitted. Unknown config fields and non-string values are rejected at policy validation. Custom expressions are not configurable yet. -Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase; V1 supports only `HttpRequest/pre_credentials`. Policies attach the complete middleware by its operator-owned gateway registration name. +Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase. V1 supports `HttpRequest/pre_credentials` and `WebSocketMessage/pre_credentials`; a service may expose either or both. Policies attach the complete middleware by its operator-owned gateway registration name. ## Register a Middleware Service @@ -46,24 +61,54 @@ Start an operator-run service before starting the gateway, then add a registrati ```toml [[openshell.supervisor.middleware]] name = "local-content-guard" -grpc_endpoint = "http://host.openshell.internal:50051" -max_body_bytes = 262144 +grpc_endpoint = "https://content-guard.example:50051" +tls_ca_cert_path = "/etc/openshell/content-guard-ca.pem" +audience = "urn:example:content-guard" +max_payload_bytes = 262144 timeout = "500ms" ``` | Field | Description | | --- | --- | | `name` | Operator-owned registration name used by policy attachments and diagnostics. Names must be unique, and `openshell/` is reserved for built-ins. | -| `grpc_endpoint` | Service address reachable from both the gateway and sandbox supervisors. Supports plaintext `http://` and TLS `https://` with platform trust roots. | -| `max_body_bytes` | Operator limit applied to every binding exposed by the service, up to the 4 MiB platform maximum. | +| `grpc_endpoint` | Service address reachable from both the gateway and sandbox supervisors. Authenticated extensions use TLS `https://`. | +| `tls_ca_cert_path` | Optional PEM trust roots for a private HTTPS service. Custom roots replace platform roots and retain hostname verification. | +| `audience` | Exact audience expected by the service. Defaults to `urn:openshell:extension:middleware:`. | +| `allow_insecure_transport` | Opt this registration out of extension authentication, permitting a plaintext `http://` endpoint with no bearer credential. Defaults to `false`. Development and trusted-network deployments only. | +| `max_payload_bytes` | Shared operator limit applied to inspectable logical payloads across every binding exposed by the service, up to the 4 MiB platform maximum. It caps HTTP bodies and complete WebSocket text messages. | | `timeout` | Optional service-wide RPC timeout using an integer with an `ms` or `s` suffix. Defaults to `500ms`; valid values range from `10ms` through `30s`. | -Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig` and `EvaluateHttpRequest`. +Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies only to `EvaluateHttpRequest`, WebSocket preflight, and each WebSocket message. WebSocket streams have no connection-wide deadline. The gateway connects to every registered service and verifies its capabilities before accepting traffic. Gateway startup fails when a service is unavailable, reports an invalid capability, or exposes more than one binding for the same operation and phase. The manifest `name` is diagnostic metadata and does not need to match the operator registration name. Operator-run registration names cannot claim the reserved `openshell/` namespace. Registration is static. Restart the gateway after adding, removing, or changing a service. See [Gateway Configuration](/reference/gateway-config#supervisor-middleware-services) for the complete gateway TOML context. +### Authenticate OpenShell Callers + +When gateway JWT signing is configured, OpenShell attaches a short-lived EdDSA bearer token to every remote middleware RPC. Gateway calls use `caller_kind: gateway`; sandbox supervisor calls use `caller_kind: supervisor` and include the sandbox ID. Supervisors request credentials by registration name through `RefreshSandboxToken`. The gateway derives the audience from operator-owned configuration and authorizes each name against the sandbox's effective policy. + +Return your expected audience in the `expected_audience` field of your `Describe` manifest. After authenticated `Describe` succeeds, OpenShell compares the advertised value with its operator-configured audience and refuses to start when they differ. This is a post-authentication consistency assertion, not audience discovery: a strict verifier may reject an incorrect audience before returning the manifest, in which case startup reports an authentication failure. Leave the field empty to skip the consistency check. + +Provision the trusted gateway URL, expected gateway ID, and public key or JWKS through the deployment. This operator-provisioned key material is the authoritative cold-start trust anchor. The expected issuer is exactly `openshell-gateway:`; fetching JWKS does not establish that identity by itself. After initial trust is established, `GET /.well-known/openid-configuration` and its `jwks_uri` provide steady-state key refresh and operational convenience. The document is OIDC-shaped rather than OIDC-compliant: `issuer` is the gateway identity, not the URL serving the document, so compare `iss` against the configured value and fetch updates only over authenticated TLS at the trusted gateway URL. + +Cache keys by `kid`. Validate, at minimum: + +- `typ` is exactly `openshell-ext+jwt`. Extension tokens and sandbox-to-gateway bootstrap tokens share a signing key and differ only in audience; this header is a second, independent discriminator. +- `alg` is pinned to `EdDSA`. Never select the algorithm from the token. +- Signature, expected issuer, exact audience, and positive expiry. +- `caller_kind`, and the sandbox identity when your service scopes behavior per sandbox. + +A sandbox-to-gateway JWT is not an extension credential even though both token types use the same signing key. + +Each token carries a unique `jti` that identifies that token instance for correlation and future explicit revocation. OpenShell reuses a token across calls until rotation and does not track `jti`, so rejecting a repeated `jti` would reject legitimate requests. Per-request replay resistance requires a request nonce or signature, channel binding, or another proof-of-possession mechanism. + +### Run Without Extension Authentication + +Set `allow_insecure_transport = true` on a registration to keep a plaintext `http://` endpoint working. OpenShell then attaches no credential to that service, supervisors do not request one, and the gateway refuses to mint one if asked. The gateway logs a warning naming the registration at every startup. + +The service cannot distinguish OpenShell from any other client that can reach it. Use this only where the network already provides that guarantee, and prefer `https://` everywhere else. + ## Apply Middleware with Policy Add middleware configs to the top-level `network_middlewares` map. Each key is the policy-local config name: @@ -92,16 +137,18 @@ See [Policy Schema](/reference/policy-schema#network-middleware) for the complet ## Configure Failure Behavior -`on_error` controls what happens when middleware is unavailable, rejects its configuration, returns an invalid result, or exceeds a body limit. +`on_error` controls what happens after an operation binding is selected and middleware is unavailable, rejects its configuration, returns an invalid result, or exceeds the selected binding's payload limit. It does not turn an unadvertised operation or an unsupported WebSocket message class into a middleware failure. | Value | Behavior | | --- | --- | -| `fail_closed` | Denies the request when the middleware stage fails. This is the default. | -| `fail_open` | Skips the failed stage and continues the request through the remaining chain. | +| `fail_closed` | Denies the HTTP request or closes the WebSocket when the stage fails. This is the default. | +| `fail_open` | Skips the failed HTTP stage. For a broken WebSocket stage stream, disables that stage for the rest of the connection and continues the remaining chain. | -Use `fail_open` only when bypassing the middleware preserves the intended security policy. OpenShell emits a detection finding when a failed stage is bypassed. +Use `fail_open` only when bypassing the middleware preserves the intended security policy. OpenShell emits a detection finding when a failed stage is bypassed and a separate state-change finding when a WebSocket stage is disabled for the session. -An explicit deny decision always stops the chain and denies the request, regardless of `on_error`. The HTTP response uses `error: middleware_denied`, identifies the policy-local middleware config, and omits policy-advisor remediation because the network and L7 allow rules already matched. OpenShell never copies the free-form middleware `reason` into the response or security logs. A service can instead return an optional stable `reason_code`: 1–64 bytes, starting with a lowercase ASCII letter and containing only lowercase ASCII letters, digits, and underscores. Invalid codes make the result a middleware failure governed by `on_error`. +Capability coverage is separate from failure handling. A host-matched HTTP-only attachment does not join the WebSocket chain, regardless of `on_error`. Binary messages are outside the V1 text-message binding and pass through even when a selected stage is `fail_closed`. OpenShell records both states as informational coverage events so operators do not mistake pass-through traffic for inspected traffic. If a deployment requires all WebSocket message classes to be inspected, V1 cannot express that requirement. + +An explicit deny decision always stops the chain and denies the request or WebSocket upgrade, regardless of `on_error`. A WebSocket preflight `DENY` is a successful policy decision, not a middleware failure; OpenShell rejects the upgrade before upstream contact and ends each still-writable stream opened by a successful preflight decision with `MIDDLEWARE_DENIAL`. The HTTP response uses `error: middleware_denied`, identifies the policy-local middleware config, and omits policy-advisor remediation because the network and L7 allow rules already matched. OpenShell never copies the free-form middleware `reason` into the response or security logs. HTTP results, WebSocket preflight decisions, and WebSocket message results can instead return an optional stable `reason_code`: 1–64 bytes, starting with a lowercase ASCII letter and containing only lowercase ASCII letters, digits, and underscores. Invalid codes make the result a middleware failure governed by `on_error`. Preflight findings and metadata use the same bounds and audit-safe handling as message results. ```json { @@ -117,19 +164,21 @@ A failed `fail_closed` stage uses `error: middleware_failed` and a platform-owne Middleware decisions are enforced regardless of the endpoint's `enforcement` mode. `enforcement: audit` applies to an endpoint's network and L7 policy rules and does not bypass middleware: a middleware deny, or a failed `fail_closed` stage, blocks the request even on an audit endpoint. A middleware service that needs to observe traffic without blocking should return an allow decision with findings, which OpenShell emits as detection findings. -## Set Body Limits +## Set Payload Limits -Every middleware binding declares the largest request or replacement body it supports. +Every middleware binding declares the largest logical payload or replacement it supports through `max_payload_bytes`. For `HTTP_REQUEST`, that payload is one request body. For `WEBSOCKET_MESSAGE`, it is one complete message rather than the whole session. - Built-in middleware uses its OpenShell-defined limit. -- Each operator-run registration sets `max_body_bytes` no higher than the service capability. +- Each operator-run registration sets one `max_payload_bytes` ceiling no higher than any binding's advertised `max_payload_bytes` capability. - A selected chain buffers using its largest stage limit, so every stage that can process the body receives it. - The same per-stage limit applies to request bodies and replacement bodies. -The gateway rejects a registration whose operator limit exceeds the service capability or the 4 MiB platform maximum instead of silently clamping it. OpenShell also bounds the non-body protobuf components: 64 KiB for service config, 4 KiB for request context, 32 KiB for the target, and 128 request header lines totaling at most 64 KiB encoded. Results allow a 4 KiB discarded free-form reason, a 64-byte validated reason code, 64 header mutations totaling at most 64 KiB encoded, 32 findings of at most 4 KiB encoded each, and 64 metadata entries totaling at most 32 KiB. Middleware gRPC servers should configure request and response message limits to at least 4 MiB plus 293 KiB so every platform-valid envelope fits. +The gateway rejects a registration whose operator limit exceeds the service capability or the 4 MiB platform maximum instead of silently clamping it. OpenShell also bounds the non-payload protobuf components: 64 KiB for service config, 4 KiB for request context, 32 KiB for the target, and 128 request header lines totaling at most 64 KiB encoded. Results allow a 4 KiB discarded free-form reason, a 64-byte validated reason code, 64 header mutations totaling at most 64 KiB encoded, 32 findings of at most 4 KiB encoded each, and 64 metadata entries totaling at most 32 KiB. Middleware gRPC servers should configure request and response message limits to at least 4 MiB plus 293 KiB so every platform-valid envelope fits. At request time, exceeding a selected stage's limit is a middleware failure for that stage alone and follows that config's `on_error` behavior; other stages in the chain still run against their own limits. OpenShell can apply `fail_open` to an oversized `Content-Length` before consuming body bytes. A chunked body can cross the limit only after bytes have been consumed, so OpenShell denies that request because it cannot safely resume the original stream. +For a WebSocket binding, `max_payload_bytes` covers complete client text messages and replacements. Exceeding a selected stage's effective text-message limit follows that stage's `on_error`. The 4 MiB parsed-text platform cap and other protocol-safety limits are independent of middleware failure policy. Binary messages are not delivered to middleware, so the operator ceiling does not become a binary relay limit; individual raw binary frames retain the 16 MiB relay-safety bound. Oversized parsed text closes the connection with code `1009`; invalid UTF-8 uses `1007`; protocol errors use `1002`; middleware or policy denials use `1008`; and policy reload uses `1012`. + ## Mutate Request Headers A middleware result can return ordered header mutations before OpenShell injects credentials. A `write` mutation adds a value when the case-insensitive header name is absent and selects one behavior when it is already present: @@ -140,7 +189,7 @@ A middleware result can return ordered header mutations before OpenShell injects A `remove` mutation removes every value for a case-insensitive header name. OpenShell applies each successful stage's mutations before invoking the next middleware, so later stages observe the accumulated header state. -Header writes must use the `x-openshell-middleware-` prefix. Removes may target other middleware-visible request headers. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters. +Writes and removals may target middleware-visible end-to-end request headers. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters or OpenShell credential placeholder syntax. Middleware runs before credential injection, but it cannot introduce a value that the later injection step would resolve. OpenShell validates and applies each stage's mutations atomically. An invalid operation discards every mutation from that stage and follows its `on_error` behavior. Built-in failures can name the offending header. Operator-run failures use a platform-owned error code so request-derived header text cannot reach logs or denied responses. @@ -164,6 +213,8 @@ Middleware activity is emitted through OpenShell's OCSF logging: - A denied invocation records a platform-owned reason derived from the policy-local config name and optional validated reason code. OpenShell does not record service-provided free-form reason text. - A bypass under `fail_open` emits a detection finding. - A required stage that fails closed emits a high-severity detection finding. +- A host-matched attachment without a WebSocket binding emits an informational `binding_not_selected` coverage event. +- A binary message encountered by an active WebSocket stage emits an informational `unsupported_message_type` coverage event with message type, sequence, and byte count. It is not reported as an invocation or failure. - Built-in findings include their type, label, and aggregate count. Operator-run findings use the operator-owned registration name and a platform label plus the aggregate count; OpenShell does not log service-provided finding text or diagnostic metadata. A stage can return at most 32 findings. Exceeding the per-stage cap is an invalid response handled through `on_error`. A maximum 10-stage chain retains and emits up to 320 findings without silently dropping findings from later stages. - Registry reload success and failure are emitted as configuration state changes. @@ -171,9 +222,13 @@ See [Logging](/observability/logging) for log access and [OCSF JSON Export](/obs ## Current Limitations -- Middleware applies only to HTTP requests parsed by the supervisor. Traffic the supervisor cannot inspect, such as HTTP/2 prior knowledge or non-HTTP TCP protocols, is denied on hosts matched by a fail-closed middleware, and relayed with a detection finding when every matching middleware is `fail_open`. -- The typed operation and phase are `HTTP_REQUEST/PRE_CREDENTIALS`. +- Middleware applies only through operation bindings advertised by each implementation. For protocols that have no supported middleware operation at all, such as HTTP/2 prior knowledge or non-HTTP TCP, the existing uninspectable-traffic gate denies a host match containing `fail_closed` and relays an all-`fail_open` match with a detection finding. +- The typed operation and phase pairs are `HTTP_REQUEST/PRE_CREDENTIALS` and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. +- A host match does not imply every advertised operation: an HTTP-only attachment can inspect the upgrade GET, then post-upgrade traffic passes with `binding_not_selected` coverage. +- The V1 WebSocket binding inspects complete client text messages only. Binary messages pass with `unsupported_message_type` coverage for active stages; control frames and upstream-to-client messages remain outside the middleware operation. - Selection uses destination host include and exclude patterns. - A fail-closed middleware cannot cover `tls: skip` endpoints because OpenShell cannot inspect that traffic. An all-`fail_open` match may cover the endpoint; OpenShell bypasses the middleware and emits a detection finding. -- Operator-run services support plaintext `http://` and TLS `https://` endpoints. HTTPS certificates must chain to a CA in the platform trust store. -- Custom trust roots, client authentication, health checks, and runtime registration are not available. +- Operator-run services use TLS `https://` when gateway JWT signing is enabled, unless the registration sets `allow_insecure_transport`. Certificates must chain to the configured custom CA or platform roots, and the endpoint hostname must match. +- Extension tokens and sandbox-to-gateway tokens are signed by the same key. They are separated by audience and by `typ`, but the extension credential path cannot yet be rotated or revoked independently of sandbox admission. +- OpenShell does not track or revoke `jti`; bearer tokens can be replayed until expiry. Per-request replay resistance requires proof of possession or request binding. +- mTLS client authentication, health checks, runtime registration, and overlapping signing-key rotation are not available. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index ebd7a9880c..4d7a3e2deb 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -30,16 +30,10 @@ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | The install script uses Homebrew, RPM, or a Debian package based on your machine. It starts the local gateway server after installation. -If you prefer [uv](https://docs.astral.sh/uv/): - -```shell -uv tool install -U openshell -``` - After installing the CLI, run `openshell --help` in your terminal to view the full CLI reference. -You can also clone the [NVIDIA OpenShell GitHub repository](https://github.com/NVIDIA/OpenShell) and use the `/openshell-cli` skill to load the CLI reference into your agent. +Install the public OpenShell agent skills with `npx skills add NVIDIA/OpenShell`. The `openshell-cli` skill guides your agent through common workflows and uses the installed CLI help as the command reference; no OpenShell source checkout is required. ## Create Your First OpenShell Sandbox diff --git a/docs/get-started/tutorials/docker-compose.mdx b/docs/get-started/tutorials/docker-compose.mdx index 4037c02995..677f34595b 100644 --- a/docs/get-started/tutorials/docker-compose.mdx +++ b/docs/get-started/tutorials/docker-compose.mdx @@ -72,13 +72,9 @@ curl -sf http://localhost:8080/healthz curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh ``` -**From PyPI (any platform with [uv](https://docs.astral.sh/uv/)):** - -```shell -uv tool install -U openshell -``` - +The `openshell` package on PyPI provides the Python SDK only and does not install the CLI. + On Windows without WSL, install the CLI inside a WSL 2 distribution (for example AlmaLinux or Ubuntu) and run all `openshell` commands from that distribution. diff --git a/docs/get-started/tutorials/first-network-policy.mdx b/docs/get-started/tutorials/first-network-policy.mdx index 178bd2f095..cbf9eaaf92 100644 --- a/docs/get-started/tutorials/first-network-policy.mdx +++ b/docs/get-started/tutorials/first-network-policy.mdx @@ -65,15 +65,16 @@ The request fails. By default, all outbound network traffic is denied. The sandb curl: (56) Received HTTP code 403 from proxy after CONNECT ``` -Exit the sandbox. Sandboxes are kept running by default, so you can reconnect later. Use `--no-keep` at creation time if you want the sandbox deleted after exit: +Leave this sandbox shell open and switch to a second terminal on your host for the next steps. The remaining `openshell` commands (checking logs and applying the policy) run on your host, not inside the sandbox. -```shell -exit -``` + +Keep the interactive sandbox shell open for the rest of the tutorial. Exiting it stops the sandbox's main process, and with the default restart policy the sandbox is not reconnectable afterward. Add `--no-keep` at creation time if you want the sandbox deleted automatically when you exit. + + ## Check the Deny Log -Every denied connection produces a structured log entry. Query the sandbox logs from your host to confirm the denial and inspect the reason. +Every denied connection produces a structured log entry. In your second (host) terminal, query the sandbox logs to confirm the denial and inspect the reason. ```shell openshell logs demo --since 5m @@ -97,7 +98,7 @@ version: 1 filesystem_policy: include_workdir: true read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] + read_write: [/tmp, /dev/null] landlock: compatibility: best_effort @@ -135,13 +136,7 @@ This tutorial uses `curl` and `read-only` access to keep things simple. When bui ## Verify If GET Requests Are Allowed -The policy is now active. Reconnect to the sandbox and retry the same request to confirm that read access works. - -```shell -openshell sandbox connect demo -``` - -Retry the same request: +The policy is now active. Return to your sandbox shell in the first terminal (it is still running) and retry the same request to confirm that read access works: ```shell curl -s https://api.github.com/zen @@ -169,15 +164,9 @@ curl -s -X POST https://api.github.com/repos/octocat/hello-world/issues \ The CONNECT request succeeded because `api.github.com` is allowed, but the L7 proxy inspected the HTTP method and returned `403`. `POST` is not in the `read-only` preset. An agent with this policy can read code from GitHub but cannot create issues, push commits, or modify anything. -Exit the sandbox: - -```shell -exit -``` - ## Check the L7 Deny Log -L7 denials are logged separately from connection-level denials. The log entry includes the exact HTTP method and path that the proxy rejected. +L7 denials are logged separately from connection-level denials. The log entry includes the exact HTTP method and path that the proxy rejected. Run this from your second (host) terminal, leaving the sandbox shell open: ```shell openshell logs demo --level warn --since 5m diff --git a/docs/get-started/tutorials/github-sandbox.mdx b/docs/get-started/tutorials/github-sandbox.mdx index 0b11b39345..89b6c0885e 100644 --- a/docs/get-started/tutorials/github-sandbox.mdx +++ b/docs/get-started/tutorials/github-sandbox.mdx @@ -180,7 +180,6 @@ filesystem_policy: - /etc - /var/log read_write: - - /sandbox - /tmp - /dev/null diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index bc30bcfc44..bba5996277 100644 --- a/docs/get-started/tutorials/index.mdx +++ b/docs/get-started/tutorials/index.mdx @@ -24,7 +24,7 @@ Launch Claude Code in a sandbox, diagnose a policy denial, and iterate on a cust -Configure a Providers v2 Microsoft Graph provider with gateway-managed OAuth2 refresh-token rotation. +Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh-token rotation. diff --git a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx index d3c4a75847..76adf55fde 100644 --- a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx +++ b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx @@ -1,14 +1,14 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Refresh Microsoft Graph Credentials with Providers v2" +title: "Refresh Microsoft Graph Credentials with a Provider Profile" sidebar-title: "Microsoft Graph Provider Refresh" slug: "get-started/tutorials/microsoft-graph-provider-refresh" -description: "Configure a Providers v2 Microsoft Graph profile with gateway-managed OAuth2 refresh-token rotation." +description: "Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh-token rotation." keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Microsoft Graph, OAuth2, Credential Refresh, Sandbox" --- -Use Providers v2 to keep Microsoft Graph access tokens short lived while sandboxes receive a stable `MS_GRAPH_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Microsoft Graph access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. +Use a provider profile to keep Microsoft Graph access tokens short lived while sandboxes receive a stable `MS_GRAPH_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Microsoft Graph access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. After completing this tutorial, you have: @@ -43,14 +43,6 @@ Do not commit access tokens, refresh tokens, or local `.env` files. The commands -## Enable Providers v2 - -Enable provider profile policy composition on the active gateway: - -```shell -openshell settings set --global --key providers_v2_enabled --value true --yes -``` - ## Create a Microsoft Graph Provider Profile Create `microsoft-graph-mail.yaml` with this profile: diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 5409a4b11d..3fd7a66ecb 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -21,15 +21,17 @@ For how the CLI resolves gateways and stores credentials, refer to [Gateway Auth ## Sandbox Supervisor Identity -Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the gateway mints its own sandbox JWTs and Kubernetes sandboxes bootstrap them with a projected ServiceAccount token. +Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the Kubernetes compute driver validates each projected ServiceAccount token and returns the authenticated sandbox ID to the gateway. The gateway verifies the sandbox still exists and mints its own sandbox JWT. -Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. +Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into gateway and sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. -Provider token grants require a SPIFFE implementation such as SPIRE and a `ClusterSPIFFEID` that assigns per-sandbox IDs from the pod's `openshell.io/sandbox-id` annotation. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. +Provider token grants require a SPIFFE implementation such as SPIRE and identities for the gateway and sandbox pods. The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.ai/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. + +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so intermediate token exchange does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. ## OIDC User Authentication -Set `server.oidc.issuer` to enable OIDC. The gateway validates the `Authorization: Bearer ` header on every request against the issuer's JWKS endpoint. +Set `server.oidc.issuer` to enable OIDC. The gateway validates the `Authorization: Bearer ` header on every request against the issuer's JWKS endpoint. It accepts JWTs signed with RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, or EdDSA (Ed25519) keys published in the issuer JWKS. Okta tenants that sign access tokens with ES256 work without additional gateway configuration. ```shell helm upgrade openshell \ @@ -37,9 +39,12 @@ helm upgrade openshell \ --version \ --namespace openshell \ --set server.oidc.issuer=https://your-idp.example.com/realms/openshell \ - --set server.oidc.audience=openshell-cli + --set server.oidc.audience=openshell-cli \ + --set server.tls.clientCaSecretName="" ``` +Set `server.tls.clientCaSecretName=""` when the gateway terminates TLS directly and browsers or CLI clients connect without client certificates. The chart omits `client_ca_path` from `gateway.toml` and does not mount the client-CA volume, leaving HTTPS-only transport with OIDC for user authentication. Do not set the value to `null`; omit the key to use the chart default, or set it to `""` to disable client certificate verification. + The `audience` value must match the client ID configured in your identity provider for the OpenShell resource server. ### OIDC values reference @@ -48,7 +53,7 @@ The `audience` value must match the client ID configured in your identity provid |---|---|---| | `server.oidc.issuer` | `""` | OIDC issuer URL. Empty disables OIDC. | | `server.oidc.audience` | `openshell-cli` | Expected `aud` claim in the JWT. | -| `server.oidc.jwksTtl` | `3600` | JWKS key cache TTL in seconds. | +| `server.oidc.jwksTtl` | `3600` | JWKS key cache TTL in seconds. Must be greater than zero. | | `server.oidc.rolesClaim` | `""` | Dot-separated path to the roles array in JWT claims. | | `server.oidc.adminRole` | `""` | Role name that grants admin access. | | `server.oidc.userRole` | `""` | Role name that grants standard user access. | @@ -67,6 +72,7 @@ helm upgrade openshell \ --namespace openshell \ --set server.oidc.issuer=https://your-idp.example.com/realms/openshell \ --set server.oidc.audience=openshell-cli \ + --set server.tls.clientCaSecretName="" \ --set server.oidc.rolesClaim=realm_access.roles \ --set server.oidc.adminRole=openshell-admin \ --set server.oidc.userRole=openshell-user @@ -74,6 +80,8 @@ helm upgrade openshell \ Both `adminRole` and `userRole` must be set, or both must be empty. Setting only one is not supported. +OIDC RBAC is method-level authorization. It controls which API operations a caller can perform, but provider and sandbox records are not owned by individual OIDC subjects. In shared clusters, treat provider credentials as gateway-wide resources and use separate gateways or external tenancy controls when users must not see or attach each other's providers and sandboxes. + ### Provider-specific rolesClaim paths | Provider | rolesClaim value | @@ -96,6 +104,8 @@ helm upgrade openshell \ The gateway still serves TLS and sandbox supervisors still authenticate with gateway-minted sandbox JWTs. User-facing CLI/API calls without OIDC or mTLS credentials are accepted as an unauthenticated local developer principal. The proxy is responsible for authenticating callers and forwarding only authorized traffic. +When the gateway terminates TLS directly and callers connect without client certificates, also set `server.tls.clientCaSecretName=""` as described in the OIDC section above. + To also disable TLS entirely (when the proxy terminates TLS before the request reaches the gateway): ```shell diff --git a/docs/kubernetes/ingress.mdx b/docs/kubernetes/ingress.mdx index 66178bc3a0..51284465d9 100644 --- a/docs/kubernetes/ingress.mdx +++ b/docs/kubernetes/ingress.mdx @@ -91,7 +91,7 @@ Envoy Gateway only terminates TLS here — it does not perform OIDC. Do not enab Because Envoy terminates TLS, the OpenShell gateway never sees a client certificate, so client mTLS cannot provide identity on this path. Use OIDC bearer tokens for client identity instead. -For headless agents and CI, the CLI obtains the token via the OAuth2 client-credentials grant (no browser): set `OPENSHELL_OIDC_CLIENT_SECRET` to the OAuth client secret before running `openshell gateway add`. The client id comes from `--oidc-client-id` (default `openshell-cli`); pass it explicitly if your IdP client uses a different id. Interactive human users get the browser-based Authorization Code + PKCE flow by default. +For an interactive login on a headless machine, set `OPENSHELL_NO_BROWSER=1` before running `openshell gateway add`. The CLI uses the Device Authorization Grant with S256 PKCE and prompts the user to approve the login from another browser. For unattended agents and CI, set `OPENSHELL_OIDC_CLIENT_SECRET` to use the OAuth2 client-credentials grant instead. The client id comes from `--oidc-client-id` (default `openshell-cli`); pass it explicitly if your identity provider uses a different id. Interactive users with a local browser get the Authorization Code flow with PKCE by default. ### Provide a TLS certificate diff --git a/docs/kubernetes/managing-certificates.mdx b/docs/kubernetes/managing-certificates.mdx index b66419b505..c4cb07f57e 100644 --- a/docs/kubernetes/managing-certificates.mdx +++ b/docs/kubernetes/managing-certificates.mdx @@ -60,6 +60,63 @@ The chart also runs a pre-install hook in JWT-only mode to create the gateway's sandbox JWT signing Secret. That Secret is separate from the cert-manager TLS certificate Secrets and is mounted at `/etc/openshell-jwt`. +## Using a real Issuer for the server certificate + +By default, cert-manager issues both the server and client certificates from +a self-signed CA the chart creates — this rotates automatically, but the +server certificate is still not publicly trusted. `certManager.serverIssuerRef` +overrides the `issuerRef` on the server `Certificate` resource to point at a +real `Issuer` or `ClusterIssuer` instead, for example an ACME issuer: + +```shell +helm upgrade --install openshell \ + oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set certManager.enabled=true \ + --set certManager.serverIssuerRef.name=letsencrypt-prod \ + --set certManager.serverIssuerRef.kind=ClusterIssuer \ + --set certManager.serverDnsNames[0]=openshell.example.com +``` + +### Dual certificate architecture + +When `serverIssuerRef` is set, the chart creates **two** server certificates: + +1. **Internal certificate** (`openshell-server-tls`): signed by the chart CA + with internal SANs (`*.svc.cluster.local`, `localhost`, etc.). +2. **External certificate** (`openshell-server-external-tls`): signed by the + configured issuer (e.g. ACME) with only the hostnames from + `certManager.serverDnsNames`. + +The gateway uses **SNI** to select which certificate to present: +supervisors connect via internal service names and receive the internal +certificate (verified against the chart CA they already trust), while CLI +users connecting through a Route or ingress use the external hostname and +receive the ACME certificate. This keeps supervisor trust pinned to only +the operator's chart CA — no WebPKI root trust is needed. + + +Public CAs such as Let's Encrypt reject certificate requests that include +internal-only names per CA/Browser Forum baseline requirements. The chart +validates this at install time and fails with an actionable error if +`certManager.serverDnsNames` contains internal-only entries while +`serverIssuerRef` is set. + +You do **not** need to set `server.grpcEndpoint` to the external hostname. +Supervisors connect via the internal service name automatically. Setting +`server.grpcEndpoint` to an external hostname would cause supervisors to +receive the ACME certificate (via SNI) which they cannot verify against the +chart CA. + + +The default `clientCaFromServerTlsSecret=true` is correct even when +`serverIssuerRef` is set: the internal server certificate is always signed +by the chart CA (the same CA that signs the client certificate), so its +`ca.crt` is the right trust anchor for mTLS verification. + ## Next Steps -Return to [Setup](/kubernetes/setup) to complete the installation. +Return to [Setup](/kubernetes/setup) to complete the installation. For +exposing the gateway externally on OpenShift with a real certificate, see +[OpenShift](/kubernetes/openshift). diff --git a/docs/kubernetes/openshift.mdx b/docs/kubernetes/openshift.mdx index 7512eaa65e..43e7d0338b 100644 --- a/docs/kubernetes/openshift.mdx +++ b/docs/kubernetes/openshift.mdx @@ -87,8 +87,56 @@ openshell gateway add http://127.0.0.1:8080 --local --name openshift openshell status ``` +## Production: expose externally with a real certificate + +The steps above run the gateway over plaintext HTTP for quick evaluation. For +a real deployment, cert-manager can issue the gateway's server certificate +from a real Issuer or ClusterIssuer (for example, an ACME issuer), and an +OpenShift Route with TLS passthrough exposes it externally while the gateway +keeps terminating its own TLS and mTLS. + +Install cert-manager and configure a working `ClusterIssuer` first — see +[Managing Certificates](/kubernetes/managing-certificates) for the +`certManager.serverIssuerRef` details. Configure an OIDC provider as described +in [Access Control](/kubernetes/access-control) — remote gateways authenticate +CLI users via OIDC, not mTLS, so the gateway must know the OIDC issuer URL. +Install the chart with: + +```shell +helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set podSecurityContext.fsGroup=null \ + --set securityContext.runAsUser=null \ + --set server.disableTls=false \ + --set certManager.enabled=true \ + --set certManager.serverIssuerRef.name= \ + --set certManager.serverIssuerRef.kind=ClusterIssuer \ + --set certManager.serverDnsNames[0]= \ + --set openshiftRoute.enabled=true \ + --set openshiftRoute.host= \ + --set server.oidc.issuer= \ + --set server.oidc.audience= +``` + +| Override | Reason | +|---|---| +| `certManager.serverIssuerRef` | Creates a second server certificate from your Issuer or ClusterIssuer for external clients. The gateway uses SNI to present this cert for the external hostname while continuing to present the internal (chart CA) cert to supervisors. The internal certificate's `ca.crt` is the chart CA that also signed the client cert, so the default `clientCaFromServerTlsSecret=true` is correct. | +| `openshiftRoute.enabled` / `openshiftRoute.host` | Creates an OpenShift Route with TLS passthrough — the router forwards the encrypted connection by SNI without decrypting, so the gateway uses the SNI hostname to select the external certificate. | +| `server.oidc.issuer` / `server.oidc.audience` | Configures server-side OIDC validation. Without these, the gateway expects mTLS client certificates and rejects OIDC-only CLI connections. See [Access Control](/kubernetes/access-control). | + +Register the gateway with the CLI over OIDC. Remote gateways authenticate CLI +users via OIDC, not mTLS — see [Access Control](/kubernetes/access-control): + +```shell +openshell gateway add https:// \ + --name openshift \ + --oidc-issuer +openshell gateway login openshift +``` + ## Next Steps -- For TLS-enabled deployments, refer to [Managing Certificates](/kubernetes/managing-certificates). -- To expose the gateway externally, refer to [Ingress](/kubernetes/ingress). +- For more on certificate provisioning modes, refer to [Managing Certificates](/kubernetes/managing-certificates). +- To expose the gateway externally through the Kubernetes Gateway API instead of a Route, refer to [Ingress](/kubernetes/ingress). - To configure OIDC authentication, refer to [Access Control](/kubernetes/access-control). diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index c2fca827f1..fb7881af2d 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -38,6 +38,19 @@ kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/lates This creates the `agent-sandbox-system` namespace, installs the `sandboxes.agents.x-k8s.io` CRD, and starts the controller. +The Helm chart checks for a supported Agent Sandbox API before it creates +gateway resources. This preflight is enabled by default. Disable it only for +offline `helm template` rendering, where Helm cannot discover cluster APIs: + +```shell +helm template openshell oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --set agentSandbox.preflight.enabled=false +``` + +The chart does not install or upgrade the cluster-scoped Agent Sandbox CRDs or +controller. + **Air-gapped clusters:** mirror the manifest above and the `registry.k8s.io/agent-sandbox/agent-sandbox-controller` image referenced inside it to your internal registry, then point the manifest's image reference at your mirror before applying. You will also need to mirror the OpenShell gateway and sandbox images — see the chart's `image.repository` value for the gateway and `server.sandboxImage` / `server.supervisorImage` for the sandbox runtime. @@ -86,6 +99,36 @@ helm upgrade --install openshell \ The chart automatically generates PKI secrets on first install using pre-install Helm hooks. No manual secret creation is required. +### Split gateway and workspace releases + +For a platform-managed namespace, install the gateway without namespace-scoped +sandbox resources, then install the workspace chart in the sandbox namespace: + +```shell +helm upgrade --install openshell \ + oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set workspaceResources.enabled=false \ + --set server.sandboxNamespace=app-a + +helm upgrade --install openshell-workspace \ + oci://ghcr.io/nvidia/openshell/openshell-workspace \ + --version \ + --namespace app-a \ + --set gateway.serviceAccount.name=openshell \ + --set gateway.serviceAccount.namespace=openshell +``` + +The workspace chart does not create the namespace or deploy a gateway. It owns +only the sandbox ServiceAccount, Role, RoleBinding, and NetworkPolicy in its +release namespace. For one pre-provisioned namespace, keep +`server.drivers.kubernetes.workspaceMode=shared` and set +`server.sandboxNamespace=app-a`. To map multiple workspaces to separately +provisioned namespaces, use `workspaceMode=operator`, configure exactly one of +`operatorNamespaceLabel` or `operatorNamespaceFile`, and install the workspace +chart in every allowlisted namespace. + ## Wait for the gateway to be ready ```shell @@ -150,7 +193,9 @@ The most commonly changed values are: | `workload.kind` | Gateway workload controller. Use `statefulset` for SQLite or `deployment` with `server.externalDbSecret`. | | `workload.allowMultiReplicaStatefulSet` | Allow `replicaCount > 1` with `workload.kind=statefulset`. Prefer Deployment for external database-backed multi-replica gateways. | | `server.sandboxNamespace` | Namespace where sandbox pods are created. Defaults to the Helm release namespace when left empty. | +| `workspaceResources.enabled` | Create namespace-scoped sandbox prerequisites from the gateway chart. Disable when installing the workspace chart separately. | | `server.externalDbSecret` | Secret containing a PostgreSQL connection URI in the `uri` key. Use when the database is managed outside the chart. | +| `server.telemetryEnabled` | Enable anonymous OpenShell telemetry from the gateway and its sandbox supervisors. Set to `false` to opt out. | | `server.sandboxImage` | Default sandbox image used when a sandbox does not specify one. | | `server.sandboxImagePullSecrets` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | | `server.grpcEndpoint` | Endpoint that sandbox supervisors use to call back to the gateway. Must be reachable from inside the cluster. | @@ -162,6 +207,7 @@ The most commonly changed values are: | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect based on cluster version: clusters running Kubernetes 1.35 or later use `image-volume` (ImageVolume GA in 1.36); older clusters use `init-container`. Set explicitly to `image-volume` on Kubernetes 1.33 or 1.34 with the ImageVolume feature gate enabled, or to `init-container` to force the legacy path on any version. | | `supervisor.topology` | Sandbox pod topology. Refer to [Topology](/kubernetes/topology). | | `supervisor.sidecar.proxyUid` | Non-root UID used when sidecar process/binary-aware network policy is disabled. The default binary-aware sidecar runs as UID 0 instead. The configured UID must not match the sandbox UID. | +| `upstreamProxy` | Operator-owned corporate HTTP forward proxy for policy-approved TLS egress. Refer to [Configure a Corporate Upstream Proxy](#configure-a-corporate-upstream-proxy). | Use a values file for repeatable deployments: @@ -197,6 +243,36 @@ server: - name: regcred ``` +## Configure a Corporate Upstream Proxy + +Configure a corporate forward proxy when sandbox TLS egress cannot dial the Internet directly. OpenShell evaluates policy and SSRF checks before it opens an HTTP CONNECT tunnel through the proxy. The proxy URL is operator-owned configuration. Sandbox environment variables cannot select, replace, or bypass it. + +Create the credential Secret in the sandbox namespace when the proxy requires Basic authentication. The Secret value uses the `user:pass` form. + +```shell +kubectl -n openshell create secret generic corporate-proxy-auth \ + --from-literal=credentials="$PROXY_USER:$PROXY_PASSWORD" +``` + +Add the proxy settings to your Helm values file. Replace the DNS suffixes and CIDRs in `noProxy` with values for your cluster. `noProxy` bypasses only the corporate proxy. OpenShell policy evaluation still applies. + +```yaml +upstreamProxy: + url: http://proxy.corp.example:8080 + noProxy: .svc,.svc.cluster.local,10.96.0.0/12,10.244.0.0/16 + authSecret: + name: corporate-proxy-auth + key: credentials + authAllowInsecure: true + +supervisor: + topology: sidecar +``` + +Use `authAllowInsecure: true` only when you accept that Basic authentication is cleartext on the connection to an `http://` proxy. The initial release supports `http://` proxy endpoints and TLS CONNECT egress. It does not support HTTPS-to-proxy, custom corporate CA bundles, or forwarding plain HTTP egress through the proxy. + +Proxy credentials require `sidecar` topology. It mounts the credential only into the dedicated network supervisor container. OpenShell rejects credential Secrets with `combined` topology because Kubernetes `fsGroup` volume permission handling can make a shared credential mount readable by the sandbox group. + ## RBAC The chart creates the following RBAC resources in the release namespace: diff --git a/docs/kubernetes/topology.mdx b/docs/kubernetes/topology.mdx index 5bbb18e1ef..869fc07f1b 100644 --- a/docs/kubernetes/topology.mdx +++ b/docs/kubernetes/topology.mdx @@ -216,8 +216,8 @@ topology = "sidecar" proxy_uid = 1337 ``` -`proxy_uid` configures only the relaxed endpoint/L7-only sidecar. It must be a -non-root UID and must not match the sandbox UID. The default binary-aware mode +`proxy_uid` configures only the relaxed endpoint/L7-only sidecar. It must be at +least `1000` and must not match the sandbox UID. The default binary-aware mode runs the sidecar as UID 0 instead. The network init container exempts the effective sidecar UID from proxy redirection so the sidecar can reach the gateway. diff --git a/docs/observability/accessing-logs.mdx b/docs/observability/accessing-logs.mdx index e96dfeab2f..4b755f74cc 100644 --- a/docs/observability/accessing-logs.mdx +++ b/docs/observability/accessing-logs.mdx @@ -41,19 +41,22 @@ For durable log storage, use the log files inside the sandbox or enable [OCSF JS ## Direct Filesystem Access -Use `openshell sandbox connect` to open a shell inside the sandbox and read the log files directly: +Start an independent shell with `sandbox exec` to read log files directly: ```text -openshell sandbox connect my-sandbox +openshell sandbox exec --name my-sandbox --tty -- /bin/bash -l sandbox@my-sandbox:~$ cat /var/log/openshell.2026-04-01.log ``` -You can also run a one-off command without an interactive shell: +Or run a one-off command without an interactive shell: ```shell -openshell sandbox connect my-sandbox -- cat /var/log/openshell.2026-04-01.log +openshell sandbox exec --name my-sandbox -- cat /var/log/openshell.2026-04-01.log ``` +`sandbox connect` attaches to the sandbox's existing canonical main process; it +does not start a new shell. + The log files inside the sandbox contain the complete record, including events that the gRPC push channel can drop under load. The push channel is bounded and drops events rather than blocking. ## Filtering by Event Type diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 1ab0b5bfed..bc8d543246 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -24,7 +24,7 @@ These events cover startup plumbing, gRPC communication, and internal state tran ### OCSF structured events -Network, process, filesystem, and configuration events use the [Open Cybersecurity Schema Framework (OCSF)](https://ocsf.io) format. OCSF is an open standard for normalizing security telemetry across tools and platforms. OpenShell maps sandbox events to OCSF v1.7.0 event classes. +Network, process, filesystem, configuration, and API activity events use the [Open Cybersecurity Schema Framework (OCSF)](https://ocsf.io) format. OCSF is an open standard for normalizing security telemetry across tools and platforms. OpenShell maps sandbox events to OCSF v1.8.0 event classes, including API Activity [6003] with the `ai_operation` profile for inference observability. In the log file, OCSF events appear in a shorthand format with an `OCSF` level label, designed for quick human and agent scanning: @@ -61,6 +61,7 @@ OpenShell maps sandbox events to these OCSF classes: | `FINDING:` | Detection Finding | 2004 | Security findings (nonce replay, proxy bypass, unsafe policy) | | `CONFIG:` | Device Config State Change | 5019 | Policy load/reload, Landlock, TLS setup, inference routes | | `LIFECYCLE:` | Application Lifecycle | 6002 | Sandbox supervisor start, SSH server ready | +| `API:INFERENCE` | API Activity | 6003 | AI model inference calls through `inference.local` (model, provider, latency, tokens) | ## Reading the Shorthand Format @@ -137,6 +138,21 @@ OCSF HTTP:POST [INFO] ALLOWED POST http://httpbin.org:443/anything [policy:httpb OCSF FINDING:CREATE [MED] "configured content matched" [type:content_guard.match count:1 middleware:prototype-content-guard] ``` +WebSocket middleware emits one safe event per preflight, session-start, or client text-message decision. The event includes the policy-local config, registered implementation, sequence, byte counts, transformation flag, and validated reason code. It never includes the message payload or service-provided free-form reason: + +```text +OCSF NET:OTHER [INFO] WEBSOCKET_MIDDLEWARE allow config=api-redactor implementation=openshell/regex sequence=3 input_bytes=128 replacement_bytes=96 transformed=true reason_code=- +``` + +Coverage events are separate from invocation decisions. `binding_not_selected` means a host-matched attachment did not advertise the WebSocket operation. `unsupported_message_type` means an active WebSocket stage encountered a binary message, which V1 passes through without inspection. Both are informational, do not apply `on_error`, and never claim the traffic was inspected: + +```text +OCSF NET:OTHER [INFO] WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected config=http-dlp implementation=example/http-dlp sequence=- message_type=- input_bytes=0 +OCSF NET:OTHER [INFO] WEBSOCKET_MIDDLEWARE_COVERAGE state=unsupported_message_type config=api-redactor implementation=openshell/regex sequence=4 message_type=binary input_bytes=512 +``` + +A fail-open stream error emits both a middleware failure and `openshell.middleware.websocket_stage_disabled`. The latter records that OpenShell will bypass that stage for later messages on the same connection. Waiting for saturated admission capacity also emits a detection finding without payload content. When both active capacity and the bounded wait queue are full, HTTP work is rejected before its payload is buffered, returns `503 Service Unavailable`, and emits `openshell.middleware.admission_exhausted`. + Proxy and SSH servers ready: ```text @@ -167,7 +183,7 @@ OCSF CONFIG:LOADED [INFO] Policy reloaded successfully [policy_hash:0cc0c2b52557 Denied `NET:` and `HTTP:` events carry a `[reason:...]` suffix that surfaces the decision detail from the event's `status_detail` field. The reason helps distinguish between policy misses, SSRF hardening, and L7 enforcement without inspecting the full OCSF JSONL record. -For supervisor middleware denials, `status_detail` contains a platform-owned reason derived from the policy-local middleware config name and optional validated reason code. Middleware failure details also use platform-owned error codes. OpenShell does not copy per-request service text into logs. +For supervisor middleware denials, `status_detail` contains a platform-owned reason derived from the policy-local middleware config name and optional validated reason code. Middleware failure details also use platform-owned error codes. OpenShell does not copy per-request service text or WebSocket message content into logs. Common reason phrases emitted by the sandbox include: diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 5c326d665c..bb85ff6cfc 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -39,7 +39,7 @@ When enabled, OCSF JSON records are written to `/var/log/openshell-ocsf.YYYY-MM- ## JSON Record Structure -Each line is a complete OCSF v1.7.0 JSON object. Here is an example of a network connection event: +Each line is a complete OCSF v1.8.0 JSON object. Here is an example of a network connection event: ```json { @@ -61,7 +61,7 @@ Each line is a complete OCSF v1.7.0 JSON object. Here is an example of a network "vendor_name": "NVIDIA", "version": "0.3.0" }, - "version": "1.7.0" + "version": "1.8.0" }, "action_id": 1, "action": "Allowed", @@ -140,6 +140,33 @@ The `class_uid` field identifies the event type: | 2004 | Detection Finding | `FINDING:` | | 5019 | Device Config State Change | `CONFIG:` | | 6002 | Application Lifecycle | `LIFECYCLE:` | +| 6003 | API Activity | `API:INFERENCE` | + +### API Activity [6003] — AI Inference + +When the inference proxy routes a model call through `inference.local`, an API Activity event is emitted with the `ai_operation` profile: + +```json +{ + "class_uid": 6003, + "class_name": "API Activity", + "activity_id": 99, + "activity_name": "Other", + "api": { "operation": "POST /v1/messages" }, + "actor": { "process": { "name": "openshell-supervisor", "pid": 1 } }, + "src_endpoint": { "ip": "127.0.0.1", "port": 3128 }, + "ai_model": { "name": "claude-haiku-4-5-20251001", "ai_provider": "https://api.anthropic.com/v1" }, + "metadata": { "version": "1.8.0", "profiles": ["container", "host", "ai_operation"] }, + "status": "Success", + "unmapped": { "latency_ms": 701 } +} +``` + +The shorthand log renders this as: + +```text +OCSF API:INFERENCE [INFO] Success claude-haiku-4-5-20251001 via https://api.anthropic.com/v1 701ms [POST /v1/messages] +``` ## Integration with External Tools diff --git a/docs/providers/aws-sigv4.mdx b/docs/providers/aws-sigv4.mdx index 61465fa1b8..cae64015da 100644 --- a/docs/providers/aws-sigv4.mdx +++ b/docs/providers/aws-sigv4.mdx @@ -12,7 +12,8 @@ AWS SigV4 credential signing lets sandbox agents call AWS services (Bedrock, S3, ## Prerequisites - A provider with `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` credentials configured. Optionally include `AWS_SESSION_TOKEN` for STS temporary credentials. -- A sandbox policy with `credential_signing` enabled on the target endpoint. +- An endpointless `aws` provider profile when the sandbox policy defines the service endpoints, or an endpoint-bearing service profile such as `aws-s3`. +- A sandbox policy with `credential_signing` enabled on the target endpoint. For the endpointless `aws` profile, the endpoint must also set `credential_binding.provider` to the attached provider name. ## Provider Setup @@ -21,6 +22,7 @@ Create a provider with AWS credentials: ```shell openshell provider create \ --name aws-prod \ + --type aws \ --credential AWS_ACCESS_KEY_ID=AKIA... \ --credential AWS_SECRET_ACCESS_KEY=wJalr... ``` @@ -30,6 +32,7 @@ For STS temporary credentials, include the session token: ```shell openshell provider create \ --name aws-sts \ + --type aws \ --credential AWS_ACCESS_KEY_ID=ASIA... \ --credential AWS_SECRET_ACCESS_KEY=secret... \ --credential AWS_SESSION_TOKEN=FwoGZX... @@ -43,10 +46,13 @@ signer reads. See [Manage Providers](/sandboxes/manage-providers#aws-sts). ## Policy Configuration -Enable SigV4 signing on a per-endpoint basis using three policy fields: +Enable SigV4 signing on a per-endpoint basis. The binding selects which +provider instance supplies credentials, while the signing fields control how +the proxy applies them: | Field | Type | Required | Description | |---|---|---|---| +| `credential_binding.provider` | string | For endpointless profiles | Exact name of the attached provider instance that supplies AWS credentials. | | `credential_signing` | string | Yes | Signing mode: `sigv4`, `sigv4:body`, or `sigv4:no_body`. | | `signing_service` | string | Yes | AWS service name for the SigV4 signature (e.g. `bedrock`, `s3`, `sts`). | | `signing_region` | string | No | AWS region override. When omitted, extracted from the endpoint hostname. Required for non-standard endpoints. | @@ -60,6 +66,8 @@ network_policies: - host: bedrock-runtime.us-east-1.amazonaws.com port: 443 protocol: rest + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: bedrock rules: @@ -82,6 +90,8 @@ network_policies: port: 443 protocol: rest access: full + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: s3 ``` @@ -96,6 +106,8 @@ network_policies: port: 443 protocol: rest access: full + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: sts ``` @@ -133,6 +145,8 @@ endpoints: port: 443 protocol: rest access: full + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: s3 signing_region: us-west-2 @@ -141,8 +155,10 @@ endpoints: ## Restrictions - `credential_signing` and `request_body_credential_rewrite` are mutually exclusive on the same endpoint. The policy validator rejects policies that set both. +- `credential_binding.provider` must name a provider attached to that sandbox. Use it only when the selected provider profile has no endpoints. Endpoint-bearing profiles already define their credential boundary. +- OpenShell rejects a signed sandbox policy before activation unless the endpoint has a resolvable AWS credential source. An endpoint-bearing profile must declare `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` and cover the signed host, port, and path. An endpointless profile must declare those keys and be selected with `credential_binding.provider` on the signed endpoint. - The `sigv4:body` mode buffers at most 10 MiB. Requests with larger bodies are rejected. Use `sigv4:no_body` or `sigv4` (auto-detect) for large payloads. -- The proxy requires `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in the provider. If either is missing, the request fails with an error. +- The active provider must contain current `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` values. If either becomes unavailable after policy activation, the request fails closed. ## Use from a Sandbox diff --git a/docs/providers/google-vertex-ai.mdx b/docs/providers/google-vertex-ai.mdx index 21b9d4d371..5c577efd5a 100644 --- a/docs/providers/google-vertex-ai.mdx +++ b/docs/providers/google-vertex-ai.mdx @@ -105,13 +105,8 @@ OpenShell exposes Anthropic Vertex routes for inference only. It does not advert ## Configure Inference Routing -Before configuring inference routing, enable provider endpoint injection so the Vertex AI network endpoints are automatically included in sandbox policies: - -```shell -openshell settings set --global --key providers_v2_enabled --value true --yes -``` - -Then point `inference.local` at the provider: +Point `inference.local` at the provider. The Vertex AI profile contributes its +network endpoints to sandbox policies automatically: ```shell openshell inference set \ @@ -137,10 +132,7 @@ Agents inside sandboxes should reach Vertex AI through `inference.local`, not by The complete setup from scratch: ```shell -# 1. Enable provider endpoint injection -openshell settings set --global --key providers_v2_enabled --value true --yes - -# 2. Create the provider +# 1. Create the provider openshell provider create \ --name vertex-local \ --type google-vertex-ai \ @@ -148,10 +140,10 @@ openshell provider create \ --config VERTEX_AI_PROJECT_ID=my-gcp-project \ --config VERTEX_AI_REGION=us-central1 -# 3. Configure inference routing +# 2. Configure inference routing openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify -# 4. Create a sandbox with the provider attached +# 3. Create a sandbox with the provider attached openshell sandbox create --name my-sandbox --provider vertex-local ``` diff --git a/docs/sandboxes/providers-v2.mdx b/docs/providers/profiles.mdx similarity index 56% rename from docs/sandboxes/providers-v2.mdx rename to docs/providers/profiles.mdx index 64a0dc6a2c..c310c02b15 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/providers/profiles.mdx @@ -1,76 +1,207 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Providers v2" -sidebar-title: "Providers v2" +title: "Profiles" +sidebar-title: "Profiles" description: "Use provider profiles to attach credentials, network policy, and refresh metadata to OpenShell sandboxes." keywords: "Generative AI, Cybersecurity, Providers, Provider Profiles, Credentials, Policy, Sandbox" -position: 5 +position: 1 --- -Providers v2 turns providers from credential records into profile-backed access bundles. A provider profile describes the credentials, endpoints, binaries, policy rules, and refresh behavior for a provider type. A provider instance stores the concrete credential and config values for one gateway. +Provider profiles turn providers from credential records into profile-backed access bundles. A provider profile describes the credentials, endpoints, binaries, policy rules, and refresh behavior for a provider type. A provider instance stores the concrete credential and config values for one gateway. -Use Providers v2 when you want provider-owned policy rules to travel with provider credentials. For example, a GitHub provider can describe both `GITHUB_TOKEN` and the GitHub API endpoints that a sandbox needs, so users do not have to copy the same network policy into every sandbox. +Use provider profiles when you want provider-owned policy rules to travel with provider credentials. For example, a GitHub provider can describe both `GITHUB_TOKEN` and the GitHub API endpoints that a sandbox needs, so users do not have to copy the same network policy into every sandbox. -## Why Providers v2 Exists +## Why Provider Profiles Exist Provider credentials and network policy were previously configured through separate workflows. A user could create a GitHub provider that stored `GITHUB_TOKEN`, but the sandbox still needed a separate policy that allowed `api.github.com`, selected the right binaries, and configured REST enforcement. -Providers v2 keeps those pieces together: +Provider profiles keep those pieces together: -| Need | Providers v2 behavior | +| Need | Profile-backed behavior | |---|---| | Repeatable provider setup | Built-in and custom provider profiles define reusable provider types. | | Provider-aware policy | Attached providers contribute `_provider_*` network policy entries to the effective sandbox policy. | | Custom provider definitions | You can export, edit, lint, import, list, and delete custom profiles. | | Runtime provider lifecycle | You can list, attach, and detach providers on existing sandboxes. | | Credential rotation | Provider refresh metadata lets the gateway refresh short-lived access tokens and update provider records. | -| Backward compatibility | Credential delivery still uses environment placeholders and proxy rewrite. | - -## Enable Providers v2 - -Provider profile policy composition is controlled by the gateway-level `providers_v2_enabled` setting. Enable it on the active gateway: - -```shell -openshell settings set --global --key providers_v2_enabled --value true -``` - -When the setting is disabled or unset, providers keep the existing credential-only behavior. Sandboxes still receive provider credential placeholders, but attached provider profiles do not add network policy entries to the effective policy. - -To disable provider profile policy composition, delete the setting: - -```shell -openshell settings delete --global --key providers_v2_enabled -``` - - -The feature flag controls provider-derived policy layers. OpenShell still supports placeholder environment variables for provider credentials, and provider profiles can also declare dynamic token grants that the sandbox proxy resolves on demand for matching HTTP endpoints. - +| Credential transport | Credential delivery uses environment placeholders and proxy rewrite. Static placeholders resolve only at profile endpoints or explicitly bound sandbox policy endpoints for endpointless profiles. | ## Available Features -Providers v2 currently includes these user-facing features: +Provider profiles include these user-facing features: - Built-in provider profiles loaded by the gateway by default. - Gateway configuration can compose built-in, user-managed, and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. +- A user-managed profile shadows a built-in profile with the same ID in its applicable workspace or platform scope. The built-in remains the fallback outside that scope. Source-managed interceptor collisions still fail closed. - `openshell provider list-profiles` with table, YAML, and JSON output. - `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. - Provider instances created from built-in or imported profile IDs with `openshell provider create --type `. +- Provider instances whose submitted credentials can be stored by a configured gateway credential driver. - Profile-backed credential discovery for explicit `openshell provider create --from-existing` and `openshell provider update --from-existing` flows. The built-in `google-vertex-ai` profile also supplements discovery with Vertex config env vars such as `VERTEX_AI_PROJECT_ID` and `VERTEX_AI_REGION`. - Just-in-time effective policy composition from sandbox policy plus attached provider profiles. - Runtime sandbox provider lifecycle commands under `openshell sandbox provider list|attach|detach`. - Credential refresh configuration with `openshell provider refresh status|configure|rotate|delete`. - Credential expiry metadata with `openshell provider update --credential-expires-at`; values accept Unix epoch milliseconds or ISO/RFC3339 timestamps. - Dynamic token grants that use the sandbox's SPIFFE JWT-SVID as an OAuth2 client assertion and inject short-lived tokens into supported headers for matching profile endpoints. +- Endpoint-bound static credential placeholders. The sandbox proxy resolves a static credential only for request hosts, ports, and paths declared by its provider profile or explicitly bound in sandbox policy for an endpointless profile. + +## Understand Static Credential Endpoint Binding + +Static credential endpoint binding prevents a placeholder for one service from +resolving on a different policy-allowed service. OpenShell associates every +static credential environment key with an endpoint boundary. Profile endpoints +supply that boundary by default. For an endpointless profile, a sandbox policy +endpoint can name the attached provider instance explicitly. The proxy checks +the resulting association before it substitutes the real value. + +The built-in `openai` and `anthropic` profiles use their public vendor +endpoints. Configuring an alternate base URL makes those providers route-only: +OpenShell withholds their static credentials and fixed-vendor policy from +direct sandbox traffic while gateway inference routing continues to use the +configured upstream. + +A request can use a static credential only when all of these checks pass: + +| Check | Configuration source | +|---|---| +| The placeholder belongs to the current attached provider state. | Sandbox provider attachment and current provider record. | +| The calling binary and destination are allowed. | Effective sandbox network policy. | +| The host, port, and canonical request path match the credential binding. | Provider profile endpoints, or an explicit sandbox policy binding for an endpointless profile. | +| The HTTP method, path, or protocol operation is allowed when L7 inspection is configured. | Effective sandbox network policy. | +| The credential has not expired. | Provider credential expiry metadata. | + +Network policy and credential binding serve different purposes. Network policy +authorizes traffic. A credential binding authorizes use of one provider +instance's credentials at an admitted endpoint. A credential binding cannot +widen sandbox network policy. + +For example, this profile endpoint binds all static credential environment keys +from the profile to `api.example.com:443` under `/v1`: + +```yaml showLineNumbers={false} +endpoints: + - host: api.example.com + port: 443 + path: /v1/** +``` + +The path `/v1/**` matches `/v1` and its descendants. An empty path, `**`, or +`/**` matches every path on the selected host and port. Other path values use +glob matching. OpenShell removes the query string and uses a canonical, +secret-redacted path for this check, so a credential embedded in a request path +does not need to be revealed before authorization. + +Use an explicit sandbox policy binding when the profile intentionally defines +credentials without defining service endpoints. The policy names the concrete +provider instance, not the profile type: + +```yaml showLineNumbers={false} +network_policies: + gcp_storage: + endpoints: + - host: storage.googleapis.com + port: 443 + protocol: rest + access: full + credential_binding: + provider: work-gcp +``` + +The provider must be attached to the sandbox and must select an endpointless +profile. OpenShell rejects the complete policy update if the provider is +unattached, has no profile, or selects a profile that already defines endpoints. +This keeps one source of credential-binding authority for each provider. +`credential_binding` is sandbox-scoped and is not accepted in a gateway-global +policy. + +For AWS endpoints, the binding and signing fields have separate jobs. +`credential_binding.provider` selects the provider instance that supplies +credentials. `credential_signing`, `signing_service`, and `signing_region` +control how the proxy applies those credentials: + +```yaml showLineNumbers={false} +network_policies: + aws_s3: + endpoints: + - host: s3.us-west-2.amazonaws.com + port: 443 + protocol: rest + access: full + credential_binding: + provider: work-aws + credential_signing: sigv4 + signing_service: s3 + signing_region: us-west-2 +``` + +The binding applies to CONNECT and forward-proxy HTTP requests, including +headers, Basic and Bearer authorization, URL paths, query parameters, opted-in +request bodies, AWS SigV4 signing, and opted-in WebSocket text messages. Raw +`tls: skip` and non-HTTP tunnels do not perform static credential substitution. + +Before it activates a sandbox policy, OpenShell verifies that every endpoint +with `credential_signing` has an attached profile that declares +`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. An endpoint-bearing profile must +cover the signed host, port, and path. An endpointless profile must be selected +by `credential_binding.provider` on that endpoint. A missing or mismatched +source rejects the whole policy update with `FAILED_PRECONDITION`. + +If an HTTP request contains a known placeholder at a destination outside its +binding, OpenShell returns HTTP 403 with this response: + +```json +{"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"} +``` + +The sandbox logs include the destination and +`credential_endpoint_mismatch`. OCSF output includes both the denied activity +and a detection finding. These events omit credential values, placeholders, +environment keys, and query strings. + +For opted-in WebSocket text-message rewriting, the mismatch can occur after the +HTTP 101 upgrade has completed. OpenShell closes that WebSocket with policy +violation code 1008 instead of returning an HTTP response. + +Binding updates apply to both current and retained placeholder generations. +For gateway-managed refresh credentials, automatic and manual token rotation +keep one opaque workload placeholder and replace only its current resolver +value. This lets a long-running process use each newly minted access token +without restarting. OpenShell does not retain older values behind this stable +placeholder. + +Explicitly configuring refresh again starts a new authorization epoch, even +when the provider and credential key are unchanged. Reconfiguration, provider +replacement or detachment, refresh deletion, and endpoint-boundary changes +revoke the old placeholder. Updating a provider profile changes the binding on +the next sandbox provider-environment sync. + + +Static credentials require at least one usable binding. OpenShell withholds only +the static credential keys and associated expiry and binding metadata from an +endpointless selected profile when no sandbox policy endpoint explicitly binds +that provider. It retains that provider's generated non-secret configuration +and valid endpoint-bound static credentials from other attached providers. + + +After upgrading a gateway and supervisor to a release with endpoint binding, +restart or recreate older running sandboxes. A new gateway withholds static +credential material from supervisors that do not advertise binding support. +Rotate attached static credentials after upgrading when an older sandbox may +have received their real values. + +When upgrading from revision-scoped refresh placeholders to stable refresh +handles, restart each existing workload once so it receives the new placeholder. +Later access-token rotations do not require workload restarts. ## Roadmap -The following Providers v2 design items are not part of the current behavior: +The following provider profile design items are not part of the current behavior: | Roadmap item | Current behavior | |---|---| | General profile-driven credential placement | Static `auth_style`, `header_name`, `query_param`, and `path_template` placement metadata is stored and validated, but static credential injection still depends on environment placeholders generated from provider credentials. Dynamic `token_grant` credentials support `bearer` and `header` placement for matching HTTP endpoints. | -| Endpoint and binary scoped credential injection | Provider profile endpoints and binaries affect policy composition. Dynamic token grants are endpoint-scoped. Static placeholder injection is not yet restricted by profile endpoint or binary metadata. | +| Binary-scoped credential injection | Provider profile binaries affect policy composition but do not yet restrict placeholder resolution by calling binary. Static and dynamic credentials are endpoint-scoped. | | Credential verification on create | `openshell provider create` does not yet probe provider verification endpoints or expose `--no-verify`. | | Automatic credential scope extraction | OpenShell does not yet inspect upstream provider responses to discover credential scopes. | | Inference mounting from attached providers | `inference_capable` is profile metadata. Attaching an inference-capable provider does not yet create `inference.local` routes. | @@ -97,10 +228,11 @@ of truth: list, export, provider creation, policy composition, and sandbox provider environment resolution use the interceptor-vended profiles instead of built-in or user-imported profiles. -Built-in Providers v2 profiles currently include: +Built-in provider profiles currently include: | Profile ID | Category | Credential environment variables | |---|---|---| +| `anthropic` | `inference` | `ANTHROPIC_API_KEY` | | `claude-code` | `agent` | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | | `codex` | `agent` | `CODEX_AUTH_ACCESS_TOKEN`, `CODEX_AUTH_REFRESH_TOKEN`, `CODEX_AUTH_ACCOUNT_ID`, `CODEX_AUTH_ID_TOKEN` | | `copilot` | `agent` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | @@ -109,6 +241,7 @@ Built-in Providers v2 profiles currently include: | `github` | `source_control` | `GITHUB_TOKEN`, `GH_TOKEN` | | `google-vertex-ai` | `inference` | `GOOGLE_SERVICE_ACCOUNT_KEY`, `GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `GOOGLE_VERTEX_AI_TOKEN`, `VERTEX_AI_TOKEN` | | `nvidia` | `inference` | `NVIDIA_API_KEY` | +| `openai` | `inference` | `OPENAI_API_KEY` | | `pypi` | `data` | None | Export a built-in profile as YAML: @@ -179,6 +312,10 @@ category: data inference_capable: false credentials: + - name: user_oidc_token + description: User OIDC token stored for gateway-side token exchange only + required: true + - name: api_token description: API access token env_vars: [CUSTOM_API_TOKEN] @@ -211,17 +348,23 @@ credentials: required: true secret: true - # Optional dynamic credential. The sandbox supervisor requests a - # SPIFFE JWT-SVID, exchanges it at token_endpoint, caches the returned - # access token, and injects it according to auth_style/header_name for - # matching endpoint traffic. + # Optional dynamic credential. The sandbox supervisor resolves this on + # demand for matching endpoint traffic, caches the returned access token, + # and injects it according to auth_style/header_name. token_grant: + # Accepted values: client_credentials, token_exchange. + grant_type: token_exchange token_endpoint: https://login.example.com/realms/custom/protocol/openid-connect/token audience: api://custom-api jwt_svid_audience: https://login.example.com/realms/custom - client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe scopes: [api.read, api.write] cache_ttl_seconds: 300 + requested_token_type: urn:ietf:params:oauth:token-type:access_token + subject_token: + source: provider_credential + credential: user_oidc_token + subject_token_type: urn:ietf:params:oauth:token-type:access_token audience_overrides: - host: api.example.com port: 443 @@ -244,6 +387,7 @@ endpoints: allow_encoded_slash: false websocket_credential_rewrite: false request_body_credential_rewrite: false + allow_uninspected_credentials: false persisted_queries: deny graphql_max_body_bytes: 65536 rules: @@ -283,15 +427,15 @@ binaries: `category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum. -`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. +`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. -`discovery` controls what `--from-existing` scans when -`providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a +`discovery` controls what `--from-existing` scans. Each entry in +`discovery.credentials` must name a credential declared under `credentials`. OpenShell scans the referenced credential's `env_vars` in order and stores the first non-empty local environment value under the actual environment variable key. -`endpoints` contains the same endpoint object shape as sandbox network policy. A profile can use access presets, protocol-specific allow rules, deny rules, WebSocket credential rewriting, request body credential rewriting, GraphQL fields, and SSRF IP allowlists. +`endpoints` contains the same endpoint object shape as sandbox network policy. A profile can use access presets, protocol-specific allow rules, deny rules, WebSocket credential rewriting, request body credential rewriting, GraphQL fields, and SSRF IP allowlists. Because profile credentials are not mapped to individual endpoints, OpenShell conservatively treats every endpoint in a profile that declares credentials as credentialed. Such endpoints require L7 inspection and cannot use `tls: skip` unless the profile explicitly sets `allow_uninspected_credentials: true`. `binaries` contains the executable paths allowed to reach the profile endpoints when the profile contributes policy to a sandbox. @@ -310,7 +454,7 @@ Profile YAML can declare these refresh strategies: | `oauth2_refresh_token` | The gateway exchanges a refresh token for a short-lived access token. | | `oauth2_client_credentials` | The gateway mints a short-lived access token with OAuth2 client credentials. | | `google_service_account_jwt` | The gateway signs a Google service account JWT and exchanges it for an access token. | -| `aws_sts_assume_role` | The gateway calls `sts:AssumeRole` and mints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in one operation. Requires `providers_v2_enabled=true`. | +| `aws_sts_assume_role` | The gateway calls `sts:AssumeRole` and mints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in one operation. | `openshell provider refresh configure` accepts only gateway-mintable strategies: `oauth2-refresh-token`, `oauth2-client-credentials`, `google-service-account-jwt`, and `aws-sts-assume-role`. Use `openshell provider update` for `static` and `external` refresh patterns. @@ -353,7 +497,14 @@ The refresh attaches to the primary credential (`access_key_id`). Each reference ### Dynamic Token Grants -`token_grant` belongs to one credential declaration. When a sandbox with the provider attached sends HTTP traffic to a matching profile endpoint, the supervisor requests a SPIFFE JWT-SVID from the local Workload API, exchanges it at `token_endpoint`, caches the returned access token, and injects it before forwarding the request upstream. Use `auth_style: bearer` to inject `Authorization: Bearer `, or `auth_style: header` with `header_name` to inject the raw access token into a custom header. Token grants do not support `query` or `path` placement. +`token_grant` belongs to one credential declaration. When a sandbox with the provider attached sends HTTP traffic to a matching profile endpoint, the supervisor resolves the dynamic credential, caches the returned access token, and injects it before forwarding the request upstream. Use `auth_style: bearer` to inject `Authorization: Bearer `, or `auth_style: header` with `header_name` to inject the raw access token into a custom header. Token grants do not support `query` or `path` placement. + +OpenShell supports two dynamic grant types: + +| Grant type | Behavior | +|---|---| +| `client_credentials` | The supervisor requests a SPIFFE JWT-SVID from the local Workload API and sends it directly to `token_endpoint` as the OAuth2 client assertion. This is the default when `grant_type` is omitted. | +| `token_exchange` | The supervisor first asks the gateway for an intermediate token. The request includes the supervisor JWT-SVID; the gateway verifies it, uses the SVID subject as the intermediate token audience, and exchanges the stored subject credential at the same `token_endpoint` using the gateway's own JWT-SVID as the client assertion. The supervisor then exchanges that intermediate token for the final upstream token using its own JWT-SVID as the client assertion. | Create provider instances for token-grant-only profiles with `--runtime-credentials`. This records an empty provider instance and makes the runtime-resolved credential source explicit: @@ -364,19 +515,39 @@ openshell provider create \ --runtime-credentials ``` +For `token_exchange` profiles, the provider also stores the user subject token referenced by `token_grant.subject_token.credential`. That credential is gateway-only: the sandbox does not receive it as environment material, static credential binding metadata, or workload placeholder ownership. Create or update that provider credential from the current gateway OIDC login with `--from-oidc-token`. This requires an active named gateway that was registered for OIDC. The CLI copies the current OIDC access token and its expiry into the provider. If the stored gateway access token is expired and a refresh token is available, the CLI refreshes it first. OpenShell does not store the OIDC refresh token in the provider. When the stored subject-token credential expires, the gateway rejects intermediate token exchange until the provider is updated with a fresh token. + +```shell +openshell provider create \ + --name custom-api \ + --type custom-api \ + --from-oidc-token + +openshell provider update custom-api \ + --from-oidc-token +``` + +OpenShell infers the destination credential when the provider profile has exactly one `token_grant.subject_token.credential`. If a profile declares more than one token-exchange subject credential, pass `--credential ` to choose one. + Token grant fields: | Field | Required | Behavior | |---|---|---| +| `grant_type` | No | `client_credentials` or `token_exchange`. Defaults to `client_credentials` for backward compatibility. | | `token_endpoint` | Yes | OAuth2 token endpoint that accepts a SPIFFE JWT-SVID client assertion. Use `https://` unless the endpoint is loopback or a Kubernetes service DNS name such as `token-issuer.default.svc.cluster.local`. | -| `audience` | No | Resource audience requested from the token service. | +| `audience` | No | Resource audience requested from the token service. For `token_exchange`, this is the final exchange audience; the gateway intermediate exchange always uses the verified supervisor SVID subject as its audience. | | `jwt_svid_audience` | No | Audience used when requesting the JWT-SVID. When omitted, OpenShell derives an issuer-style audience from Keycloak token endpoint paths or falls back to the full token endpoint URL. | -| `client_assertion_type` | No | OAuth2 `client_assertion_type` form value. Defaults to RFC 7523 `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Set a provider-specific value, such as `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe`, only when the token issuer explicitly requires it. | +| `client_assertion_type` | No | OAuth2 `client_assertion_type` form value. Defaults to RFC 7523 `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Set `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` when the token issuer expects the SPIFFE assertion type. | | `scopes` | No | OAuth2 scopes sent as a space-separated `scope` parameter. | | `cache_ttl_seconds` | No | Token cache TTL override. When omitted or `0`, OpenShell uses the token response `expires_in` with a 30-second safety margin and one-hour cap, or five minutes minus the margin if the response does not include an expiry. | -| `audience_overrides` | No | Endpoint-specific `audience` and `scopes` overrides selected by host, port, and path. | +| `requested_token_type` | No | RFC 8693 `requested_token_type` sent during token exchange. Defaults to `urn:ietf:params:oauth:token-type:access_token`. | +| `subject_token` | Required for `token_exchange` | Subject-token source used for the gateway-brokered intermediate exchange. Phase one supports `source: provider_credential`, where `credential` names another credential declared in the same profile. That referenced credential is broker-only and cannot declare workload injection metadata such as env vars, static placement, refresh, or token grant metadata. | +| `subject_token.subject_token_type` | No | RFC 8693 `subject_token_type` for the stored subject token. Defaults to `urn:ietf:params:oauth:token-type:access_token`. | +| `audience_overrides` | No | Endpoint-specific final-exchange `audience` and `scopes` overrides selected by host, port, and path. These overrides do not affect the gateway intermediate exchange. | + +Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. `token_exchange` also requires the gateway to have its own Workload API socket so it can present a gateway JWT-SVID during the intermediate exchange. They apply to HTTP traffic that the proxy can inspect. Endpoints with `tls: skip` bypass TLS termination and cannot receive dynamic token grant injection for HTTPS traffic. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. -Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. They apply to HTTP traffic that the proxy can inspect. Endpoints with `tls: skip` bypass TLS termination and cannot receive dynamic token grant injection for HTTPS traffic. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. +The gateway only brokers an intermediate token for a sandbox principal, and only when the requested provider is attached to that sandbox. It verifies the supervisor JWT-SVID issuer, audience, signature, and SPIFFE trust domain against the gateway's own JWT-SVID, then uses the verified supervisor SVID subject as the intermediate-token audience. ## Provider Instances @@ -400,15 +571,11 @@ openshell provider create \ --from-existing ``` -When `providers_v2_enabled=true`, `--from-existing` uses the provider profile's -`discovery` section. If no profile exists for the requested type, the command -fails instead of falling back to the legacy provider registry. When -`providers_v2_enabled=false`, `--from-existing` uses the legacy provider -registry and ignores profile `discovery` metadata. - -For example, with Providers v2 enabled, `--type openai --from-existing` -requires an imported `openai` profile with a `discovery` section. Setting -`OPENAI_API_KEY` alone is not enough for v2 profile discovery. +`--from-existing` uses the provider profile's `discovery` section. If no +profile exists for the requested type, the command fails instead of falling +back to the retired provider registry. The built-in `openai` profile discovers +`OPENAI_API_KEY`; the built-in `anthropic` profile discovers +`ANTHROPIC_API_KEY`. Create a provider from an imported custom profile: @@ -419,6 +586,36 @@ openshell provider create \ --credential CUSTOM_API_TOKEN ``` +Create a provider whose credential is stored by a configured gateway credential +driver: + +```shell +openshell provider create \ + --name openai-stored \ + --type openai \ + --credential OPENAI_API_KEY +``` + +The create/update API stores submitted provider credentials and secret refresh +material through the gateway's active credential storage path and persists only +internal credential handles. Secret refresh material includes OAuth refresh +tokens, client secrets, service-account private keys, and temporary AWS source +secrets. By default, the gateway stores AES-256-GCM encrypted credential +envelopes in the gateway database outside provider and refresh-state records. The Helm chart +creates a retained Kubernetes Secret for the default storage key-encryption key +and injects it into every gateway pod when no external credential driver is enabled. +`credential_drivers = []` is invalid. Multi-replica Kubernetes gateways can use +a shared database with the default encrypted store, or choose a shared backend +such as `kubernetes-secrets` or `vault`. + +Every `secret_material_keys` entry must name a key supplied in `material` in the +same configure request. Clients submit secret values, not internal credential +handles. + +Provider records that already contain inline database credentials remain +readable for upgrade compatibility. New provider create/update requests store +credential values through the active credential driver and persist only handles. + Provider profiles whose required credentials are fully runtime-resolvable through `token_grant` or gateway-managed refresh can be created without `--credential`. Inspect the provider: @@ -445,11 +642,49 @@ Use an ISO/RFC3339 timestamp or Unix epoch milliseconds. Use `0` as the timestam OpenShell skips expired provider credentials when it builds a sandbox provider environment. Running sandboxes also reject expired retained credential generations during placeholder resolution, so stale placeholders fail closed instead of forwarding unresolved or expired credential material. -## Configure Credential Refresh +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. -Refresh configuration is stored separately from the current injectable credential value. The gateway refresh worker reads refresh state, mints a new short-lived token for supported strategies, writes the token back to the provider record, and updates credential expiry metadata. +## Configure Credential Refresh -For a complete Microsoft Graph OAuth2 refresh-token walkthrough, see [Refresh Microsoft Graph Credentials with Providers v2](/get-started/tutorials/microsoft-graph-provider-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. + +For OAuth token endpoint failures, the gateway parses the bounded standard OAuth +error response and reports a structured recovery action through `provider +refresh status`. It does not retain provider error descriptions or raw response +bodies. The recovery actions are: + +- `retry`: a network, rate-limit, or temporary issuer failure; the worker retries automatically. +- `reauthorize`: a user refresh grant is no longer usable, such as `invalid_grant`; obtain a new grant and run `provider refresh configure`. +- `fix_configuration`: the OAuth client, scopes, grant type, or administrator policy must be corrected. +- `investigate`: the issuer returned an unrecognized response; the worker retries, but the operator should investigate persistent failures. + +The gateway parks `reauthorize` and `fix_configuration` failures instead of +retrying them every worker tick. A manual `provider refresh rotate` still +attempts the exchange. The current access token remains available only until +its recorded expiry, after which credential resolution fails closed. + +Before OpenShell 0.1.0, refresh-state and credential-driver migrations are not +supported. Upgrading from a build that stored refresh material inline requires +reconfiguring the refresh grant. To change credential drivers, delete or +reconfigure affected providers while the original driver is still available, +then select the new driver and create the credentials again. Do not run mixed +gateway versions against the same refresh records. + +Each explicit `refresh configure` call also starts a new gateway-owned +authorization epoch. Automatic refresh and `refresh rotate` preserve that epoch, +so running workloads keep the same opaque credential handle while the short-lived +token changes. Configuring refresh again is a revocation boundary and causes +running workloads that still hold the previous handle to fail closed. + +While gateway-managed refresh is configured, `provider update --credential` +cannot replace or delete its primary credential or any co-minted output. Use +`provider refresh rotate` to mint a new short-lived value. Re-run +`provider refresh configure` to start a new authorization, or use +`provider refresh delete` before returning those credential keys to manual +management. You can still update unrelated credentials, configuration, and +credential expiry metadata. + +For a complete Microsoft Graph OAuth2 refresh-token walkthrough, see [Refresh Microsoft Graph Credentials with a Provider Profile](/get-started/tutorials/microsoft-graph-provider-refresh). The profile YAML strategy values use underscores, while the CLI `--strategy` values use kebab-case: @@ -517,7 +752,7 @@ openshell provider refresh configure drive-work \ This example assumes you imported a custom profile with `id: google-drive`. -`--secret-material-key` takes the name of a `--material` key, not the secret value. For example, use `--material client_secret="$MS_CLIENT_SECRET"` with `--secret-material-key client_secret`. The key should match a material entry so OpenShell can record that material field as sensitive when it stores refresh state. The gateway uses the material values to mint future access tokens, but only the `--credential-key` value, such as `MS_GRAPH_ACCESS_TOKEN`, becomes the injectable provider credential. If a refresh response rotates an OAuth refresh token, OpenShell stores the new `refresh_token` material and marks `refresh_token` as secret automatically. +`--secret-material-key` takes the name of a `--material` key, not the secret value. For example, use `--material client_secret="$MS_CLIENT_SECRET"` with `--secret-material-key client_secret`. Prefer `--secret-material-env` so the value does not appear in shell history. The gateway combines caller markings with authoritative profile metadata and strategy-defined secret fields, then stores those values through the active credential driver. Only the `--credential-key` value, such as `MS_GRAPH_ACCESS_TOKEN`, becomes injectable. If an OAuth response rotates a refresh token, OpenShell stores the replacement through the credential driver automatically. Use `--credential-expires-at` when the current provider credential already has a known expiry timestamp. For refresh-managed keys, the value can be Unix epoch milliseconds or an ISO/RFC3339 timestamp such as `2026-01-01T00:00:00Z` or `2026-01-01T01:00:00+01:00`. OpenShell stores that value as epoch milliseconds in both refresh state and provider credential metadata. Later gateway-managed refreshes replace it with the minted token expiry. @@ -535,11 +770,22 @@ Check refresh status: openshell provider refresh status my-graph ``` +The status table includes `RECOVERY` and `FAILURE_CODE`. Clients should use the +structured recovery action rather than parsing `LAST_ERROR`. For example, +`oauth_invalid_grant` with recovery action `reauthorize` means the user must +complete OAuth authorization again; `oauth_invalid_client` with +`fix_configuration` means changing the user login alone will not repair the +grant. OpenShell parks `reauthorize` failures until an explicit rotation or +reconfiguration. It retries `fix_configuration` failures hourly so externally +repaired clocks, policies, or credential-store values can recover without +creating rapid token-endpoint traffic. Transient and unrecognized failures use +the existing bounded 60-second retry interval. + The status table reports operational state without printing token values or refresh material: ```text -PROVIDER CREDENTIAL_KEY STRATEGY STATUS EXPIRES_AT NEXT_REFRESH LAST_REFRESH LAST_ERROR -my-graph MS_GRAPH_ACCESS_TOKEN oauth2_refresh_token refreshed 2026-06-01 00:00:00 2026-05-31 23:50:00 2026-05-31 23:00:00 - +PROVIDER CREDENTIAL_KEY STRATEGY STATUS RECOVERY EXPIRES_AT NEXT_REFRESH LAST_REFRESH FAILURE_CODE LAST_ERROR +my-graph MS_GRAPH_ACCESS_TOKEN oauth2_refresh_token reauthorization_required reauthorize 2026-06-01 00:00:00 - 2026-05-31 23:00:00 oauth_invalid_grant OAuth refresh grant is no longer usable ``` When no refresh configuration exists, the CLI distinguishes whole-provider checks from credential-specific checks: @@ -585,13 +831,14 @@ openshell sandbox create \ -- claude ``` -When `providers_v2_enabled=true`, each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. When the setting is disabled, the sandbox receives provider credentials but not provider-derived policy entries. +Each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. A gateway-global policy suppresses provider-derived policy layers. Updating a custom provider profile affects every provider instance whose `type` matches that profile ID. Provider instances are not rewritten, and sandbox-authored policies are not modified. Running sandboxes observe the updated provider-derived policy on their next config sync. If a gateway-global policy is active, provider-derived policy layers remain suppressed. -Providers v2 does not infer or auto-attach providers from the sandbox command. -Attach providers explicitly with `--provider` during sandbox creation, or use -`openshell sandbox provider attach` after creation. +The CLI can infer a provider profile from a recognized sandbox command and +auto-create the provider from profile discovery. Attach a different existing +provider explicitly with `--provider`, or use `openshell sandbox provider +attach` after creation. List providers attached to a sandbox: @@ -600,6 +847,9 @@ openshell sandbox provider list provider-demo ``` The list output includes provider name, provider type, credential key count, and config key count. +Add `--output json` or `--output yaml` to return the provider name and type plus +sorted `credential_keys` and `config_keys` arrays. Structured output never +includes credential values, opaque credential handles, or config values. ## Policy Composition @@ -730,9 +980,9 @@ Provider attach and detach update the persisted sandbox provider list. Running s The policy effect applies to future effective policy reads after the sandbox observes the update. The credential environment effect applies only to new process launches after the update is observed, such as later SSH, exec, or SFTP sessions. -Already-running processes keep the environment they started with. OpenShell does not mutate a live process environment after provider attach, detach, or credential update. If a long-running process needs a newly attached provider credential placeholder, restart that process or launch a new process after the sandbox has observed the provider update. +Already-running processes keep the placeholder environment they started with. OpenShell does not mutate a live process environment after provider attach, detach, or credential update. The proxy resolves existing placeholders against current credentials and bindings, so rotation, expiry, endpoint changes, and detach take effect without restarting the process. If a long-running process needs a newly attached provider credential placeholder, restart that process or launch a new process after the sandbox has observed the provider update. -Detaching a provider removes its provider policy layer from future effective policy reads and removes its credential placeholders from future process environments. It does not remove environment variables from already-running processes. +Detaching a provider removes its provider policy layer from future effective policy reads, revokes resolution for its existing placeholders, and removes its credential placeholders from future process environments. It does not remove the placeholder strings from already-running process environments. OpenShell rejects provider updates and refresh configuration when they would make two providers attached to the same sandbox expose the same active credential environment key. It also rejects attached provider sets with ambiguous dynamic token grants at equal host/path specificity. Use provider-specific credential names and make one dynamic grant selector more specific when one sandbox needs multiple providers with overlapping upstream concepts. diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index 2ef54de70c..ebfd5c7cbc 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -47,7 +47,7 @@ Set these environment variables before starting the gateway: For local access, the server certificate must be valid for the endpoint the CLI uses. Include `localhost`, `127.0.0.1`, and `::1` in the certificate SANs when users connect to a local gateway through loopback. -Package-managed local gateways generate this bundle automatically for the `openshell` gateway name. Homebrew uses `https://[::1]:17670` by default; Debian and RPM use `https://127.0.0.1:17670`. +Package-managed local gateways generate this bundle automatically for the `openshell` gateway name. Homebrew registers `https://localhost:17670`; Debian and RPM use `https://127.0.0.1:17670`. When you register a package-managed local gateway with `openshell gateway add --local --name openshell`, the CLI refreshes its mTLS bundle from the package-managed TLS directory. On Homebrew, the gateway service also mirrors the Docker sandbox client bundle into `$HOME/.local/state/openshell/homebrew/tls` before startup so Docker Desktop can bind-mount the files into sandbox containers. @@ -91,7 +91,7 @@ The same settings are available through environment variables: |---|---|---| | `OPENSHELL_OIDC_ISSUER` | OIDC issuer URL. The gateway discovers `/.well-known/openid-configuration` from this URL. | None | | `OPENSHELL_OIDC_AUDIENCE` | Expected JWT `aud` claim. | `openshell-cli` | -| `OPENSHELL_OIDC_JWKS_TTL` | JWKS cache TTL in seconds. The gateway also refreshes on an unknown key ID. | `3600` | +| `OPENSHELL_OIDC_JWKS_TTL` | JWKS cache TTL in seconds. The gateway also refreshes on an unknown key ID. Must be greater than zero. | `3600` | | `OPENSHELL_OIDC_ROLES_CLAIM` | Dot-separated claim path containing roles. | `realm_access.roles` | | `OPENSHELL_OIDC_ADMIN_ROLE` | Role required for admin operations. | `openshell-admin` | | `OPENSHELL_OIDC_USER_ROLE` | Role required for standard user operations. | `openshell-user` | @@ -120,23 +120,97 @@ openshell gateway add https://gateway.example.com \ --oidc-audience openshell-cli ``` -When you register or log in to an OIDC gateway, the CLI uses the Authorization Code flow with PKCE. It opens a browser, receives the authorization code on a localhost callback, exchanges the code for tokens, and stores the token bundle under the gateway credential directory. If `OPENSHELL_OIDC_CLIENT_SECRET` is set, the CLI uses the client credentials flow instead. Use that mode for CI and other non-interactive automation. +When you register or log in to an OIDC gateway, the CLI uses the Authorization Code flow with PKCE. It opens a browser, receives the authorization code on a localhost callback, exchanges the code for tokens, and stores the token bundle under the gateway credential directory. After `openshell gateway logout`, the next browser login asks the identity provider for a fresh login prompt so you can choose a different browser user instead of silently reusing the previous session. If `OPENSHELL_OIDC_CLIENT_SECRET` is set, the CLI uses the client credentials flow instead. Use that mode for CI and other non-interactive automation. + +Official Python, TypeScript, and Go SDKs can perform renewable client-credentials +authentication directly. Configure the service account at the identity provider +with the audience, roles, scopes, and workspace membership required by the +gateway. The SDKs discover the token endpoint, attach the bearer token to each +RPC, and repeat the grant before expiry. They keep the client secret and access +token in memory and do not update the CLI's `oidc_token.json`. SDK clients +require TLS when sending these credentials to a non-loopback gateway. + + + + +```python +from openshell import ClientCredentialsAuth, SandboxClient + +auth = ClientCredentialsAuth( + client_secret=lambda: load_secret(), + # Omit issuer/client_id/scopes/audience to use active gateway metadata. +) +client = SandboxClient.from_active_cluster(client_credentials=auth) +``` + + + + +```ts +import { clientCredentials, OpenShellClient } from '@nvidia/openshell-sdk' + +const client = await OpenShellClient.connect({ + gateway: 'https://gateway.example.com', + oidcTokenProvider: clientCredentials({ + issuer: 'https://idp.example.com/realms/openshell', + clientId: 'openshell-service', + clientSecret: () => loadSecret(), + audience: 'openshell-gateway', + scopes: ['sandbox:read', 'sandbox:write'], + }), +}) +``` + + + + +```go +auth, err := oidc.NewClientCredentialsAuth( + oidc.WithGateway("production"), + oidc.WithClientSecretProvider(loadSecret), +) +client, err := v1.NewClient(v1.Config{Address: address, Auth: auth, TLS: tlsConfig}) +``` + + + The connection flow: +For a headless environment, set `OPENSHELL_NO_BROWSER=1` before registering or logging in to the gateway. When this variable is set and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI uses the Device Authorization Grant (RFC 8628) with S256 PKCE. This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider. + 1. The CLI loads the stored OIDC token bundle. 2. If the access token is expired and a refresh token is available, the CLI refreshes it with the OIDC scopes saved in the gateway metadata. 3. The CLI connects to the gateway and attaches `authorization: Bearer ` metadata to each gRPC request. -4. The gateway validates the JWT signature, issuer, audience, expiration, and key ID against the issuer's JWKS. +4. The gateway validates the JWT signature, issuer, audience, expiration, subject, and key ID against the issuer's JWKS. 5. The gateway extracts roles and optional scopes from the configured claim paths. 6. The gateway authorizes the gRPC method. Platform-scoped methods require the configured admin role. Workspace-scoped methods require the configured user role and a sufficient membership in the target workspace. Admin role holders satisfy user-role checks and bypass workspace membership checks. For the Platform Admin, Workspace Admin, and Workspace User permissions, refer to [Manage Workspaces and Access](/sandboxes/manage-workspaces). +#### JWT validation + +The gateway validates OIDC access tokens against the issuer's JWKS endpoint. It derives the signing algorithm from each JWK's `kty` and `crv` fields, not from the JWT header: + +| JWK `kty` | `crv` | Algorithm | +|---|---|---| +| RSA | — | RS256, RS384, RS512, PS256, PS384, or PS512 | +| EC | P-256 | ES256 | +| EC | P-384 | ES384 | +| OKP | Ed25519 | EdDSA (Ed25519) | + +For EC and OKP keys, `crv` fully determines the algorithm. For RSA keys, the algorithm is selected from the JWK's declared `alg` field when it names one of the six RSA algorithms above; if `alg` is absent (as with Microsoft Entra ID, which omits it entirely), the gateway defaults to RS256. If `alg` names a non-RSA algorithm, the key is treated as contradictory and skipped (see below) rather than falling back to RS256. + +The gateway rejects tokens when the JWT header `alg` does not match the algorithm pinned from the JWK. Tokens must include `iss`, `aud`, `exp`, and `sub` claims. The `iss` and `aud` values must match the configured issuer and audience. + +JWKs with `use: enc`, an `alg` that genuinely contradicts the derived algorithm (for example an RSA key declaring `ES256`), or `key_ops` that excludes `verify` are skipped. The gateway refreshes the JWKS cache when it encounters an unknown key ID, throttled to at most once per second to bound how much an unknown-`kid` lookup can amplify requests against the issuer. If a refresh yields zero usable keys, the gateway keeps serving the previous key set rather than dropping all signing keys and failing every request, but only for up to three times the configured TTL. If the JWKS is still empty after that grace period, the cached keys are evicted and all tokens are rejected until the issuer recovers. + +Common identity providers such as Keycloak (RS256), Microsoft Entra ID (RSA), and Okta (often ES256) publish compatible signing keys. + If `OPENSHELL_OIDC_SCOPES_CLAIM` is set, the gateway also enforces scopes. It accepts space-delimited scope strings such as `scope: "openid sandbox:read"` and JSON arrays such as `scp: ["sandbox:read"]`. Standard OIDC scopes such as `openid`, `profile`, `email`, and `offline_access` are ignored for authorization. `openshell:all` grants access to all scoped methods. -Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, the gateway mints that token only after TokenReview validates the projected ServiceAccount token, the pod UID matches the live pod, and the pod's controlling `Sandbox` ownerReference matches the live Sandbox CR. Log upload, policy status, credential environment lookup, inference bundle lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. `GetInferenceBundle` returns route material that includes provider credentials, so it requires a sandbox principal; user callers manage inference configuration through the user-facing inference APIs instead. +Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, the Kubernetes compute driver validates the projected ServiceAccount token with TokenReview, verifies the live pod UID and controlling `Sandbox` ownerReference, and returns the authenticated sandbox ID to the gateway. The gateway verifies that sandbox still exists before minting its JWT. Log upload, policy status, credential environment lookup, inference bundle lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. `GetInferenceBundle` returns route material that includes provider credentials, so it requires a sandbox principal; user callers manage inference configuration through the user-facing inference APIs instead. Re-authenticate an OIDC gateway with: diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 4db3c9c472..1c846c29a8 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -18,6 +18,8 @@ Gateway CLI flag > gateway OPENSHELL_* env var > TOML file > built-in defa `database_url` is env-only. The loader rejects it when it appears in the file. When `OPENSHELL_DB_URL` is unset, the gateway stores its SQLite database under `$XDG_STATE_HOME/openshell/gateway/openshell.db`. +`name` assigns an operator-facing identity to the gateway installation. Set it with `[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. It defaults to `openshell`; the Helm chart defaults it to the chart fullname so all replicas in one installation share a name. Chart fullnames are only unique within their Kubernetes namespace, so set `server.name` explicitly when one collector receives telemetry from multiple namespaces or clusters. This identity is independent of client-side gateway aliases, TLS names, and `gateway_jwt.gateway_id`. + ## Package-Managed Locations Package-managed gateways do not require a TOML file. Create one at the package's optional config location when you need to override built-in defaults. Set `OPENSHELL_GATEWAY_CONFIG` in the launch environment to use a different file. @@ -31,11 +33,11 @@ Package-managed gateways do not require a TOML file. Create one at the package's 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. The Podman driver negotiates separate, restricted listeners for sandbox callbacks, 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. -The Homebrew formula creates its prefix config once with `bind_address = "[::1]:17670"`. Keeping the primary API on IPv6 loopback leaves IPv4 loopback available for Podman Machine's sandbox callback listener. A user config takes precedence, and upgrades do not overwrite either config. +The Homebrew formula creates its prefix config without setting `bind_address`, so the gateway uses its built-in `127.0.0.1:17670` primary listener. Docker Desktop and Podman Machine reuse that listener for sandbox callbacks. A user config takes precedence. Upgrades preserve user-edited configs and migrate only an unchanged prefix config generated with the affected IPv6-loopback default. ## Layout -The file is rooted at `[openshell]`. Gateway-wide settings live under `[openshell.gateway]`. Each compute driver owns its own `[openshell.drivers.]` table. Shared keys set at gateway scope are inherited into driver tables when not overridden. +The file is rooted at `[openshell]`. Gateway-wide settings live under `[openshell.gateway]`. Each compute driver owns its own `[openshell.drivers.]` table. Credential drivers own `[openshell.credential_drivers.]` tables. Shared compute-driver keys set at gateway scope are inherited into compute driver tables when not overridden. ```toml [openshell] @@ -52,6 +54,9 @@ version = 1 [openshell.drivers.kubernetes] # ... driver-specific settings ... + +[openshell.credential_drivers.kubernetes-secrets] +# ... credential-driver-specific settings ... ``` ## Full Example @@ -66,6 +71,7 @@ A complete gateway configuration covering every section. Trim to the fields you version = 1 [openshell.gateway] +name = "production-us-west" bind_address = "0.0.0.0:8080" health_bind_address = "0.0.0.0:8081" metrics_bind_address = "0.0.0.0:9090" @@ -76,6 +82,10 @@ log_level = "info" # VM is never auto-detected and requires an explicit entry here. compute_drivers = ["kubernetes"] +# Optional external provider credential storage backend. Omit this key to use +# the gateway's default encrypted database credential storage. +credential_drivers = ["kubernetes-secrets"] + sandbox_namespace = "openshell" ssh_session_ttl_secs = 3600 @@ -95,7 +105,7 @@ disable_tls = false # Shared driver defaults. These inherit into [openshell.drivers.] tables # when the driver-specific table does not override them. -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" # Defaults to the gateway version; override to pin a specific build. # supervisor_image = "ghcr.io/nvidia/openshell/supervisor:" client_tls_secret_name = "openshell-client-tls" @@ -123,16 +133,24 @@ provider_profile_sources = [ # both the gateway and sandbox supervisors. [[openshell.supervisor.middleware]] name = "local-content-guard" -grpc_endpoint = "http://host.openshell.internal:50051" -max_body_bytes = 262144 +grpc_endpoint = "https://host.openshell.internal:50051" +tls_ca_cert_path = "/etc/openshell/certs/content-guard-ca.pem" +audience = "urn:openshell:middleware:local-content-guard" +max_payload_bytes = 262144 timeout = "500ms" # Gateway listener TLS (distinct from the per-driver guest_tls_*). +# client_ca_path is optional; omit it for HTTPS-only listeners that do not +# verify client certificates. [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" key_path = "/etc/openshell/certs/gateway-key.pem" client_ca_path = "/etc/openshell/certs/client-ca.pem" require_client_auth = false +# Optional: SNI-based dual certificate for external (e.g. ACME) TLS. +# external_cert_path = "/etc/openshell/certs/external.pem" +# external_key_path = "/etc/openshell/certs/external-key.pem" +# external_server_names = ["gateway.example.com"] [openshell.gateway.gateway_jwt] signing_key_path = "/etc/openshell/jwt/signing.pem" @@ -156,7 +174,7 @@ service_name = "openshell-gateway" [openshell.gateway.oidc] issuer = "https://idp.example.com/realms/openshell" audience = "openshell-cli" -jwks_ttl_secs = 3600 +jwks_ttl_secs = 3600 # Must be greater than zero. roles_claim = "realm_access.roles" admin_role = "openshell-admin" user_role = "openshell-user" @@ -165,6 +183,7 @@ scopes_claim = "" [[openshell.gateway.interceptors]] name = "quota" grpc_endpoint = "unix:///run/openshell/interceptors/quota.sock" +audience = "urn:openshell:interceptor:quota" order = 10 failure_policy = "fail_closed" binding_policy = "allowlist" @@ -180,10 +199,16 @@ failure_policy = "fail_closed" [[openshell.gateway.interceptors.bindings]] rpc = "openshell.v1.OpenShell/UpdateConfig" phases = ["validate"] + +[openshell.credential_drivers.kubernetes-secrets] +namespace = "openshell" +allow_reference_namespace = false ``` Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth] enabled = true` to authenticate CLI callers from verified client certificates. Kubernetes deployments must leave this unset and use OIDC or a trusted access proxy; the Helm chart does not render this table. +`[openshell.gateway.tls]` supports optional SNI-based dual-certificate mode for deployments that need separate internal and external server certificates. Set `external_cert_path` and `external_key_path` to point at the external (e.g. ACME/publicly-trusted) certificate and key. List the hostnames that should be served with the external certificate in `external_server_names`. Connections whose TLS SNI hostname matches one of those names receive the external certificate; all other connections (including those with no SNI) receive the primary internal certificate from `cert_path`/`key_path`. Both fields must be set together — providing only one is a configuration error. On Kubernetes with the Helm chart, the external certificate is managed automatically when `certManager.serverIssuerRef.name` is set; the chart populates these fields from the cert-manager-issued external server certificate. + `[openshell.gateway] policy_validation_failure_mode` controls what sandbox supervisors do when a complete candidate policy fails runtime validation. The default, `fail_closed`, deactivates the previous network policy, closes relays pinned to it, and denies new egress until a valid generation loads. `retain_last_valid` leaves the previous valid generation active. Both modes reject the candidate atomically; startup always fails closed when no previous valid generation exists. Gateway mutation paths that can preflight a known effective scope reject invalid candidates before persistence and leave the active policy unchanged regardless of this setting. Changing the value requires restarting the gateway so it can reload `gateway.toml` and distribute the new posture to sandbox supervisors. `[openshell.gateway.gateway_jwt] ttl_secs` controls gateway-minted sandbox JWT lifetime. When omitted, it defaults to `0`: the token `exp` claim and `expires_at_ms` response field become `0`, and the sandbox JWT does not expire. Use that default only for local single-player Docker, Podman, or VM gateways. Kubernetes and other shared deployments should set a positive TTL; Helm renders `3600` seconds by default, and the gateway logs a warning when a Kubernetes gateway uses `0`. @@ -208,11 +233,34 @@ The transport is **OTLP over gRPC only**. HTTP/protobuf and HTTP/JSON are not su The OpenTelemetry SDK logs export failures after startup. Spans in a failed batch are dropped rather than retried. -`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`. +`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`, `openshell.gateway.name` from the gateway's configured `name`, and `openshell.gateway.compute_driver`. Only OpenTelemetry traces are exported. Inbound gRPC and HTTP requests produce server spans named for the RPC or HTTP method. Store and compute-driver operations appear as child spans. Internal reconciliation, credential-refresh, and driver-watch loops create operation roots for their store work because no inbound request supplies a parent. The gateway continues valid W3C `traceparent` context and starts a new trace when none is supplied. Request spans carry `method`, `path`, and the `request_id` that also appears in gateway logs. Health endpoint spans use DEBUG level and are not exported by the default INFO filter. -The gateway forwards the OTLP configuration to managed external drivers. Each driver exports under its own service name. +The gateway forwards the OTLP configuration, configured gateway name, configured compute driver, and W3C trace context to managed external drivers. Built-in drivers also export their spans to the same collector through dedicated in-process providers. Driver spans retain the gateway trace context, use a distinct service name such as `openshell-driver-docker` or `openshell-driver-podman`, and carry the same `openshell.gateway.name` and `openshell.gateway.compute_driver` resource attributes as gateway spans. Compute-driver client and server spans use the same fully qualified protobuf operation name, such as `openshell.compute.v1.ComputeDriver/CreateSandbox`, in both the span name and `rpc.method`. The service name and span kind distinguish each side. Backend-prefixed child spans identify implementation work. A streaming watch records a terminal status when observed; consumer teardown without a terminal status leaves the span status unset. Operator-run external drivers own their own telemetry configuration. + +For Helm deployments, set `server.otlp.endpoint` to render this table. The +optional `server.otlp.serviceName` value overrides the gateway service name; +driver service names remain fixed. + +The local `mise run helm:k3s:create` workflow installs a trace collector and UI, +enables Agent Sandbox controller tracing on supported releases, and configures +both the controller and the existing Skaffold deployment to export traces to it +automatically. +The local `gateway`, `gateway:docker`, `gateway:podman`, and `gateway:vm` tasks +also set the gateway installation name in their generated configuration. Their +defaults are driver-specific (`kubernetes-dev`, `docker-dev`, `podman-dev`, and +`vm-dev`), and their documented `OPENSHELL_*_GATEWAY_NAME` overrides update both +CLI registration and exported telemetry identity. +Run `mise run helm:k3s:forward` to forward OTLP/gRPC to local port `4317` and +the trace UI to local port `18888`. When Skaffold has deployed a Kubernetes +gateway, the task also forwards it to local port `8090`; otherwise it continues +with the collector ports only. The Skaffold run task registers the forwarded +gateway under the worktree-specific k3d cluster name; select it with +`openshell gateway select `. The local Podman, Docker, and VM gateway +tasks export to the forwarded receiver automatically. + +In-process compute drivers read their backend-specific settings from `[openshell.drivers.]`. An external driver's gateway table supplies only its `socket_path`; configure the external driver process itself through that binary's flags or environment variables. A driver that advertises `supports_sandbox_authentication` may authenticate an opaque bootstrap credential through the compute-driver protocol. The gateway trusts the returned sandbox ID only for `IssueSandboxToken`, verifies that the sandbox still exists, and then mints its own JWT. The in-process Kubernetes driver reads `service_account_name`, `workspace_mode`, and namespace discovery from `[openshell.drivers.kubernetes]`; an external Kubernetes driver receives the equivalent values through its own CLI or environment contract. ### Tuning @@ -244,27 +292,44 @@ Register operator-run supervisor middleware services with one or more `[[openshe ```toml [[openshell.supervisor.middleware]] name = "local-content-guard" -grpc_endpoint = "http://host.openshell.internal:50051" -max_body_bytes = 262144 +grpc_endpoint = "https://host.openshell.internal:50051" +tls_ca_cert_path = "/etc/openshell/certs/content-guard-ca.pem" +audience = "urn:openshell:middleware:local-content-guard" +max_payload_bytes = 262144 timeout = "500ms" ``` -Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. Bindings are identified by operation and phase. A manifest may expose at most one binding for each operation and phase pair; V1 supports only `HttpRequest/pre_credentials`, so a service currently exposes one binding. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name. +Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. Bindings are identified by operation and phase. A manifest may expose at most one binding for each operation and phase pair. V1 supports `HttpRequest/pre_credentials` and `WebSocketMessage/pre_credentials`, so a service can inspect HTTP, WebSocket, or both. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name. The gateway connects to every registered service and validates `Describe` before it starts. The service must therefore be running before the gateway. Policy creation and full policy updates call `ValidateConfig`; an unavailable service or invalid middleware configuration rejects the policy before persistence. -`max_body_bytes` is the operator limit for every binding exposed by the service. It must be greater than zero, no larger than each binding's advertised limit, and no larger than the 4 MiB platform maximum. OpenShell rejects an oversized value instead of silently clamping it. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size body and its protobuf envelope fit on the transport. +`max_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and replacement bodies as well as complete WebSocket text messages and replacements. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. + +`timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies only to `EvaluateHttpRequest`, WebSocket preflight, and each WebSocket message. An accepted WebSocket stream has no connection-wide RPC deadline. -`timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The effective timeout covers `ValidateConfig` and `EvaluateHttpRequest`; `Describe` uses the service timeout because binding metadata is not available yet. +The service `grpc_endpoint` supports plaintext `http://` and TLS `https://`. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:`. After authenticated `Describe` succeeds, OpenShell treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. -The service `grpc_endpoint` currently supports plaintext `http://` and TLS `https://` using the platform trust store. Custom trust roots, client authentication, health checks, and runtime registration are not currently supported. The endpoint must be reachable from both the gateway and sandbox supervisors; use `host.openshell.internal` or another shared address that can be resolved in both places. +When `gateway_jwt` is configured, OpenShell attaches short-lived bearer credentials to gateway and supervisor calls and requires `https://`. A middleware endpoint must be reachable from sandbox supervisors, so Unix sockets are not an option here. Set `allow_insecure_transport = true` on a registration to keep a plaintext `http://` endpoint: OpenShell then attaches no credential, supervisors do not request one, and the gateway logs a warning naming the registration at every startup. mTLS client authentication, health checks, and runtime registration are not currently supported. The endpoint must be reachable from both the gateway and sandbox supervisors; use `host.openshell.internal` or another shared address that can be resolved in both places. -See [Supervisor Middleware](/extensibility/supervisor-middleware) for selection, failure, body-limit, and operational guidance. +See [Supervisor Middleware](/extensibility/supervisor-middleware) for selection, failure, payload-limit, and operational guidance. ## Gateway Interceptors `[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target only methods in the gateway's built-in allowlist of unary mutation RPCs. New RPCs are non-interceptable until they are deliberately added to that allowlist; adding one does not require handler-specific interceptor code. Request bodies are exposed as protobuf JSON objects. Fields marked secret in the protobuf schema are recursively omitted from requests and post-commit responses. Interceptors cannot patch an omitted field or a containing object. +HTTPS interceptor endpoints use the platform trust store by default. Set `tls_ca_cert_path` to a PEM certificate bundle for a private CA; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:interceptor:`. After authenticated `Describe` succeeds, the gateway treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. When `gateway_jwt` is configured, network interceptors must use HTTPS and receive short-lived gateway-caller bearer credentials; local Unix sockets are also supported. Set `allow_insecure_transport = true` to keep a plaintext `http://` interceptor endpoint with no credential attached and a startup warning. + +### Extension Token Verification Endpoints + +When `gateway_jwt` is configured the gateway publishes two unauthenticated documents that extension services use to verify OpenShell callers: + +| Path | Contents | +| --- | --- | +| `/.well-known/jwks.json` | Single-key JWKS holding the Ed25519 public key and its `kid`. | +| `/.well-known/openid-configuration` | OIDC-shaped discovery metadata: the exact expected `iss`, an absolute `jwks_uri`, and `EdDSA` as the only supported signing algorithm. | + +The discovery document is OIDC-shaped rather than OIDC-compliant: `issuer` is the gateway identity (`openshell-gateway:`), not the URL the document is served from. Verifiers compare `iss` against that value and must not infer trust from the document's location; the serving TLS connection is what authenticates the gateway. Both endpoints return `404` when `gateway_jwt` is not configured. + `binding_policy` controls how the manifest and operator binding configuration combine: - `dynamic` enables valid manifest bindings and treats configured entries as optional narrowing overrides. This is the compatibility default. The gateway logs a startup warning because the interceptor controls its non-secret RPC authority. @@ -292,6 +357,91 @@ The gateway validates snapshot structure and provider-profile semantics. It trea `image_pull_policy` is intentionally not a shared gateway key. Kubernetes and Docker use `Always`, `IfNotPresent`, or `Never`. Podman uses `always`, `missing`, `never`, or `newer`. Set it inside the relevant driver table. +## Credential Drivers + +Set `credential_drivers` only when the gateway should store provider secrets in an external credential backend. Provider secrets include injectable credentials and gateway-only refresh material such as OAuth refresh tokens, client secrets, and service-account private keys. OpenShell supports at most one enabled credential driver at a time. When `credential_drivers` is omitted, the gateway uses its default encrypted database credential storage. `credential_drivers = []` is invalid in the TOML file; omit the field for the default encrypted store, or select a backend such as `kubernetes-secrets` or `vault`. + +Credential driver tables are backend-owned and live under `[openshell.credential_drivers.]`. Built-in drivers default to in-tree transport, so they do not need a `transport` field. Use `transport = "uds"` with an absolute `socket_path` only for a remote gRPC driver over a Unix domain socket. + +```toml +[openshell.gateway.credential_storage] +key_encryption_key_path = "/var/lib/openshell/credentials/key-encryption-key.bin" +``` + +For Kubernetes Secrets: + +```toml +[openshell.gateway] +credential_drivers = ["kubernetes-secrets"] + +[openshell.credential_drivers.kubernetes-secrets] +namespace = "openshell" +``` + +For Vault instead: + +```toml +[openshell.gateway] +credential_drivers = ["vault"] + +[openshell.credential_drivers.vault] +address = "http://vault.vault.svc.cluster.local:8200" +mount = "secret" +kv_version = "2" +auth_method = "kubernetes" +role = "openshell-gateway" +service_account_token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" +``` + +For the default encrypted database store, OpenShell stores provider credentials as JSON envelopes encrypted with AES-256-GCM in the gateway database. Each credential gets a random data-encryption key; the gateway wraps that key with a local key-encryption key. By default, the key-encryption key is created at `$XDG_STATE_HOME/openshell/gateway/credentials/key-encryption-key.bin` with owner-only permissions. Use `[openshell.gateway.credential_storage] key_encryption_key_env` instead of `key_encryption_key_path` to load a base64-encoded 32-byte key-encryption key from an environment variable. Back up the database and key-encryption key together; losing either makes stored credentials unrecoverable. In Kubernetes, the Helm chart creates a retained Secret containing the shared key-encryption key, injects it as `OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY`, and renders `key_encryption_key_env` for the gateway when no external credential driver is enabled. Multi-replica deployments need every replica to use the same database and key-encryption key; the chart default handles the key-encryption key side. + +For GitOps and `helm template` workflows where `lookup` returns empty, the chart-generated KEK Secret gets a random value on every render, making credentials unrecoverable. Set `server.credentialStorage.existingSecret` to the name of a pre-provisioned Secret containing the key-encryption key under the `key-encryption-key` data key. When set, the chart skips KEK Secret generation and references the provided Secret directly. + +```yaml +server: + credentialStorage: + existingSecret: my-preprovisioned-kek-secret +``` + +For `kubernetes-secrets`, `namespace` sets where OpenShell-managed provider Secret objects are stored. When omitted, the driver uses the in-cluster ServiceAccount namespace when available, otherwise `default`. The Helm chart creates a Role granting the gateway access to all Secrets in the credential namespace because OpenShell-managed Secret names are dynamic SHA-256 hashes that cannot be restricted with `resourceNames`. Deploy credential Secrets in a dedicated namespace (`server.credentialDrivers.kubernetesSecrets.namespace`) to limit the RBAC blast radius. + +For `vault`, `address` points at the Vault service, `mount` and `kv_version` describe the KV engine where OpenShell-managed provider secrets are stored, and `auth_method = "kubernetes"` logs in with the gateway Pod's ServiceAccount token. For local or development validation, use `auth_method = "token_file"` with `token_path = "/path/to/token"`. Do not put literal Vault tokens in TOML. + +Provider records that already contain inline database credentials remain readable for upgrade compatibility. New provider create/update requests still submit credential values through the normal API, but the gateway stores those values through the active credential storage path and persists only handles. Before OpenShell 0.1.0, OpenShell does not automatically migrate inline refresh material or credential handles between drivers. Reconfigure refresh grants after an upgrade. Before changing credential drivers, remove affected credentials while the original driver is still available, then select the new driver and create them again. Do not run mixed gateway versions against the same refresh records. + +For remote credential drivers, set `transport = "uds"` with `socket_path`. Omit `command`, `args`, and `startup_timeout_secs` when another service manager prestarts the driver socket. Keep backend tokens out of TOML; point the driver at mounted token files or native identity mechanisms instead. + +The built-in `kubernetes-secrets` and `vault` drivers can also run out of +process over UDS. Set `command` to the standalone driver binary and pass +driver-specific settings through `args`; the gateway appends `--bind-socket +` when it launches the process. + +```toml +[openshell.gateway] +credential_drivers = ["kubernetes-secrets"] + +[openshell.credential_drivers.kubernetes-secrets] +transport = "uds" +socket_path = "/run/openshell/credential-drivers/kubernetes-secrets.sock" +command = "/usr/libexec/openshell/openshell-driver-kubernetes-secrets" +args = ["--namespace", "openshell"] +``` + +```toml +[openshell.gateway] +credential_drivers = ["vault"] + +[openshell.credential_drivers.vault] +transport = "uds" +socket_path = "/run/openshell/credential-drivers/vault.sock" +command = "/usr/libexec/openshell/openshell-driver-vault" +args = [ + "--address", "http://vault.vault.svc.cluster.local:8200", + "--auth-method", "kubernetes", + "--role", "openshell-gateway", +] +``` + ## Driver References Each example is a complete TOML file for one compute driver. The examples repeat `[openshell]` and `[openshell.gateway]` so they stay copyable, and the driver tables list the accepted driver-specific keys. Driver-specific values override inherited gateway defaults. The gateway rejects unknown driver fields after inheritance is merged. @@ -315,16 +465,29 @@ compute_drivers = ["kubernetes"] cert_path = "/etc/openshell-tls/server/tls.crt" key_path = "/etc/openshell-tls/server/tls.key" client_ca_path = "/etc/openshell-tls/client-ca/ca.crt" +# When cert-manager serverIssuerRef is configured, these are populated by Helm: +# external_cert_path = "/etc/openshell-tls/server-external/tls.crt" +# external_key_path = "/etc/openshell-tls/server-external/tls.key" +# external_server_names = ["gateway.example.com"] [openshell.drivers.kubernetes] +# Workspace isolation mode. "shared" renders all sandboxes into a single +# namespace. "managed" auto-creates a K8s namespace per workspace +# (openshell-{gateway_id}-{workspace}). "operator" maps each workspace to a +# pre-provisioned namespace discovered via label selector or drop-in file. +workspace_mode = "shared" +# Gateway identity used in managed-mode namespace naming. Defaults to the +# gateway JWT gateway_id. Must be a DNS-1123 label. +# gateway_id = "openshell" namespace = "agents" service_account_name = "openshell-sandbox" -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "IfNotPresent" image_pull_secrets = ["regcred"] # Defaults to the gateway version; override to pin a specific build. # supervisor_image = "ghcr.io/nvidia/openshell/supervisor:" supervisor_image_pull_policy = "IfNotPresent" + # Use the image volume on Kubernetes >= 1.35 (GA in 1.36); switch to "init-container" # on older clusters or where the ImageVolume feature gate is off. supervisor_sideload_method = "image-volume" @@ -332,6 +495,32 @@ supervisor_sideload_method = "image-volume" # filesystem, and network enforcement in the agent container. "sidecar" moves # pod-level network enforcement and gateway session handling into a network sidecar. topology = "combined" +# Optional corporate HTTP forward proxy for policy-approved TLS egress. The +# sandbox workload cannot select or override these settings. Only http:// proxy +# endpoints and TLS CONNECT traffic are supported; plain HTTP egress remains +# direct. `no_proxy` bypasses only the corporate proxy, never OpenShell policy. +# https_proxy = "http://proxy.corp.example:8080" +# no_proxy = ".svc,.svc.cluster.local,10.96.0.0/12,10.244.0.0/16" +# Proxy credentials must be an existing Secret in the sandbox namespace. The +# key contains a `user:pass` value and is mounted only in the network +# supervisor container, never in workload environment or command arguments. +# proxy_auth_secret_name = "corporate-proxy-auth" +# proxy_auth_secret_key = "credentials" +# The gateway validates the Secret name/key syntax and their configuration +# relationship at startup; it does not read the Secret from the Kubernetes API. +# Kubernetes resolves the Secret when the Sandbox Pod starts. A missing key or +# Secret prevents that Pod from starting; unreadable or malformed `user:pass` +# content is validated fail-closed by the supervisor at startup and never +# falls back to direct egress. +# Proxy credential Secrets require `topology = "sidecar"`. Combined topology +# shares its credential mount with the workload and can make it readable by the +# sandbox group through Kubernetes `fsGroup` volume permission handling. +# Required with a credential Secret: Basic authentication to an http:// proxy +# is cleartext on the connection to that proxy. +# proxy_auth_allow_insecure = true +# Last resort for hostname-filtering proxy ACLs. The proxy resolves the target, +# so its ACL becomes part of the egress boundary for proxied connections. +# proxy_connect_by_hostname = true grpc_endpoint = "https://openshell-gateway.agents.svc:8080" ssh_socket_path = "/run/openshell/ssh.sock" client_tls_secret_name = "openshell-client-tls" @@ -356,15 +545,29 @@ provider_spiffe_workload_api_socket_path = "/spiffe-workload-api/spire-agent.soc # Explicit sandbox UID/GID for the supervisor container securityContext and # PVC init container. When unset, the driver auto-detects from OpenShift SCC # namespace annotations (openshift.io/sa.scc.uid-range) if present, falling -# back to 1000 on non-OpenShift clusters. +# back to 1000 on non-OpenShift clusters. Any non-root Linux UID/GID is valid. # sandbox_uid = 1500 # sandbox_gid = 1500 +# Operator-mode namespace discovery. At least one must be set when +# workspace_mode = "operator". Both can be combined. +# operator_namespace_label discovers namespaces matching a K8s label selector. +# operator_namespace_label = "openshell.ai/workspace=true" +# operator_namespace_file reads allowed namespaces from a JSON/YAML file +# (hot-reloaded on change, e.g. via ConfigMap volume mount). +# operator_namespace_file = "/etc/openshell/workspace-namespaces.json" + +[openshell.drivers.kubernetes.managed_ssh_ingress] +enabled = true +gateway_namespace = "openshell" +gateway_pod_selector = { "app.kubernetes.io/name" = "openshell", "app.kubernetes.io/instance" = "openshell" } [openshell.drivers.kubernetes.sidecar] # UID used by relaxed long-running network sidecars. Strict process/binary-aware # sidecars run as UID 0 so Kubernetes grants the required /proc inspection # capabilities into the effective set. In sidecar topology the network init -# container installs nftables rules that exempt the effective sidecar UID. +# container installs nftables rules that exempt the effective sidecar UID, so +# this dedicated infrastructure UID must remain at least 1000 and must not +# match the sandbox workload UID. proxy_uid = 1337 # Keep process/binary-aware network policy enabled in sidecar topology. Set # false to run the sidecar as proxy_uid, drop the sidecar's extra /proc @@ -373,6 +576,19 @@ proxy_uid = 1337 process_binary_aware_network_policy = true ``` +In managed workspace mode, the Kubernetes driver copies each explicitly named +`image_pull_secrets` Secret from `namespace` into the managed workspace +namespace on sandbox creation. Shared and operator modes require the Secret to +already exist in the sandbox namespace. + +For token-exchange provider profiles, the gateway also needs access to its own +SPIFFE Workload API socket. In Helm deployments, set +`server.providerTokenGrants.spiffe.enabled=true`; the chart mounts the socket +into the gateway pod and sets `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this validation path does not require gateway access to +the SPIRE OIDC discovery endpoint or its TLS CA. + ### Docker Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required); guest mTLS material is supplied as host paths. @@ -388,7 +604,7 @@ compute_drivers = ["docker"] [openshell.drivers.docker] socket_path = "/var/run/docker.sock" -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" # Docker vocabulary: Always | IfNotPresent | Never. Empty behaves like IfNotPresent. image_pull_policy = "IfNotPresent" sandbox_namespace = "docker-dev" @@ -428,10 +644,11 @@ compute_drivers = ["podman"] [openshell.drivers.podman] # Rootless socket path. For root Podman use /run/podman/podman.sock. -# Omit to auto-detect: the driver probes for a responsive Podman socket and -# fails to start if none respond. +# Omit to auto-detect: the driver probes for a responsive Podman socket, then +# asks the podman CLI where its socket is, and fails to start if neither finds +# one. Set this to pin a specific Podman machine instead. socket_path = "/run/user/1000/podman/podman.sock" -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "missing" # always | missing | never | newer grpc_endpoint = "https://host.containers.internal:17670" # The gateway overwrites gateway_port from bind_address at runtime. @@ -457,13 +674,25 @@ sandbox_pids_limit = 2048 # but increase process churn (each check spawns a conmon subprocess). # Set to 0 to disable health checks entirely. Default: 10. health_check_interval_secs = 10 +# User namespace mode for sandbox containers. Omit to use the default. +# Supported modes: auto, host, keep-id, no-map, private. +# userns = "auto" +# Explicit UID/GID mappings for userns = "private". Each entry is +# "container_id:host_id:size". Required when mode is "private"; rejected +# for other modes. Rootless Podman uses intermediate IDs (0:0:1, 1:1:65535); +# rootful Podman uses absolute host IDs (0:1000:1, 1:100000:65536). +# uidmap = ["0:0:1", "1:1:65535"] +# gidmap = ["0:0:1", "1:1:65535"] # Corporate forward proxy for sandbox egress. When set, the in-container # supervisor chains policy-approved TLS tunnels through this proxy with HTTP # CONNECT instead of dialing destinations directly. Plain-HTTP requests are -# not proxied and always dial the destination directly. Only http:// proxy -# URLs in explicit http://host:port form are supported: the scheme and port -# are both required, and a URL carrying a path, query, or fragment is -# rejected rather than silently truncated. +# not proxied and always dial the destination directly. http:// and https:// +# proxy URLs in explicit scheme://host:port form are supported: the scheme and +# port are both required, and a URL carrying a path, query, or fragment is +# rejected rather than silently truncated. For an https:// proxy the supervisor +# wraps the connection to the proxy in TLS before the CONNECT handshake, +# verifying the proxy certificate against the built-in and system roots plus +# the optional proxy_ca_bundle below. # NO_PROXY entries (hostnames, domain suffixes, IPs, CIDRs, each with an # optional :port qualifier) are dialed directly. A port-qualified entry only # bypasses that destination port. IP and CIDR entries also match hostnames @@ -503,20 +732,35 @@ health_check_interval_secs = 10 # supervisor, so a credential accepted here is never rejected in-container. # Keep gateway.toml and the auth file owner-readable only (mode 0600). # -# WARNING: the supervisor sends the credential as a Proxy-Authorization: -# Basic header over the plain-TCP connection to the http:// proxy. Basic +# WARNING: with an http:// proxy the supervisor sends the credential as a +# Proxy-Authorization: Basic header over the plain-TCP connection. Basic # auth is base64, not encryption: anyone on the network path between the # sandbox host and the proxy can recover the credential. Because of that, -# proxy_auth_file requires the explicit acknowledgement -# proxy_auth_allow_insecure = true; without it the configuration is -# rejected at gateway startup. Only opt in when the path to the proxy is a -# trusted network segment. +# proxy_auth_file with an http:// proxy requires the explicit +# acknowledgement proxy_auth_allow_insecure = true; without it the +# configuration is rejected at gateway startup. Only opt in when the path +# to the proxy is a trusted network segment. For an https:// proxy the +# credential is sent inside the verified TLS session, so the +# acknowledgement is not required (but tolerated if set). +# +# proxy_ca_bundle points at a PEM CA bundle (on the gateway host) trusted for +# the corporate proxy. A CA certificate is not secret, so the gateway +# bind-mounts it read-only into the sandbox. It is trusted in two places: the +# TLS handshake with an https:// proxy, and — because a TLS-intercepting proxy +# (mitmproxy, squid ssl-bump) re-signs tunneled server certificates with the +# same CA — the sandbox trust bundle and the supervisor's upstream +# re-encryption, so intercepted upstream TLS keeps working and sandbox +# workloads trust the re-signed certificates. It is valid with either an +# http:// or https:// proxy and requires a proxy URL; the file must exist and +# contain at least one certificate, or the sandbox fails closed at startup. # https_proxy = "http://proxy.corp.com:8080" # no_proxy = "*.svc.cluster.local,10.0.0.0/8" # proxy_auth_file = "/etc/openshell/secrets/proxy-auth" # proxy_auth_allow_insecure = true # Last resort for hostname-filtering proxy ACLs; see the warning above. # proxy_connect_by_hostname = true +# Corporate CA trusted for an https:// proxy and TLS-intercepting proxies. +# proxy_ca_bundle = "/etc/openshell/tls/proxy-ca.pem" ``` ### MicroVM @@ -537,10 +781,10 @@ compute_drivers = ["vm"] state_dir = "/var/lib/openshell/vm" # Where the gateway looks for the openshell-driver-vm subprocess binary. driver_dir = "/usr/local/libexec/openshell" -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" grpc_endpoint = "https://host.containers.internal:17670" # Empty falls back to default_image. -bootstrap_image = "ghcr.io/nvidia/openshell/sandbox:latest" +bootstrap_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" krun_log_level = 1 vcpus = 2 mem_mib = 2048 @@ -550,7 +794,52 @@ guest_tls_cert = "/var/lib/openshell/guest-tls/client.pem" guest_tls_key = "/var/lib/openshell/guest-tls/client-key.pem" # Resolved sandbox UID/GID for the rootfs /etc/passwd entry. # Defaults to 10001 when unset; matching GID is used if sandbox_gid is empty. +# Any non-root Linux UID/GID is valid. # sandbox_uid = 20001 +# Corporate forward proxy for sandbox egress. The keys, their semantics, and +# the fail-closed contract are identical to the Podman driver above: only TLS +# (CONNECT) egress is chained, plain-HTTP destination requests always dial +# directly, credentials must come from proxy_auth_file rather than the URL, +# an http:// proxy with credentials requires proxy_auth_allow_insecure, and +# any present-but-invalid value is rejected at gateway startup rather than +# degrading to a direct dial. proxy_auth_file and proxy_ca_bundle are paths on +# the gateway host. +# +# The sandbox cannot select or override these settings. They reach the guest +# supervisor on its command line through a per-sandbox file the driver writes +# into the overlay upperdir on every launch, so a sandbox image cannot supply +# its own values or disable the operator's by baking a file at that path. +# +# Reachability: a proxy on the corporate network needs no special address and +# works on every VM sandbox. The guest's callback to the gateway is unaffected +# and never traverses the proxy. +# +# A proxy on the gateway host itself is reachable only from libkrun-backed +# (non-GPU) sandboxes: their egress leaves through gvproxy, which NATs +# 192.168.127.254 to the host's 127.0.0.1, so address it as +# http://host.openshell.internal: rather than http://127.0.0.1:. +# GPU sandboxes run on the QEMU/TAP backend, which has no such NAT — +# host.openshell.internal resolves to the TAP host address, and the driver's +# nftables rules let the guest reach only the gateway port on the host. A +# gateway-host proxy URL is therefore rejected when the sandbox launches on +# QEMU, rather than timing out on every CONNECT; give GPU sandboxes a proxy +# address routable from the guest's masqueraded egress. +# +# Because a microVM has no bind mounts or container secrets, the driver stages +# the credential and the CA into the per-sandbox overlay disk: the credential +# root-only inside the guest, and both removed with the sandbox. The +# credential is therefore at rest in that overlay image on the gateway host — +# the same delivery the per-sandbox gateway token already uses, and a +# difference from the Podman secret model worth noting when choosing where to +# keep proxy credentials. +# https_proxy = "http://host.openshell.internal:8080" +# no_proxy = "10.0.0.0/8,.internal.example" +# proxy_auth_file = "/etc/openshell/secrets/proxy-auth" +# proxy_auth_allow_insecure = true +# Last resort for hostname-filtering proxy ACLs; see the Podman section above. +# proxy_connect_by_hostname = true +# Corporate CA trusted for an https:// proxy and TLS-intercepting proxies. +# proxy_ca_bundle = "/etc/openshell/tls/proxy-ca.pem" ``` ### Extension Driver diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 3afc6b9caf..a0c5d29737 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -30,7 +30,7 @@ network_middlewares: { ... } | `landlock` | object | No | Static | Configures Landlock LSM enforcement behavior. | | `process` | object | No | Static | Sets the user and group the agent process runs as. | | `network_policies` | map | No | Dynamic | Declares which binaries can reach which network endpoints. | -| `network_middlewares` | map | No | Dynamic | Selects ordered HTTP request middleware by destination host. | +| `network_middlewares` | map | No | Dynamic | Attaches ordered middleware by destination host; each implementation's manifest selects its supported HTTP and WebSocket operations. | Static fields are set at sandbox creation time. Changing them requires destroying and recreating the sandbox. Dynamic fields can be updated on a running sandbox with `openshell policy update` for incremental merges or `openshell policy set` for full replacement, and take effect without restarting. @@ -52,7 +52,7 @@ Controls filesystem access inside the sandbox. Paths not listed in either `read_ |---|---|---|---| | `include_workdir` | bool | No | When `true`, automatically adds the agent's working directory to `read_write`. | | `read_only` | list of strings | No | Paths the agent can read but not modify. Typically system directories like `/usr`, `/lib`, `/etc`. | -| `read_write` | list of strings | No | Paths the agent can read and write. Typically `/sandbox` (working directory) and `/tmp`. | +| `read_write` | list of strings | No | Paths the agent can read and write. Typically `/tmp`; set `include_workdir: true` to add the driver-resolved working directory. | **Validation constraints:** @@ -76,7 +76,6 @@ filesystem_policy: - /dev/urandom - /etc read_write: - - /sandbox - /tmp - /dev/null ``` @@ -123,9 +122,9 @@ Sets the OS-level identity for the agent process inside the sandbox. | `run_as_group` | string | No | Overrides the group name or GID selected by the compute driver. Docker and Podman fall back to the image's OCI `USER`. | **Validation constraint:** An explicit policy value must be `sandbox` or a -numeric UID/GID in the allowed sandbox range. Docker and Podman may select -other named identities or non-root system IDs only through OCI `USER` -fallback. Root identities are always rejected. +numeric UID/GID from `1` through `4294967294`. OpenShell rejects `0` as root +and `4294967295` as the invalid identity sentinel. Docker and Podman may select +other named identities through OCI `USER` fallback. Omission is preserved independently for each field. For example, setting only `run_as_user` keeps that explicit user while allowing the active driver to @@ -161,22 +160,25 @@ Each endpoint defines a reachable destination and optional inspection rules. | Field | Type | Required | Description | |---|---|---|---| -| `host` | string | Yes | Hostname or IP address. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. | +| `host` | string | Conditional | Hostname or IP address. Required for `protocol: tcp`; transparent TCP requires a valid DNS hostname and rejects literal IPs. A non-TCP proxy endpoint may omit `host` only when `allowed_ips` supplies the destination constraint. Supports a `*` wildcard inside the first DNS label only: `*.example.com`, `**.example.com`, and intra-label patterns like `*-aiplatform.googleapis.com` are accepted; bare `*`/`**`, TLD wildcards (`*.com`), and wildcards outside the first label are rejected at load time. Prefer exact hosts for `protocol: tcp`: a wildcard authorizes DNS queries for all matching names and can provide a DNS-label exfiltration channel. | | `port` | integer | Yes | TCP port number. | | `path` | string | No | Optional HTTP path glob used to select between L7 endpoints that share the same host and port. Empty means all paths. Use this when REST and GraphQL live under the same host, such as `/repos/**` and `/graphql`. | -| `protocol` | string | No | Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Omit for TCP passthrough. | -| `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. | +| `protocol` | string | No | Set to `tcp` with a valid DNS hostname to allow native TCP clients through policy DNS and transparent capture without payload inspection. Omit the field for L4 passthrough through an explicit proxy, including legacy hostless `allowed_ips` endpoints. Set to `rest` for HTTP method/path inspection, `websocket` for RFC 6455 upgrade and client text-message inspection, `graphql` for GraphQL-over-HTTP operation inspection, `mcp` for MCP Streamable HTTP request inspection, or `json-rpc` for generic JSON-RPC-over-HTTP method inspection. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket traffic. Provider-credentialed endpoints require an inspected protocol unless `allow_uninspected_credentials` is explicitly set. | +| `tls` | string | No | TLS handling mode. The proxy auto-detects TLS by peeking the first bytes of each connection and terminates it for inspected HTTPS traffic, so this field is optional in most cases. Set to `skip` to disable auto-detection for edge cases such as client-certificate mTLS or non-standard protocols. Provider-credentialed endpoints reject `tls: skip` unless `allow_uninspected_credentials` is explicitly set. The values `terminate` and `passthrough` are deprecated and log a warning; they are still accepted for backward compatibility but have no effect on behavior. | | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | | `access` | string | No | Access preset. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. Not valid on `protocol: mcp` or `protocol: json-rpc`; MCP uses explicit rules unless `mcp.allow_all_known_mcp_methods: true` enables the endpoint method profile, and JSON-RPC always uses explicit rules. | | `rules` | list of allow rule objects | No | Fine-grained protocol-specific allow rules. Mutually exclusive with `access`. | | `deny_rules` | list of deny rule objects | No | L7 deny rules that block specific requests even when allowed by `access` or `rules`. Deny rules take precedence over allow rules. | -| `allowed_ips` | list of string | No | CIDR or IP allowlist for SSRF override. Exact user-declared hostname endpoints may resolve to RFC 1918 private addresses without this field, but wildcard, hostless, and policy-advisor-proposed endpoints still require `allowed_ips` for private resolved IPs. Entries overlapping loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), or unspecified (`0.0.0.0`) are rejected at load time. | +| `allowed_ips` | list of string | No | CIDR or IP allowlist for SSRF override. Exact user-declared hostname endpoints may resolve to RFC 1918 private addresses without this field, but wildcard, hostless, and policy-advisor-proposed endpoints still require `allowed_ips` for private resolved IPs. A hostless allowlist is valid only for the legacy proxy path and cannot be combined with `protocol: tcp`. Entries overlapping loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), or unspecified (`0.0.0.0`) are rejected at load time. | | `allow_encoded_slash` | bool | No | When `true`, L7 request parsing preserves `%2F` inside path segments instead of rejecting it. Use this for registries and APIs such as npm scoped packages (`/@scope%2Fname`). Defaults to `false`. | -| `websocket_credential_rewrite` | bool | No | When `true` on a `protocol: rest` or `protocol: websocket` endpoint, OpenShell rewrites credential placeholders in client-to-server WebSocket text messages after an allowed HTTP `101` upgrade. Binary frames are relayed but not rewritten. Defaults to `false`. | -| `request_body_credential_rewrite` | bool | No | When `true` on a `protocol: rest` endpoint, OpenShell rewrites credential placeholders in UTF-8 `application/json`, `application/x-www-form-urlencoded`, and `text/*` request bodies before forwarding upstream. The proxy buffers at most 256 KiB and updates `Content-Length` after rewriting. For chunked requests, the limit counts framing, extensions, and trailers. Defaults to `false`. Mutually exclusive with `credential_signing`. | +| `websocket_credential_rewrite` | bool | No | When `true` on a `protocol: rest` or `protocol: websocket` endpoint, OpenShell rewrites credential placeholders in client-to-server WebSocket text messages after an allowed HTTP `101` upgrade. On provider-credentialed endpoints without `allow_uninspected_credentials`, OpenShell uses the parsed relay and rejects binary frames; text frames containing placeholders fail closed when rewrite is disabled. Defaults to `false`. | +| `request_body_credential_rewrite` | bool | No | When `true` on a `protocol: rest` endpoint, OpenShell rewrites credential placeholders in UTF-8 `application/json`, `application/x-www-form-urlencoded`, and `text/*` request bodies before forwarding upstream. The proxy buffers at most 256 KiB and updates `Content-Length` after rewriting. For chunked requests, the limit counts framing, extensions, and trailers. When rewrite is disabled and the sandbox has provider credentials, ordinary bodies continue to stream, but a reserved credential placeholder is rejected before its marker reaches upstream, including for providers without endpoint profiles. Defaults to `false`. Mutually exclusive with `credential_signing`. | +| `allow_uninspected_credentials` | bool | No | Explicit security-sensitive opt-in that permits a provider-credentialed endpoint to use traffic paths OpenShell cannot inspect or rewrite, including L4-only and `tls: skip` tunnels. Defaults to `false`. Policy proposals that set it require explicit security-flagged approval. | | `credential_signing` | string | No | Proxy-side credential signing mode. When set, the proxy strips the sandbox client's `Authorization` header and re-signs with real provider credentials. Values: `sigv4` (auto-detect payload mode from client headers), `sigv4:body` (buffer and hash body, max 10 MiB), `sigv4:no_body` (unsigned payload, stream body). Mutually exclusive with `request_body_credential_rewrite`. See [AWS SigV4](/providers/aws-sigv4). | | `signing_service` | string | No | AWS service name for SigV4 signing (e.g. `bedrock`, `s3`, `sts`). Required when `credential_signing` is set. | | `signing_region` | string | No | AWS region override for SigV4 signing (e.g. `us-east-1`). When omitted, the region is extracted from the endpoint hostname. Required for non-standard AWS endpoints where the region cannot be inferred. | +| `credential_binding` | object | No | Binds static credentials from an attached provider to this endpoint when that provider's profile defines no endpoints. This field is valid only in a sandbox-scoped policy. | +| `credential_binding.provider` | string | Yes with `credential_binding` | Exact name of the provider instance attached to the sandbox. The referenced provider must have a profile, and that profile must define no endpoints. | | `persisted_queries` | string | No | GraphQL hash-only behavior for `protocol: graphql` and GraphQL-over-WebSocket operation policy. Default is `deny`; use `allow_registered` only with `graphql_persisted_queries`. | | `graphql_persisted_queries` | map | No | Trusted GraphQL persisted-query registry keyed by hash or saved-query ID. Values contain `operation_type`, optional `operation_name`, and optional root `fields`. | | `graphql_max_body_bytes` | integer | No | Maximum GraphQL-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | @@ -189,6 +191,10 @@ Each endpoint defines a reachable destination and optional inspection rules. **Validation constraints:** - `access` and `rules` are mutually exclusive; setting both is rejected. +- `protocol: tcp` requires a valid DNS hostname. Hostless `allowed_ips`, IP-literal hosts, trailing-dot names, and malformed DNS selectors are rejected with a policy-validation error. +- `protocol: tcp` requires at least one port and rejects L7-only fields, including `path`, `enforcement`, `access`, `rules`, `deny_rules`, request rewriting and credential signing fields, and GraphQL, JSON-RPC, or MCP options. +- A sandbox runtime must support policy DNS and transparent TCP capture before it can activate a policy containing `protocol: tcp`. Docker and Podman provide this runtime support. +- Adding the first `protocol: tcp` endpoint to a running sandbox that started without one is rejected atomically because its DNS and capture substrate is startup infrastructure. Recreate the sandbox with a TCP endpoint. A sandbox that started with the substrate can remove and re-add TCP endpoints dynamically. - When `protocol` is set, at least one of `access` or `rules` is required for `rest`, `websocket`, `graphql`, and `sql`. - `mcp` and `json-rpc` reject `access` presets; use explicit `rules`. - `json-rpc` requires explicit `rules` with `allow.method`. @@ -197,9 +203,36 @@ Each endpoint defines a reachable destination and optional inspection rules. - `rules: []` (empty list) is rejected; use `access: full` or remove `rules`. - Non-empty `rules` must contain at least one effective allow clause; rules where every entry lacks an allow are rejected as deny-all. - `deny_rules: []` (empty list) is rejected; remove it if no denials are needed. +- `credential_signing` requires a resolvable AWS credential source before a sandbox policy can activate. Use an attached endpoint-bearing profile that declares `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` and covers the signed endpoint, or bind an attached endpointless profile that declares those keys with `credential_binding.provider`. Credential rewrite recognizes the canonical `openshell:resolve:env:KEY` placeholder form and whole-token provider-shaped aliases such as `provider-OPENSHELL-RESOLVE-ENV-API_TOKEN` when the referenced environment key exists in the configured provider credentials. +Static provider placeholders also require the request host, port, and path to +match their credential binding. Profile endpoints supply this boundary by +default. An endpointless profile can instead use a sandbox policy endpoint with +`credential_binding.provider` set to the exact attached provider name. OpenShell +rejects unattached providers, profileless providers, endpointful profiles, and +global policies that use this field. Network policy admission does not expand +the credential boundary unless the endpoint explicitly supplies this binding. +OpenShell rejects a request mismatch with HTTP 403 and +`credential_endpoint_mismatch`. Refer to [Static Credential Endpoint +Binding](/providers/profiles#understand-static-credential-endpoint-binding). + +This example allows the sandbox to reach Google Cloud Storage and binds the +static credentials from the attached `work-gcp` provider to that endpoint: + +```yaml showLineNumbers={false} +network_policies: + gcp_storage: + endpoints: + - host: storage.googleapis.com + port: 443 + protocol: rest + access: full + credential_binding: + provider: work-gcp +``` + #### Access Levels The `access` field accepts one of the following values on REST, WebSocket, and GraphQL endpoints. MCP and JSON-RPC endpoints reject `access` because HTTP method/path presets cannot authorize JSON-RPC safely. Use explicit MCP rules, set `mcp.allow_all_known_mcp_methods: true` for the MCP method profile, or use explicit JSON-RPC rules. @@ -497,7 +530,7 @@ Identifies an executable that is permitted to use the associated endpoints. **Category:** Dynamic -A map of up to 10 middleware configs selected after network and L7 policy admit an HTTP request. Each map key is the stable policy-local config identity. Middleware selection is independent of the network policy entry that admitted the request. Every matching config runs once by ascending `order` before provider credential injection. Order values must be unique across the policy, and runtime selection also enforces the 10-stage maximum. +A map of up to 10 middleware configs selected after network and L7 policy admit an HTTP request or WebSocket upgrade. Each map key is the stable policy-local config identity. Middleware selection is independent of the network policy entry that admitted the traffic. Every matching config runs once by ascending `order` before provider credential injection. WebSocket-capable bindings continue on client text messages after the upgrade. Order values must be unique across the policy, and runtime selection also enforces the 10-stage maximum. ```yaml showLineNumbers={false} network_middlewares: @@ -519,10 +552,10 @@ network_middlewares: | `middleware` | string | Yes | Built-in middleware name or operator-owned registration name. `openshell/` is reserved for built-ins. | | `order` | integer | No | Execution priority. Lower values run first, and values must be unique across the policy. Defaults to `0`; therefore, policies with multiple configs normally specify it explicitly. | | `config` | object | No | Implementation-owned configuration validated by the selected middleware. | -| `on_error` | string | No | `fail_closed` denies the request when the stage fails; `fail_open` skips the failed stage. Defaults to `fail_closed`. | +| `on_error` | string | No | Applies only after an advertised operation binding is selected. `fail_closed` denies the HTTP request or closes the WebSocket when that stage fails; `fail_open` skips a failed HTTP stage or disables a broken WebSocket stage for the rest of that connection. Defaults to `fail_closed`. | | `endpoints` | object | Yes | Host selector with required non-empty `include` and optional `exclude` lists, limited to 32 combined patterns. Exclusions take precedence. | -Host selectors use the same case-insensitive exact and DNS glob semantics as network endpoints: `*` matches exactly one DNS label and `**` matches one or more labels, so `**.example.com` covers subdomains but not `example.com` itself. Brace alternates are rejected at validation. Middleware runs only on HTTP requests the supervisor parses. A fail-closed selector that can cover a `tls: skip` endpoint is rejected because OpenShell cannot inspect that traffic. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. +Host selectors use the same case-insensitive exact and DNS glob semantics as network endpoints: `*` matches exactly one DNS label and `**` matches one or more labels, so `**.example.com` covers subdomains but not `example.com` itself. Brace alternates are rejected at validation. A matching attachment joins only the operation chains its implementation advertises. An HTTP-only attachment may inspect a WebSocket upgrade GET without joining the post-upgrade chain; OpenShell permits the messages and records `binding_not_selected` coverage regardless of `on_error`. WebSocket bindings inspect complete client text messages. Binary messages pass with `unsupported_message_type` coverage for active stages. A fail-closed selector that can cover a `tls: skip` endpoint is rejected because OpenShell cannot inspect that traffic through any operation. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. See [Supervisor Middleware](/extensibility/supervisor-middleware) for registration, failure behavior, body limits, and operational guidance. diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 675ffaff6a..1aead424b3 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -3,14 +3,42 @@ # SPDX-License-Identifier: Apache-2.0 title: "Sandbox Compute Drivers" sidebar-title: "Compute Drivers" -description: "Reference for Docker, Podman, MicroVM, and Kubernetes sandbox compute drivers." -keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, Reference" +description: "Reference for Docker, Podman, MicroVM, Kubernetes, and Windows MXC sandbox compute drivers." +keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, MXC, Reference" position: 4 --- -The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, and delete sandboxes through the gateway API. - -Every compute driver runs the OpenShell supervisor inside the sandbox workload. The supervisor launches the agent process, applies policy, routes egress through the proxy, injects configured credentials, and maintains the gateway session. +The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, stop, start, and delete sandboxes through the gateway API. + +Most compute drivers run the OpenShell supervisor inside the sandbox workload. +The supervisor launches the agent process, applies policy, routes egress through +the proxy, injects configured credentials, and maintains the gateway session. +A driver may instead set `driver_reports_runtime_readiness`. In that mode, +driver-reported readiness does not require a supervisor session. The canonical +create-time policy is part of `DriverSandboxSpec`; a driver that enforces policy +outside the standard supervisor fetches later revisions through the existing +sandbox configuration API. The Windows MXC driver reports its own readiness. + +Stop stops compute but retains the sandbox record and the driver's +persistent workspace boundary. Start reactivates the same driver resource. +Delete remains independent and removes compute plus driver-owned persistent +state. While a sandbox is stopped, gateway access paths and exposed services +remain unavailable. + +Restarting the gateway preserves this intent. The gateway does not stop Docker +or Podman containers during shutdown. At startup it sends idempotent start +requests for Docker, Podman, and MicroVM sandboxes that were intended to run; +already-running resources are unchanged, retained stopped compute is restarted, +and explicitly stopped sandboxes remain stopped. Kubernetes workloads continue +running independently of the gateway process. + +The gateway forwards one exact, persisted main-process specification to every +driver. Drivers serialize that specification in +`OPENSHELL_MAIN_PROCESS_SPEC`; they do not install an idle `sleep` workload or +reconstruct argv with shell parsing. Runtime restart policies are disabled so +an exited canonical process remains a terminal sandbox result. Exit code zero +produces `Completed`; a nonzero or signal-normalized exit produces `Error` +with the exact exit code. Driver and supervisor failures remain `Error`. ## Configure a Compute Driver @@ -21,11 +49,12 @@ Configure the compute driver on the gateway. Current releases accept one driver compute_drivers = ["docker"] ``` -Reserved built-in values are `docker`, `podman`, `kubernetes`, and `vm`. +Reserved built-in values are `docker`, `podman`, `kubernetes`, `vm`, and `mxc`. +The `mxc` driver is available only in native Windows gateway builds. Non-reserved names select an extension driver and require a `socket_path` in `[openshell.drivers.]`. -When `compute_drivers` is unset, the gateway auto-detects Kubernetes, then Podman, then Docker. Local container runtimes must respond to an API probe before the gateway selects them. The VM driver is never auto-detected; configure it explicitly with `compute_drivers = ["vm"]` or set `OPENSHELL_DRIVERS=vm` in the launch environment. +When `compute_drivers` is unset, the gateway auto-detects Kubernetes, then Podman, then Docker. Docker must respond on a known API socket. Podman first probes known API sockets and then asks the `podman` CLI for the active native or machine-backed socket. The VM driver is never auto-detected; configure it explicitly with `compute_drivers = ["vm"]` or set `OPENSHELL_DRIVERS=vm` in the launch environment. Common gateway options: @@ -47,16 +76,18 @@ compute_drivers = ["kyma"] socket_path = "/run/openshell/kyma.sock" ``` -For a launch-time socket override, pass the same non-reserved driver name with -the socket path: +For a launch-time socket override, pass the selected driver name with the +socket path. The endpoint replaces normal driver construction for that name, +including canonical built-in names: ```shell openshell-gateway --drivers kyma --compute-driver-socket /run/openshell/kyma.sock +openshell-gateway --drivers docker --compute-driver-socket /run/openshell/docker.sock ``` -The gateway does not spawn, supervise, or delete extension drivers. The -operator must protect the socket so only the gateway uid can access it. -Reserved built-in names cannot be selected through unmanaged socket endpoints. +The gateway connects to the operator-provided endpoint; it does not provision +or supervise the remote driver. The operator must protect the socket so only +the gateway uid can access it. Sandbox create supports `--cpu` and `--memory` for per-sandbox compute sizing. Docker and Podman apply them as runtime limits. Kubernetes applies them as both @@ -107,15 +138,15 @@ It overrides the gateway's configured default runtime class for that sandbox, while a typed `SandboxTemplate.runtime_class_name` value from the API still takes precedence. -Docker and Podman callback listeners accept only supervisor callback gRPC -methods. Use the gateway's primary endpoint for CLI, administrator, health, -reflection, inference-route management, and HTTP requests. A -`PermissionDenied` response from one of the sandbox-visible callback addresses -is expected for those requests. The gateway fails startup if a callback -requirement resolves to the exact primary listener address because one socket -cannot preserve both authorization scopes. For the IPv4-loopback callback used -by Podman Machine, bind the primary listener to a distinct address such as -`[::1]:17670`. +Docker and Podman report the address through which their sandboxes can reach +the gateway. If the primary listener covers that address, the gateway reuses +it and sandbox JWT authentication restricts the supervisor to its callback RPC +allowlist. If the primary listener is not reachable through that address, the +gateway creates an additional callback-only listener. Use the primary endpoint +for CLI, administrator, health, reflection, inference-route management, and +HTTP requests. A `PermissionDenied` response from an additional callback-only +listener is expected for those requests. Do not broaden the primary listener +to `0.0.0.0` solely to make sandbox callbacks reachable. ## Docker Driver @@ -123,10 +154,27 @@ by Podman Machine, bind the primary listener to a distinct address such as The gateway talks to the Docker daemon to create sandbox containers. Docker is also required for local image builds from directories or Dockerfiles. +Docker Desktop and compatible macOS runtimes route `host.openshell.internal` +through an IPv4 host-gateway alias. The gateway reuses an IPv4 primary listener +that already covers loopback. Otherwise, the Docker driver requests a separate +`127.0.0.1:` callback-only listener. + For maintainer-level implementation details, refer to the [Docker driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-docker/README.md). Select Docker with `compute_drivers = ["docker"]` in `[openshell.gateway]`. Configure Docker driver values such as `socket_path`, `grpc_endpoint`, `network_name`, `supervisor_bin`, `supervisor_image`, `image_pull_policy`, `ssh_socket_path`, `sandbox_pids_limit`, and `guest_tls_*` in `[openshell.drivers.docker]`. When `socket_path` is unset, the driver uses the same responsive local socket selected by auto-detection. An explicitly selected Docker driver falls back to `/var/run/docker.sock` when no candidate responds. +When operating `openshell-driver-docker` as an external driver, set +`OPENSHELL_OTLP_ENDPOINT` to export its spans. The driver continues W3C trace +context from gateway RPCs and reports as `openshell-driver-docker`. + +Stop stops the existing Docker container without removing its writable +layer or attached volumes. Start starts that same container. A durably +stopped container stays stopped across gateway restart, and delete remains +responsible for removing it. Graceful gateway shutdown stops running-intent +Docker containers through the driver RPC without recording an explicit +sandbox stop. On startup, the gateway reconciles that retained intent with an +idempotent start request. Explicitly stopped sandboxes remain stopped. + For GPU-backed Docker sandboxes, configure Docker CDI before starting the gateway so OpenShell can detect the daemon capability. ### Docker Driver Config Mounts @@ -178,22 +226,51 @@ Docker mount schema: OpenShell rejects mount `source`, `target`, and Docker volume `subpath` values with surrounding whitespace. OpenShell also rejects mount targets that replace -the workspace root, container root, supervisor files, `/etc/openshell`, -`/etc/openshell-tls`, authentication material, or network namespace paths. These -checks do not make host bind mounts safe. +the workspace root or container root, or contain or are contained by the +configured SSH socket or reserved `/opt/openshell`, `/etc/openshell`, +`/etc/openshell-tls`, `/run/openshell`, `/run/openshell-sidecar`, and network +namespace roots. These checks do not make host bind mounts safe. ## Podman Driver [Podman](https://podman.io/)-backed sandboxes run as rootless containers on the gateway host. Use Podman for Linux workstation workflows that avoid a rootful Docker daemon. -The gateway talks to the Podman API socket. The Podman driver requires Podman 5.x, cgroups v2, rootless networking, and an active Podman user socket. When `socket_path` is not set, the driver probes for a responsive Podman socket and fails to start if none respond. +The gateway talks to the Podman API socket. The Podman driver requires Podman 5.x, cgroups v2, rootless networking, and an active Podman user socket. When `socket_path` is not set, the driver probes known socket paths, then uses the `podman` CLI to resolve the active native or machine-backed connection. It fails to start if neither method finds a socket. For maintainer-level implementation details, refer to the [Podman driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-podman/README.md) and [Podman networking notes](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-podman/NETWORKING.md). Select Podman with `compute_drivers = ["podman"]` in `[openshell.gateway]`. Configure Podman driver values such as `socket_path`, `network_name`, `supervisor_image`, `stop_timeout_secs`, `image_pull_policy`, `grpc_endpoint`, `host_gateway_ip`, `sandbox_ssh_socket_path`, `sandbox_pids_limit`, and `guest_tls_*` in `[openshell.drivers.podman]`. +### macOS Podman Socket Path + +On macOS, Homebrew-installed Podman does not create the default socket path +that the driver probes (`~/.local/share/containers/podman/machine/podman.sock`). +The actual API socket lives under `/var/folders/` in a path that macOS can +rotate after a reboot. + +If the gateway fails with `Podman socket not found; is podman machine running?` +while `podman machine list` shows a running machine, set the +`OPENSHELL_PODMAN_SOCKET` environment variable to the dynamic socket path: + +```shell +export OPENSHELL_PODMAN_SOCKET="$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}')" +``` + +Add this to your shell profile or gateway launch environment so it resolves +correctly after each reboot. Alternatively, set `socket_path` in +`[openshell.drivers.podman]` to the current path, but note that the path may +change when macOS rotates `/var/folders/`. + Podman sandboxes default to a 45-second graceful stop window before Podman escalates from `SIGTERM` to `SIGKILL`. Set `stop_timeout_secs` in gateway config, or `OPENSHELL_STOP_TIMEOUT` for the standalone driver, when a local runtime needs a different teardown window. +Stop stops the existing Podman container while retaining its named workspace +volume and driver-owned secrets. Start starts the same container. Delete is +the operation that removes the container and named volume. Graceful gateway +shutdown stops running-intent Podman containers through the driver RPC without +recording an explicit sandbox stop. On startup, the gateway reconciles that +retained intent with an idempotent start request while leaving explicitly +stopped sandboxes alone. + For proxy-required networks, the Podman driver also accepts the corporate egress proxy keys `https_proxy`, `no_proxy`, `proxy_auth_file`, `proxy_auth_allow_insecure`, and `proxy_connect_by_hostname`. The supervisor chains policy-approved TLS tunnels through the proxy with HTTP CONNECT instead of dialing destinations directly. See the [Gateway Configuration File](./gateway-config) reference for the full contract, including the cleartext-credential acknowledgement and the validated-IP CONNECT behavior. On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192.168.127.254`, for sandbox host aliases by default. Set `host_gateway_ip` only when your Podman machine uses a non-standard host-loopback address. On Linux, an empty `host_gateway_ip` keeps Podman's `host-gateway` resolver behavior. Direct local callbacks from rootless Podman require Podman to report the pasta network helper. Slirp4netns, other helpers, and Podman versions that do not report their helper require an explicitly remote `grpc_endpoint`; otherwise the gateway fails startup rather than leaving sandbox callbacks unreachable. Rootful Podman continues to use the configured network's bridge gateway address. @@ -264,7 +341,11 @@ The VM driver boots a cached immutable bootstrap ext4 root disk. When the reques VM sandbox creation follows the same progress model as Kubernetes-backed sandboxes. The gateway accepts the sandbox, then the VM driver publishes watch events while it resolves the image, prepares or reuses the bootstrap and prepared image caches, creates the writable overlay, and starts the VM launcher. -On gateway restart, the gateway starts a fresh VM driver process. The driver scans its state directory for accepted sandbox launch records, restarts those VMs, and reuses each sandbox's existing `overlay.ext4` so files written inside the sandbox remain available after the supervisor reconnects. +On graceful gateway shutdown, the gateway stops running-intent VMs through the driver RPC while retaining their launch records and writable overlays. On restart, the gateway starts a fresh VM driver process and reconciles the retained intent through the same idempotent start request used by the local container drivers. Running-intent VMs restart with their existing `overlay.ext4`, while explicitly stopped VMs remain stopped. + +Stopped VM state directories contain a marker that prevents startup from +launching the VM. The driver retains `sandbox.pb`, `overlay.ext4`, and extension +state, then removes the marker and restores the same overlay on start. For maintainer-level implementation details, refer to the [VM driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-vm/README.md). @@ -287,7 +368,7 @@ The gateway starts `openshell-driver-vm` over a private Unix socket and passes i ### Local image resolution -The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then falls back to the Podman socket (Docker-compatible API). On Linux with Podman, enable the API socket so the driver can find local images: +The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then uses the same Podman socket discovery as the Podman driver. On Linux with Podman, enable the API socket so the driver can find local images: ```shell systemctl --user start podman.socket @@ -299,10 +380,33 @@ The VM driver creates nftables rules on the host for each sandbox VM's TAP netwo On hosts with restrictive firewalls (e.g. firewalld), the host firewall may additionally block VM traffic that the driver's rules accept. If VM sandboxes cannot reach the network, verify that the host firewall allows forwarding and input for `vmtap-*` interfaces. See the [VM driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-vm/README.md#host-side-nftables-rules) for details. +### Corporate Proxy Egress + +For proxy-required networks, the VM driver accepts the same corporate egress proxy keys as the Podman driver: `https_proxy`, `no_proxy`, `proxy_auth_file`, `proxy_auth_allow_insecure`, `proxy_connect_by_hostname`, and `proxy_ca_bundle`. The in-guest supervisor chains policy-approved TLS tunnels through the proxy with HTTP CONNECT instead of dialing destinations directly. + +The settings reach the guest supervisor on its command line through a per-sandbox argument file the driver writes into the overlay upperdir on every launch, so a sandbox cannot select, alter, or disable the proxy from inside the guest — including through image `ENV`, the sandbox environment, or files baked into the image at the paths the driver uses. + +A proxy on the corporate network needs no special address and works on every VM sandbox. The guest's callback to the gateway never traverses the proxy. + +A proxy on the gateway host itself works only for libkrun-backed (non-GPU) sandboxes, whose egress leaves through gvproxy: configure `https_proxy = "http://host.openshell.internal:"` rather than a `127.0.0.1` URL, because gvproxy NATs that alias to the host's `127.0.0.1`. GPU sandboxes use the QEMU/TAP backend, where `host.openshell.internal` resolves to the TAP host address and the driver's [host firewall rules](#host-firewall) allow the guest to reach only the gateway port on the host. The driver rejects a gateway-host proxy URL when a sandbox launches on QEMU instead of letting every CONNECT time out, so give GPU sandboxes a proxy address routable from the guest's masqueraded egress. + +Because a microVM has no bind mounts or container secrets, the driver stages the credential (root-only) and the CA bundle into the per-sandbox overlay disk and removes them with the sandbox. See the [Gateway Configuration File](./gateway-config) reference for the full contract, including the cleartext-credential acknowledgement and the validated-IP CONNECT behavior. + ## Kubernetes Driver Kubernetes-backed sandboxes run as pods in the configured sandbox namespace. Use Kubernetes for shared clusters, remote compute, GPU scheduling, and operator-managed environments. + +Kubernetes workspace namespaces are an administrative trust boundary. In +shared and managed modes, only the OpenShell gateway and its trusted Agent +Sandbox controller may administer Sandbox CRs, sandbox pods, or the configured +sandbox ServiceAccount in those namespaces. In operator mode, allowlist only +namespaces where the platform operator preserves that exclusive control. +Untrusted principals must not be able to create sandbox pods with fabricated +owner references or use the sandbox ServiceAccount. The operator namespace +allowlist is a trust grant, not a tenant isolation mechanism. + + Helm deployments set Kubernetes driver values through the chart. For maintainer-level implementation details, refer to the [Kubernetes driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-kubernetes/README.md). @@ -311,23 +415,37 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA |---|---|---| | `compute_drivers = ["kubernetes"]` | Not applicable | Select the Kubernetes compute driver. | | `[openshell.drivers.kubernetes].namespace` | `server.sandboxNamespace` | Set the namespace for sandbox resources. The Helm chart defaults to the release namespace when left empty. | -| `service_account_name` | `sandboxServiceAccount.name` | Set the Kubernetes service account assigned to sandbox pods and accepted by the gateway TokenReview bootstrap path. The Helm chart creates a dedicated sandbox service account by default. | +| `service_account_name` | `sandboxServiceAccount.name` | Set the Kubernetes service account assigned to sandbox pods and accepted by the Kubernetes driver's TokenReview bootstrap path. The Helm chart creates a dedicated sandbox service account by default. | | `default_image` | `server.sandboxImage` | Set the default sandbox image. | | `image_pull_policy` | `server.sandboxImagePullPolicy` | Set the Kubernetes image pull policy for sandbox pods. | -| `image_pull_secrets` | `server.sandboxImagePullSecrets` | Attach Kubernetes image pull secrets to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | +| `image_pull_secrets` | `server.sandboxImagePullSecrets` | Attach Kubernetes image-pull Secrets to sandbox pods. Managed mode copies these explicitly named Secrets from the configured source namespace into each workspace namespace. In shared and operator modes, the Secrets must already exist in the sandbox namespace. | +| `[managed_ssh_ingress]` | `networkPolicy.enabled` | In managed mode, create an SSH ingress policy in every workspace namespace. Helm configures the gateway namespace and pod selector automatically. Operator mode leaves namespace policy management to the platform operator. | | `grpc_endpoint` | `server.grpcEndpoint` | Set the gateway callback endpoint reachable from sandbox pods. | | `client_tls_secret_name` | `server.tls.clientTlsSecretName` | Mount sandbox client TLS materials from a Kubernetes secret. | | `supervisor_image` | `supervisor.image.repository` / `supervisor.image.tag` | Override the supervisor image that provides the `openshell-sandbox` binary. The default repository with an empty tag uses the version-pinned image built into the gateway. Changing the repository uses the effective gateway image tag, while setting a tag pins that version explicitly. | | `supervisor_image_pull_policy` | `supervisor.image.pullPolicy` | Set the Kubernetes image pull policy for the supervisor image. | | `supervisor_sideload_method` | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect from cluster version. Set to `image-volume` to mount the supervisor OCI image directly as a volume (requires Kubernetes 1.33+ with the ImageVolume feature gate; GA in 1.36), or `init-container` to copy it through an init container on older clusters. | | `topology` | `supervisor.topology` | Set `combined` for the default single supervisor path, or `sidecar` to move pod-level network enforcement and the gateway session into a dedicated sidecar. | -| `sidecar.proxy_uid` | `supervisor.sidecar.proxyUid` | Non-root UID used by the relaxed sidecar when process/binary-aware network policy is disabled. The default binary-aware sidecar runs as UID 0. The network init container exempts the effective sidecar UID from proxy redirection. | +| `https_proxy` | `upstreamProxy.url` | Set the operator-owned `http://host:port` corporate forward proxy used for policy-approved TLS CONNECT egress. | +| `no_proxy` | `upstreamProxy.noProxy` | Set destinations that bypass only the corporate proxy. OpenShell policy evaluation still applies. | +| `proxy_auth_secret_name` | `upstreamProxy.authSecret.name` | Set the existing Secret name in the sandbox namespace that contains the proxy credential. Requires `sidecar` topology. | +| `proxy_auth_secret_key` | `upstreamProxy.authSecret.key` | Set the Secret key containing the `user:pass` credential. Requires `sidecar` topology. | +| `proxy_auth_allow_insecure` | `upstreamProxy.authAllowInsecure` | Set `true` to acknowledge that Basic authentication to an HTTP proxy is cleartext. Required with a proxy credential Secret. | +| `proxy_connect_by_hostname` | `upstreamProxy.connectByHostname` | Send hostnames rather than validated IPs in CONNECT requests. Use only when proxy ACLs require hostname targets. | +| `sidecar.proxy_uid` | `supervisor.sidecar.proxyUid` | Dedicated UID of at least `1000` used by the relaxed sidecar when process/binary-aware network policy is disabled. It must not match the workload UID. The default binary-aware sidecar runs as UID 0. The network init container exempts the effective sidecar UID from proxy redirection. | | `sidecar.process_binary_aware_network_policy` | `supervisor.sidecar.processBinaryAwareNetworkPolicy` | Keep process/binary-aware network policy enabled in `sidecar` topology. The default runs the sidecar as UID 0 with `SYS_PTRACE` and `DAC_READ_SEARCH`. Set false to run as `proxy_uid`, drop both capabilities, and enforce endpoint/L7 policy without matching `policy.binaries`. | | `app_armor_profile` | `server.appArmorProfile` | Set the sandbox agent container's AppArmor profile. Helm defaults this to `Unconfined` so AppArmor-enabled nodes do not block supervisor network namespace setup. Set the Helm value to an empty string to omit the field, or use `RuntimeDefault` or `Localhost/` for operator-managed profiles. | | `workspace_default_storage_size` | `server.workspaceDefaultStorageSize` | Set the default workspace PVC size for new sandboxes. | | `workspace_storage_class` | `server.workspaceStorageClass` | Set the `StorageClass` for the workspace PVC. Empty (default) omits `storageClassName` and uses the cluster's default `StorageClass`. Set this on clusters with no default `StorageClass`, otherwise the workspace PVC stays `Pending` and the sandbox never starts. | | `sa_token_ttl_secs` | `server.sandboxJwt.k8sSaTokenTtlSecs` | Set the projected ServiceAccount token TTL used for the bootstrap token exchange. | +Managed-mode Secret copying requires the gateway ServiceAccount to create +Secrets. Kubernetes RBAC cannot restrict Secret `create` by resource name, so +the Helm chart grants cluster-wide Secret `create`; Secret `get` and `patch` +remain limited to the explicitly configured TLS and image-pull Secret names. +The driver creates copies only in gateway-owned managed namespaces. Do not +reuse the gateway ServiceAccount for unrelated workloads. + In `combined` topology, the agent container carries the Linux capabilities needed by the supervisor for network namespace setup, Landlock filesystem policy, process privilege changes, and network policy enforcement. In `sidecar` @@ -355,6 +473,13 @@ process/binary identity through `/proc/`. The Kubernetes driver creates namespaced `agents.x-k8s.io` `Sandbox` resources from the Kubernetes SIG Apps [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project. It detects the served Sandbox API at runtime, caches the selected API version for the gateway process, and uses `v1beta1` when available before falling back to `v1alpha1`, so supported Agent Sandbox installations work without version-specific operator configuration. The Agent Sandbox controller turns those resources into sandbox pods and related storage. +Stop patches the existing resource rather than deleting it. For `v1beta1`, +the driver sets `spec.operatingMode` to `Suspended` or `Running`. For +`v1alpha1`, it sets `spec.replicas` to `0` or `1`. The Sandbox resource and its +workspace PVC keep their identity across both operations. Stop returns only +after the controller reports suspension and deletes the old pod, so an +immediate start cannot race the prior pod's termination. + If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the controller and CRD rollout completes so the gateway can detect the served API versions again. @@ -430,6 +555,12 @@ The policy can set `process.run_as_user` and `process.run_as_group` independently. Each explicit field wins. The active compute driver supplies the identity for omitted fields. +Explicit numeric values may use any non-root Linux UID/GID from `1` through +`4294967294`. OpenShell rejects `0` as root and `4294967295` as the invalid +identity sentinel. Low numeric identities can inherit permissions from matching +accounts, files, volumes, or devices, so choose them with the same care as any +other runtime identity. + ### Docker / Podman Docker and Podman inspect the final image and use its OCI `USER` declaration as @@ -443,6 +574,24 @@ declared name or numeric components for both direct and SSH children. When `USER` omits the group, the supervisor uses the user's numeric primary GID. It does not modify `/etc/passwd` or `/etc/group`. +Docker also inspects OCI `WorkingDir`. An absolute value becomes the +agent workspace; an empty, root (`/`), or explicit `/sandbox` value uses the +managed `/sandbox` compatibility workspace. +OpenShell creates and owns that compatibility workspace. Any other workdir must +already exist in the immutable image without symlink components. The completed +UID/GID and supplementary groups must already be able to traverse every parent +and write and enter the workdir. OpenShell does not change that directory's +ownership or mode. A one-shot validator drops to that identity and uses kernel +effective-access checks, including POSIX ACL grants and LSM denials. It rejects +workdirs that overlap the OCI runtime namespaces under `/proc`, `/sys`, or +`/dev`, and rejects overlap with actual OpenShell control paths. Docker checks +the original image filesystem in the final supervisor and rejects image +`VOLUME` declarations that would mask the workdir or one of its parents before +validation. The resolved workspace is the cwd and `HOME` for direct and SSH +children. The supervisor itself starts from `/`, so a missing or invalid +workspace is handled during readiness instead of preventing the container +runtime from starting it. + Sandbox creation fails before readiness if a required `USER` component is missing, malformed, unknown, ambiguous, or resolves to UID/GID 0. An image without `USER` therefore works only when policy explicitly provides both @@ -472,7 +621,9 @@ The VM driver injects the sandbox UID into the rootfs guest's `/etc/passwd`, `/e Docker and Podman custom images do not need a baked-in `"sandbox"` user. Declare a non-root OCI `USER`, or set both process identity fields explicitly in policy. Named image users require matching account entries; a numeric `UID:GID` pair -does not. OpenShell continues to use `/sandbox` as the workspace; it does not -adopt the image's OCI working directory. Until OCI working-directory support is -added, custom images must create `/sandbox` and make it writable by the selected -identity. +does not. For Docker, declare an absolute OCI `WORKDIR` to select the workspace. +Images with no working directory, `WORKDIR /`, or `WORKDIR /sandbox` use +OpenShell's managed `/sandbox` compatibility workspace. For any other Docker +path, create the directory in the image and grant the final process identity +write and execute permission in the Dockerfile. Podman, Kubernetes/OpenShift, +and VM sandboxes continue to use `/sandbox`. diff --git a/docs/sandboxes/inference-routing.mdx b/docs/sandboxes/inference-routing.mdx index 2c065c6cb4..1b47bfdf95 100644 --- a/docs/sandboxes/inference-routing.mdx +++ b/docs/sandboxes/inference-routing.mdx @@ -118,6 +118,12 @@ openshell provider create \ Replace the base URL and API key with the values from your provider. For supported providers out of the box, refer to [Supported Inference Providers](/sandboxes/manage-providers#supported-inference-providers). For other providers, refer to your provider's documentation for the correct base URL, available models, and API key setup. +This override is used by gateway inference routing. It disables the built-in +public OpenAI endpoint policy and credential binding for direct sandbox +traffic, so the alternate-upstream key cannot be substituted at +`api.openai.com`. Import an endpoint-bearing custom profile if a sandbox must +contact the alternate upstream directly. + @@ -147,6 +153,9 @@ openshell provider create \ Use `--config OPENAI_BASE_URL` to point to any OpenAI-compatible server running where the gateway runs. For host-backed local inference, use `host.openshell.internal` or the host's LAN IP. Avoid `127.0.0.1` and `localhost`. Set `OPENAI_API_KEY` to a dummy value if the server does not require authentication. +The override is route-only and does not add the local endpoint to sandbox +network policy. + For a self-contained setup, the Ollama sandbox bundles Ollama inside the sandbox itself, so no host-level provider is needed. Refer to [Inference Ollama](/get-started/tutorials/inference-ollama) for details. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 839dab73fd..6146ab8d39 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -13,7 +13,9 @@ AI agents typically need credentials to access external services: an API key for Create and manage providers that supply credentials to sandboxes. -Providers v2 is available for profile-backed provider policy, provider-owned network rules, and gateway-managed credential refresh. This page remains the credential-focused provider command reference. For the new workflow, see [Providers v2](/sandboxes/providers-v2). +Provider profiles define provider credentials, policy, and refresh behavior. See +[Profiles](/providers/profiles) for the profile format and custom +profile workflow. Provider profiles include metadata for known endpoints and binaries. View @@ -46,7 +48,7 @@ and stores them in the provider. Supply a credential value directly: ```shell -openshell provider create --name my-api --type generic --credential API_KEY=sk-abc123 +openshell provider create --name my-nvidia --type nvidia --credential NVIDIA_API_KEY=nvapi-example ``` ### Bare Key Form @@ -55,26 +57,57 @@ Pass a key name without a value to read the value from the environment variable of that name: ```shell -openshell provider create --name my-api --type generic --credential API_KEY +openshell provider create --name my-nvidia --type nvidia --credential NVIDIA_API_KEY ``` -This looks up the current value of `$API_KEY` in your shell and stores it. +This looks up the current value of `$NVIDIA_API_KEY` in your shell and stores it. -Provider profile metadata is available for known provider types. Provider profile -network policy is gateway opt-in: +### From the Current OIDC Login + +Profile-backed token-exchange providers can store the current gateway OIDC +access token as their subject credential. The subject credential stays +gateway-only and is not emitted into sandbox environment material or static +credential bindings: + +```shell +openshell provider create \ + --name custom-api \ + --type custom-api \ + --from-oidc-token +``` + +OpenShell infers the destination credential from the provider profile when the +profile has exactly one `token_grant.subject_token.credential`. If the profile +has more than one, pass `--credential `. Refresh the stored subject token +later with: ```shell -openshell settings set --global --key providers_v2_enabled --value true +openshell provider update custom-api --from-oidc-token ``` -Without `providers_v2_enabled=true`, provider behavior remains credential-only. +This copies the current OIDC access token and expiry from the active gateway +login. This requires an active named gateway that was registered for OIDC. If +the stored gateway access token is expired and a refresh token is available, the +CLI refreshes it before storing the provider credential. It does not store the +OIDC refresh token in the provider. -When `providers_v2_enabled=true`, `--from-existing` uses profile-backed -discovery instead of the legacy provider registry. The requested `--type` must +`--from-existing` uses profile-backed discovery. The requested `--type` must have a built-in or imported provider profile with a `discovery` section. If no matching profile exists, the CLI returns an error instead of falling back to legacy discovery. +Create a provider whose profile requires no static credentials without a +credential source: + +```shell +openshell provider create --name public-pypi --type pypi +``` + +Provider creation rejects profileless types. For a custom GitLab deployment, +import a profile with the deployment's endpoints and then create an instance +using that profile ID. Existing profileless provider records remain usable, but +OpenShell does not create new ones. + Update a custom provider profile after exporting it, editing its endpoints, binaries, or credential metadata, and preserving the exported `resource_version`: @@ -86,9 +119,26 @@ openshell provider profile update my-api -f my-api-profile.yaml Import remains create-only and fails if the profile ID already exists. Use `provider profile update ` for existing custom profiles. Built-in profiles are read-only. The target ID must match the profile ID in the file. Update accepts -one file at a time and rejects stale resource versions. When -`providers_v2_enabled=true`, updated profile policy applies to all provider -instances of that type on the next sandbox config sync. +one file at a time and rejects stale resource versions. Updated profile policy +applies to all provider instances of that type on the next sandbox config sync. + +Delete one or more custom provider profiles by ID: + +```shell +openshell provider profile delete custom-api custom-alt +``` + +When a multi-profile delete fails for one entry, the CLI reports that profile's +failure and continues with the remaining IDs. The command exits with an error +after it attempts every requested deletion if any entry failed. + + +Static credentials require a built-in or imported provider profile. Profiles +normally define at least one endpoint. An endpointless profile requires an +explicit `credential_binding.provider` on a sandbox policy endpoint. OpenShell +does not activate static credentials from a profileless provider because it +cannot determine which credential definition applies. + ## Manage Providers @@ -179,17 +229,15 @@ openshell provider refresh rotate my-graph --credential-key MS_GRAPH_ACCESS_TOKE ### AWS STS -AWS STS refresh requires `providers_v2_enabled=true`. The gateway calls -`sts:AssumeRole` and writes three short-lived credentials (`AWS_ACCESS_KEY_ID`, -`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) to the provider record atomically. +The gateway calls `sts:AssumeRole` and writes three short-lived credentials +(`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) to the +provider record atomically. The `aws` and `aws-s3` profiles declare `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN` as `additional_outputs` of the `AWS_ACCESS_KEY_ID` refresh, so all three credentials are gateway-minted. Create the provider with `--runtime-credentials` — no placeholder credential is needed. ```shell -openshell settings set --global --key providers_v2_enabled --value true --yes - openshell provider create --name my-aws --type aws-s3 --runtime-credentials openshell provider refresh configure my-aws \ @@ -239,6 +287,16 @@ Delete a provider: openshell provider delete my-claude ``` +Delete multiple providers by listing their names in one command: + +```shell +openshell provider delete my-claude my-github +``` + +When a multi-provider delete fails for one entry, the CLI reports that +provider's failure and continues with the remaining names. The command exits +with an error after it attempts every requested deletion if any entry failed. + ## Attach Providers to Sandboxes Pass one or more `--provider` flags when creating a sandbox: @@ -247,27 +305,28 @@ Pass one or more `--provider` flags when creating a sandbox: openshell sandbox create --provider my-claude --provider my-github -- claude ``` -Each `--provider` flag attaches one provider. The sandbox receives all -credentials from every attached provider at runtime. Profile-managed providers -also contribute provider-generated network policy entries when -`providers_v2_enabled` is enabled at the gateway. When the setting is disabled, -providers keep the previous behavior and only provide credentials. +Each `--provider` flag attaches one provider. The sandbox receives eligible +credentials from every attached provider as placeholders at runtime. Each +static credential resolves only for endpoints in that provider's profile, or +for explicitly bound sandbox policy endpoints when the profile is endpointless. +Profile-managed providers also contribute provider-generated network policy +entries. Endpoint binding independently limits where each static credential can +be resolved. - -Legacy provider attachment is fixed at sandbox creation time. Providers v2 adds -`openshell sandbox provider attach` and `openshell sandbox provider detach` for -running sandboxes. See [Providers v2](/sandboxes/providers-v2#attach-and-detach-providers) -for runtime attach and detach behavior. + +Use `--provider` to attach providers when creating a sandbox. To change the +providers attached to a running sandbox, use `openshell sandbox provider attach` +and `openshell sandbox provider detach`. See +[Profiles](/providers/profiles#attach-and-detach-providers) for details. - + ### Auto-Discovery Shortcut -When `providers_v2_enabled=false` and the trailing command in -`openshell sandbox create` is a recognized tool name (`claude`, `codex`, or -`opencode`), the CLI auto-creates the required provider from your local -credentials if one does not already exist. You do not need to create the -provider separately: +When the trailing command in `openshell sandbox create` maps to an available +profile, the CLI auto-creates the required provider from local profile +discovery if one does not already exist. You do not need to create the provider +separately: ```shell openshell sandbox create -- claude @@ -276,15 +335,38 @@ openshell sandbox create -- claude This detects `claude` as a known tool, finds your `ANTHROPIC_API_KEY`, creates a provider, attaches it to the sandbox, and launches Claude Code. -Providers v2 disables command-derived provider inference. When -`providers_v2_enabled=true`, create or import the provider profile, create the -provider instance, and pass `--provider ` explicitly. +If the inferred type has no built-in or imported profile, creation fails. Import +a custom profile first or pass an existing provider with `--provider `. ## How Credential Injection Works -The agent process inside the sandbox never sees real credential values. At startup, the proxy replaces each credential with an opaque placeholder token in the agent's environment. When the agent sends an HTTP request containing a placeholder, the proxy resolves it to the real credential before forwarding upstream. +The agent process inside the sandbox never sees real credential values. At +startup, OpenShell replaces each credential with an opaque placeholder token in +the agent's environment. When the agent sends an HTTP request containing a +placeholder, the proxy resolves it immediately before forwarding the request. -This resolution requires the proxy to see plaintext HTTP. Endpoints must use `protocol: rest` in the policy (which auto-terminates TLS) or explicit `tls: terminate`. Endpoints without TLS termination pass traffic through as an opaque stream, and credential placeholders are forwarded unresolved. +Static credential resolution has two independent authorization boundaries: + +1. Network policy must allow the calling binary and request destination. +2. The credential binding must include the request host, port, and path. Profile + endpoints provide the binding by default. An endpointless profile can use a + sandbox policy endpoint that names the attached provider instance through + `credential_binding.provider`. + +Both checks must pass. A provider profile endpoint does not grant network access +unless provider policy composition or the sandbox's own policy allows the +request. A plain sandbox policy endpoint does not grant credential use unless +the profile already covers it or the endpoint explicitly binds an endpointless +provider. + +Every static credential declared by a provider receives the complete endpoint set from that provider's +profile, or the explicitly bound sandbox policy endpoints for an endpointless +profile. + +Credential resolution requires the proxy to handle the request as HTTP. Raw +`tls: skip` and non-HTTP tunnels remain opaque and do not support credential +rewrite. Refer to [Profiles](/providers/profiles#understand-static-credential-endpoint-binding) +for endpoint matching and migration guidance. ### Supported injection locations @@ -296,30 +378,42 @@ The proxy resolves credential placeholders in the following parts of an HTTP req | Header value (Basic auth) | Agent base64-encodes `user:` in an `Authorization: Basic` header. The proxy decodes, resolves, and re-encodes. | `Authorization: Basic ` | | Query parameter value | Agent places the placeholder in a URL query parameter. | `GET /api?key=` | | URL path segment | Agent builds a URL with the placeholder in the path. Supports concatenated patterns. | `POST /bot/sendMessage` | +| Supported request body | An inspected REST endpoint opts in with `request_body_credential_rewrite: true`. | `{"api_key":""}` | +| WebSocket text message | A REST or WebSocket endpoint opts in with `websocket_credential_rewrite: true`. | `{"token":""}` | +| AWS SigV4 signing | An endpoint configures `credential_signing`, and the proxy signs with the endpoint-bound AWS credentials. | `credential_signing: sigv4` | -The proxy does not modify request bodies, cookies, or response content. +The proxy does not rewrite cookies, response content, unsupported request +bodies, or WebSocket binary frames. ### Fail-closed behavior -If the proxy detects a credential placeholder in a request but cannot resolve it, it rejects the request with HTTP 500 instead of forwarding the raw placeholder to the upstream server. This prevents accidental credential leakage in server logs or error responses. +If policy allows a request but the credential binding does not include the +request endpoint, the proxy rejects the request with HTTP 403 and the +`credential_endpoint_mismatch` reason. It emits a denied activity event and a +security finding without recording the secret, placeholder, environment key, or +query string. -### Example: Telegram Bot API (path-based credential) +Unknown, malformed, expired, or otherwise unresolved placeholders also fail +closed instead of being forwarded to the upstream service. -Create a provider with the Telegram bot token: +### Inspect an Endpoint Binding -```shell -openshell provider create --name telegram --type generic --credential TELEGRAM_BOT_TOKEN=123456:ABC-DEF -``` - -The agent reads `TELEGRAM_BOT_TOKEN` from its environment and builds a request like `POST /bot/sendMessage`. The proxy resolves the placeholder in the URL path and forwards `POST /bot123456:ABC-DEF/sendMessage` to the upstream. - -### Example: Google API (query parameter credential) +Export the profile used by a provider to inspect its credential boundary: ```shell -openshell provider create --name google --type generic --credential YOUTUBE_API_KEY=AIzaSy-secret +openshell provider profile export github -o yaml ``` -The agent sends `GET /youtube/v3/search?part=snippet&key=`. The proxy resolves the placeholder in the query parameter value and percent-encodes the result before forwarding. +For the built-in GitHub profile, `GITHUB_TOKEN` and `GH_TOKEN` can resolve for +the profile's `api.github.com:443` and `github.com:443` endpoints. Even if a +sandbox policy allows `uploads.example.com:443`, sending either placeholder +there returns `credential_endpoint_mismatch`. + +For a custom service, define the credential in a custom provider profile. Put +stable endpoints in the profile, or leave the profile endpointless and bind +each concrete provider instance from sandbox policy. Refer to +[Profiles](/providers/profiles#provider-profiles) for the profile workflow +and schema. ## Supported Provider Types @@ -333,26 +427,24 @@ The following provider types are supported. | `codex` | `OPENAI_API_KEY` | OpenAI Codex | | `copilot` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | GitHub Copilot CLI | | `deepinfra` | `DEEPINFRA_API_KEY` | DeepInfra inference API | -| `generic` | User-defined | Any service with custom credentials | | `github` | `GITHUB_TOKEN`, `GH_TOKEN` | GitHub API and `gh` CLI. Refer to [GitHub Sandbox](/get-started/tutorials/github-sandbox). | -| `gitlab` | `GITLAB_TOKEN`, `GLAB_TOKEN`, `CI_JOB_TOKEN` | GitLab API, `glab` CLI | | `nvidia` | `NVIDIA_API_KEY` | NVIDIA API Catalog | -| `openai` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint. Set `--config OPENAI_BASE_URL` to point to the provider. Refer to [Inference Routing](/sandboxes/inference-routing). | -| `opencode` | `OPENCODE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY` | OpenCode | +| `openai` | `OPENAI_API_KEY` | OpenAI API. An `OPENAI_BASE_URL` override remains available for gateway inference routing but does not authorize the fixed public OpenAI endpoint for sandbox traffic. Refer to [Inference Routing](/sandboxes/inference-routing). | `ANTHROPIC_API_KEY` is an API key from [console.anthropic.com](https://console.anthropic.com), not a subscription token. Subscription users must generate a separate API key from the Anthropic Console. -Use the `generic` type for any service not listed above. You define the -environment variable names and values yourself with `--credential`. +For a service not listed above, import a custom provider profile that declares +its credential environment variables and endpoints. Use the imported profile +ID as the provider `--type`. ## Supported Inference Providers -The following providers have been tested with `inference.local`. Any provider that exposes an OpenAI-compatible API works with the `openai` type. Set `--config OPENAI_BASE_URL` to the provider's base URL and `--credential OPENAI_API_KEY` to your API key. +The following providers have been tested with `inference.local`. Any provider that exposes an OpenAI-compatible API works with the `openai` routing type. Set `--config OPENAI_BASE_URL` to the provider's base URL and `--credential OPENAI_API_KEY` to your API key. A base URL override makes the provider route-only: it does not contribute the built-in public OpenAI endpoint to sandbox policy or make the key substitutable there. Import an endpoint-bearing custom profile when a sandbox needs direct access to the alternate upstream. | Provider | Name | Type | Base URL | API Key Variable | |---|---|---|---|---| diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index e520c55f49..31c7182e5f 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -20,6 +20,37 @@ Create a sandbox with a single command. For example, to create a sandbox with Cl openshell sandbox create -- claude ``` +The trailing command is the sandbox's canonical main process. OpenShell starts +it once, streams its output, and returns its exit status. Exit code 0 leaves a +retained sandbox in `Completed`; a nonzero exit leaves it in `Error` with a +`MainProcessFailed` condition. With no trailing command, OpenShell starts a +login shell in a retained pseudo-terminal: `/bin/bash -l` when the image +provides bash, otherwise a shell detected in the image such as `/bin/sh` on +minimal bases like Alpine. Add `--detach` to create the sandbox without +attaching: + +```shell +openshell sandbox create --name worker --detach -- ./worker +``` + +Detached commands have no attachment grace period. When the command exits, +OpenShell records its terminal phase immediately. For a foreground command, +the create request declares one expected SSH attachment. OpenShell retains the +terminal transport until that connection drains and closes naturally, then +finalizes ephemeral cleanup. + +Use `--no-keep` for an ephemeral command. OpenShell drains stdout and stderr, +captures the command result, and deletes the sandbox after the command exits: + +```shell +openshell sandbox create --no-keep -- sh -c 'echo done; exit 0' +``` + +`--upload` cannot yet be combined with a trailing main command because uploads +finish after the canonical process starts. Create a scratch sandbox, upload the +files, then launch the workload with `sandbox exec`, or build the files into the +sandbox image. + For automation, use `--output json` or `--output yaml` to get machine-readable sandbox metadata after creation: ```shell @@ -128,6 +159,59 @@ Local directories and Dockerfiles require a local gateway because the CLI builds through the local Docker daemon. Use a registry image reference for remote gateways. +## Reuse Workload Templates + +Sandbox workload templates let workspace admins define reusable runtime shapes for a workspace. A template stores the image, environment, resource requests, and driver-specific configuration that sandboxes should inherit. When you create a sandbox from a template, the create request can still attach providers, labels, and policy, but the workload comes from the named template. + +Create a template: + +```shell +openshell sandbox template create gpu-kata \ + --image registry.example.com/agent:latest \ + --cpu 2 \ + --memory 4Gi \ + --gpu 1 \ + --label team=runtime \ + --env FEATURE_FLAG=on +``` + +Use `--gpu` without a count when the template should request the active +driver's default GPU assignment. Use `--gpu COUNT` when the template needs a +specific number of GPUs. + +Add driver-specific settings when the active compute driver needs them: + +```shell +openshell sandbox template create gpu-kata \ + --image registry.example.com/agent:latest \ + --driver-config-json '{"kubernetes":{"pod":{"runtime_class_name":"kata-containers","node_selector":{"pool":"gpu"}}}}' +``` + +If you omit `--image`, the gateway applies its default sandbox image when a sandbox is created from the template. Use this when the template should only define resource, environment, or driver settings. + +Create a sandbox from a template: + +```shell +openshell sandbox create --template gpu-kata --provider github -- claude +``` + +The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. + +Inspect and manage templates: + +```shell +openshell sandbox template list +openshell sandbox template list --label-selector team=runtime +openshell sandbox template get gpu-kata +openshell sandbox template delete gpu-kata +``` + +Use `--all-workspaces` with `sandbox template list` when you need an admin view across workspaces: + +```shell +openshell sandbox template list --all-workspaces +``` + ## Base Sandbox Container The `base` sandbox container is the default runtime image for standard OpenShell sandboxes unless the gateway overrides its default sandbox image. It is published as `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` and maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) repository. @@ -142,12 +226,21 @@ For default policy coverage by agent, refer to [Default Policy](/reference/defau ## Connect to a Sandbox -Open an SSH session into a running sandbox: +Attach to the canonical main process in a running sandbox: ```shell openshell sandbox connect my-sandbox ``` +Disconnecting does not stop the process or close its stdin. A later `connect` +attaches to the same process instance and replays up to 1 MiB of recent +output. One attachment owns stdin at a time. Use `sandbox exec --tty -- +/bin/bash -l` when you want a new independent shell instead. + +Press `Ctrl-P`, then `Ctrl-Q` to disconnect without terminating the main +process. `Ctrl-C` retains its normal terminal behavior and interrupts the +foreground process. + Launch VS Code or Cursor directly into the sandbox workspace: ```shell @@ -183,14 +276,27 @@ openshell sandbox exec -n my-sandbox --tty -- /bin/bash OpenShell allocates a TTY automatically when both stdin and stdout are terminals. Force the behavior with `--tty` or disable it with `--no-tty`. -| Flag | Purpose | -| -------------- | -------------------------------------------------------- | -| `-n`, `--name` | Sandbox to target. | -| `--workdir` | Working directory for the command inside the sandbox. | -| `--timeout` | Command timeout in seconds. `0` disables the timeout. | -| `--tty` | Force TTY allocation. | -| `--no-tty` | Disable TTY allocation even when attached to a terminal. | -| `--env` | Set an environment variable for the command (`KEY=VALUE`, repeatable). | +| Flag | Purpose | +| ----------------- | -------------------------------------------------------- | +| `-n`, `--name` | Sandbox to target. | +| `--workdir` | Working directory for the command inside the sandbox. | +| `--timeout` | Command timeout in seconds. `0` disables the timeout. | +| `--tty` | Force TTY allocation. | +| `--no-tty` | Disable TTY allocation even when attached to a terminal. | +| `--no-login-shell`| Run the command without sourcing shell login startup files. | +| `--env` | Set an environment variable for the command (`KEY=VALUE`, repeatable). | + +### Skip shell startup files + +By default `sandbox exec` runs the command through a login shell (`bash -lc`), so the sandbox user's first available `.bash_profile`, `.bash_login`, or `.profile` is sourced first (and `.bashrc` only if that login file sources it). This makes tool-specific environment configuration available automatically, which suits interactive and tool-discovery use. + +For automation and managed checks that need predictable output and side effects, pass `--no-login-shell` so those startup files are not sourced before the command runs: + +```shell +openshell sandbox exec -n my-sandbox --no-login-shell -- /usr/local/bin/managed-probe +``` + +In this mode a sandbox user's login startup files cannot write to the command's output, create files, or otherwise affect the requested command before it starts. The command still runs under `bash -c`, which reads `BASH_ENV` if it is set in the command's environment. The default (login-shell) behavior is unchanged when the flag is omitted. ## Set Environment Variables @@ -202,6 +308,8 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands. +When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. + You can also set per-command environment variables with `sandbox exec`: ```shell @@ -242,6 +350,36 @@ with SandboxClient.from_active_cluster() as client: assert sandbox.id in {s.id for s in matches} ``` +Create reusable sandbox templates through the Python SDK when several runs +should share the same workload shape: + +```python +from openshell import SandboxClient + +with SandboxClient.from_active_cluster() as client: + templates = client.sandbox_templates() + + templates.create( + workspace="default", + name="python", + image="ghcr.io/nvidia/openshell-community/sandboxes/python:latest", + ) + + sandbox = client.create_from_template(workspace="default", template_name="python") +``` + +For non-interactive automation, pass a renewable client-credentials provider. +Omitted issuer, client ID, audience, and scopes are read from the active +gateway's metadata. The client requires TLS for non-loopback gateways: + +```python +from openshell import ClientCredentialsAuth, SandboxClient + +auth = ClientCredentialsAuth(client_secret=lambda: load_secret()) +with SandboxClient.from_active_cluster(client_credentials=auth) as client: + sandboxes = client.list(workspace="default") +``` + ## Expose Long Running Services Service forwarding makes a long-running process inside a sandbox reachable through a gateway-managed URL. Use it for development servers, notebooks, dashboards, or other services that keep listening after the sandbox starts. Run the service on loopback inside the sandbox, expose its port, then open the URL printed by OpenShell. @@ -270,6 +408,17 @@ List endpoints for one sandbox: openshell service list my-sandbox ``` +Use structured output for automation: + +```shell +openshell service list --output json +openshell service list my-sandbox --output yaml +``` + +Each record contains `workspace`, `sandbox`, `service`, `target_port`, and +`url`. The unnamed service uses an empty `service` string. An empty result is +an empty collection. + Show or delete one endpoint: ```shell @@ -368,6 +517,20 @@ openshell forward list openshell forward stop 8000 my-sandbox ``` +Use JSON or YAML output when inspecting tracked forwards from automation: + +```shell +openshell forward list --output json +openshell forward list --output yaml +``` + +Structured output includes `sandbox`, `bind_address`, `port`, `pid`, and +`alive`. The `alive` boolean reports whether the tracked PID still matches the +expected OpenShell SSH forward; it does not probe the forwarded socket. When no +forwards are tracked, structured output returns an empty collection. + +The default table colorizes the `STATUS` column only when both standard output and standard error are capable ANSI terminals; piping or redirecting either stream, or running under `TERM=dumb`, gives a plain-text table. Other styled output—including `-v` log lines, progress spinners, prompts, and error messages—is decided per stream, so redirecting one stream leaves the other styled. Prefer `--output json` for automation rather than matching on the table. Set `NO_COLOR` to any non-empty value or pass `--color never` to suppress ANSI formatting, and `--color always` to force it when piping into a pager. `--color` applies to every `openshell` command. + You can also forward a port at creation time with `--forward`: @@ -392,22 +555,31 @@ Append the output to `~/.ssh/config` or use `--editor` on `sandbox create`/`sand Upload files from your host into the sandbox: ```shell -openshell sandbox upload my-sandbox ./src /sandbox/src +openshell sandbox upload my-sandbox ./src ``` -When the local path is a named directory, OpenShell preserves that basename at the destination, matching `scp -r` and `cp -r`. The example above creates `/sandbox/src/src`. +When you omit the destination, OpenShell discovers the sandbox's working +directory and uploads there. For a named local directory, OpenShell preserves +the basename, matching `scp -r` and `cp -r`. If that directory already exists, +the upload merges into it and overwrites matching entries without deleting +unrelated entries. OpenShell preserves symlinks during upload. A symlink arrives in the sandbox as a symlink with the same target path instead of an expanded copy of the target file or directory. Dangling symlinks are also preserved. Download files from the sandbox to your host: ```shell -openshell sandbox download my-sandbox /sandbox/output ./local +openshell sandbox download my-sandbox output ./local ``` When the sandbox-side source is a single file, the destination follows `cp`-style placement: if the destination already exists as a directory or ends with `/`, the file lands inside it as `/`; otherwise the file is written at the exact destination path. -The CLI only allows sandbox-side sources that resolve inside the writable workspace (`/sandbox`). Paths that escape lexically (`/etc/passwd`, `/sandbox/../etc/passwd`) and paths that escape through a symlink (`/sandbox/etc-link` pointing at `/etc`) are both refused before any data is transferred. +The CLI discovers the sandbox's canonical working directory and only allows +sandbox-side sources that resolve inside it. Paths that escape lexically, such +as `/etc/passwd` or `/sandbox/../etc/passwd`, and paths that escape through a +symlink are refused before any data is transferred. Relative sources are +resolved from the working directory; absolute sources within the same canonical +directory are also accepted. You can also upload files at creation time with the `--upload` flag on @@ -427,6 +599,29 @@ back to an unfiltered upload and prints a warning. Pass `--no-git-ignore` to opt into unfiltered uploads explicitly, upload a path outside the Git work tree, or force-add the intended files if they should remain Git-aware. +## Stop and Start Sandboxes + +Stop compute when you want to retain a sandbox and its persistent workspace +without keeping its container, pod, or VM running: + +```shell +openshell sandbox stop my-sandbox +openshell sandbox start my-sandbox +``` + +The name is optional and defaults to the last-used sandbox. Stop stops local +background forwards and waits for the `Stopped` phase. Start waits until the +same sandbox returns to `Ready`. While stopped, you cannot connect, execute +commands, transfer files, forward ports, or reach exposed services. Policies, +provider attachments, settings, service definitions, and persistent workspace +data remain associated with the sandbox. + +Stop and start are idempotent. You can also start a retained `Completed` or +`Error/MainProcessFailed` sandbox to launch a fresh instance of its canonical +command. Starting a fresh instance invalidates SSH sessions issued for the +previous runtime generation. Delete an inactive sandbox normally when you no +longer need its retained state. + ## Delete Sandboxes Deleting a sandbox stops all processes, releases resources, and purges injected credentials. @@ -435,6 +630,16 @@ Deleting a sandbox stops all processes, releases resources, and purges injected openshell sandbox delete my-sandbox ``` +Delete multiple sandboxes by listing their names in one command: + +```shell +openshell sandbox delete sandbox-a sandbox-b +``` + +When a multi-sandbox delete fails for one entry, the CLI reports that sandbox's +failure and continues with the remaining names. The command exits with an error +after it attempts every requested deletion if any entry failed. + ## Sandbox Lifecycle Every sandbox moves through a defined set of phases: @@ -443,7 +648,11 @@ Every sandbox moves through a defined set of phases: | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Provisioning | The runtime is setting up the sandbox environment, or the gateway is waiting for the sandbox supervisor to establish its authenticated control session. | | Ready | The sandbox is running and its supervisor control session is connected. You can connect, execute commands, sync files, and view logs. | -| Error | Something went wrong during provisioning or execution. Check logs with `openshell logs` for details. | +| Stopping | The gateway accepted a stop request and is stopping compute while retaining persistent state. | +| Stopped | Compute was stopped explicitly and access is unavailable. | +| Starting | Compute is starting. The sandbox becomes usable only after a fresh supervisor session connects. | +| Completed | The canonical main process exited with code 0. Its normalized result is available in `status.exit_code`. | +| Error | The canonical main process failed, or sandbox infrastructure failed. Inspect the condition reason and `status.exit_code`. | | Deleting | The sandbox is being torn down. The system releases resources and purges credentials. | The compute backend can become ready before the sandbox supervisor connects to @@ -453,6 +662,12 @@ After a gateway restart, an existing sandbox can return to `Provisioning` temporarily while its supervisor reconnects. Wait for the phase to return to `Ready` before you connect to the sandbox or execute commands. +The gateway records a successful canonical main-process exit as `Ready=False` +with reason `MainProcessCompleted`. Nonzero and signal-normalized results use +`MainProcessFailed` and the `Error` phase. It also sets `status.exit_code`; +signal exits use the standard `128 + signal` convention. Compute runtimes do +not automatically restart that process. + ## Sandbox Compute Drivers The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, and delete sandboxes through the gateway API. diff --git a/docs/sandboxes/manage-workspaces.mdx b/docs/sandboxes/manage-workspaces.mdx index 5efbd4d09d..86da25fbb9 100644 --- a/docs/sandboxes/manage-workspaces.mdx +++ b/docs/sandboxes/manage-workspaces.mdx @@ -9,8 +9,9 @@ position: 3 --- An OpenShell workspace is an access and resource isolation boundary. Sandboxes, -providers, services, policies, settings, and inference routes belong to a -workspace and are not visible to members of other workspaces. +sandbox workload templates, providers, services, policies, settings, and +inference routes belong to a workspace and are not visible to members of other +workspaces. The CLI targets the `default` workspace unless you set `--workspace` or `OPENSHELL_WORKSPACE`. The logical OpenShell workspace described here is @@ -49,6 +50,8 @@ The following table summarizes common operations. | Add Workspace Users or remove members | Any workspace | Assigned workspace | No | | Assign the Workspace Admin role | Any workspace | No | No | | Create, use, or delete sandboxes and services | Any workspace | Assigned workspace | Assigned workspace | +| Create or delete sandbox workload templates | Any workspace | Assigned workspace | No | +| Read or list sandbox workload templates | Any workspace | Assigned workspace | Assigned workspace | | Create, update, or delete providers | Any workspace | Assigned workspace | No | | Change workspace policy or settings | Any workspace | Assigned workspace | No | | Manage platform profiles or global configuration | Yes | No | No | @@ -121,6 +124,10 @@ openshell workspace member remove \ --subject 'oidc-subject-for-user' ``` +Add `--output json` or `--output yaml` to list membership records for +automation. Each record contains `subject` and a normalized `role` of `admin`, +`user`, or `unknown`. An empty result is an empty collection. + To change a member's role, remove the existing membership and add it again with the new role. A Platform Admin must perform any change to `admin`. @@ -183,11 +190,11 @@ be deleted. openshell workspace delete team-ml ``` -A custom workspace must not contain sandboxes, providers, provider profiles, -services, SSH sessions, settings, policies, draft policy chunks, or credential -refresh state. Remove those resources before retrying deletion. OpenShell -removes membership records and inference routes as part of successful -workspace deletion. +A custom workspace must not contain sandboxes, sandbox workload templates, +providers, provider profiles, services, SSH sessions, settings, policies, draft +policy chunks, or credential refresh state. Remove those resources before +retrying deletion. OpenShell removes membership records and inference routes as +part of successful workspace deletion. ## Next Steps diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index c116590405..c5a34db14f 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -19,8 +19,9 @@ version: 1 # Static: locked at sandbox creation. Paths the agent can read vs read/write. filesystem_policy: + include_workdir: true read_only: [/usr, /lib, /etc] - read_write: [/sandbox, /tmp] + read_write: [/tmp] # Static: Landlock LSM kernel enforcement. best_effort uses highest ABI the host supports. landlock: @@ -61,19 +62,19 @@ network_middlewares: Static sections are locked at sandbox creation. Changing them requires destroying and recreating the sandbox. Dynamic sections can be updated on a running sandbox with `openshell policy update` for incremental merges or `openshell policy set` for full replacement, and take effect without restarting. -When a hot reload changes rules, the supervisor publishes a new policy generation and closes connections pinned to the previous generation. This includes HTTP keep-alive tunnels, `tls: skip`, non-HTTP payloads, HTTP upgrades such as WebSocket, and long-lived response streams such as SSE. Most clients reconnect automatically, and the next connection or request is evaluated against the current policy. Use `protocol: websocket` when policy should stay attached to the RFC 6455 upgrade and client text messages after the allowed upgrade. Add `websocket_credential_rewrite: true` only when the relay should rewrite credential placeholders in client-to-server WebSocket text messages. Add `request_body_credential_rewrite: true` only on inspected REST endpoints that need OpenShell to rewrite placeholders in supported text request bodies. +When a hot reload changes rules, the supervisor publishes a new policy generation and closes connections pinned to the previous generation. This includes HTTP keep-alive tunnels, `tls: skip`, non-HTTP payloads, HTTP upgrades such as WebSocket, and long-lived response streams such as SSE. Most clients reconnect automatically, and the next connection or request is evaluated against the current policy. A parsed WebSocket relay closes with code `1012` when its attached policy generation becomes stale. Use `protocol: websocket` when policy should stay attached to the RFC 6455 upgrade and client text messages after the allowed upgrade. Provider-credentialed endpoints reject L4-only and `tls: skip` modes by default. On a credentialed WebSocket upgrade, OpenShell keeps the connection on the parsed relay and rejects binary frames unless the endpoint explicitly sets `allow_uninspected_credentials: true`. Add `websocket_credential_rewrite: true` when the relay should rewrite credential placeholders in client-to-server WebSocket text messages. Add `request_body_credential_rewrite: true` only on inspected REST endpoints that need OpenShell to rewrite placeholders in supported text request bodies. | Section | Type | Description | |---|---|---| | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | -| `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values in the allowed sandbox range. Docker and Podman may use named identities or non-root system IDs only through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. Root identities are always rejected. The agent also runs with seccomp filters that block dangerous system calls. | -| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | -| `network_middlewares` | Dynamic | Declares keyed HTTP request middleware configs. After network and L7 policy admit a request, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. | +| `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | +| `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` passes through the network supervisor, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints with `protocol: tcp` allow ordinary DNS resolution and native TCP connections without inspecting payloads. Endpoints without `protocol` retain L4 passthrough through an explicit proxy.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | +| `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | ## Supervisor Middleware -Supervisor middleware can inspect, deny, or replace admitted HTTP request bodies before provider credentials are injected. Middleware selection is independent of the `network_policies` rule that admitted the request: each keyed `network_middlewares` entry matches the destination host through `endpoints.include` and `endpoints.exclude`. +Supervisor middleware can inspect, deny, or replace admitted HTTP request bodies and client WebSocket text messages before provider credentials are injected. Middleware selection is independent of the `network_policies` rule that admitted the traffic: each keyed `network_middlewares` entry matches the destination host through `endpoints.include` and `endpoints.exclude`. ```yaml network_middlewares: @@ -91,15 +92,15 @@ network_middlewares: Matching entries run once each by ascending `order`; lower values run first, and duplicate order values are rejected. The default order is `0`, so policies with multiple entries normally set it explicitly. Map keys are structurally unique. An optional `name` provides a human-readable label, defaults to the map key, and does not change the key used as the config identity. Different keys may use the same implementation and run as distinct stages. `exclude` takes precedence over `include`. -`openshell/regex` is an example built into the supervisor. It applies fixed regular expressions as a best-effort UTF-8 text transformation, without guarantees that sensitive values will be detected or fully removed. Custom expressions are not configurable yet. Operator-run middleware must be registered by name before a policy can reference it. The gateway validates implementation-owned config before accepting the policy. +`openshell/regex` is an example built into the supervisor. It applies fixed regular expressions to UTF-8 HTTP request bodies and complete client-to-upstream WebSocket text messages before credential injection. The initial pattern recognizes `sk-` tokens. This is a best-effort text transformation without guarantees that sensitive values will be detected or fully removed; it does not inspect binary or upstream-to-client WebSocket messages. Custom expressions are not configurable yet. Operator-run middleware must be registered by name before a policy can reference it. The gateway validates implementation-owned config before accepting the policy. -`on_error` defaults to `fail_closed`. Use `fail_open` only when skipping a failed middleware is acceptable. Middleware applies only to HTTP traffic the supervisor can parse and inspect. Policy validation rejects a fail-closed selector that can cover a `tls: skip` endpoint. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. +`on_error` defaults to `fail_closed`. Use `fail_open` only when skipping a selected stage that fails is acceptable. For WebSocket streams, a broken fail-open stage is disabled for the rest of that connection and OpenShell emits a state-change finding. A host-matched attachment joins only operation chains advertised by its implementation. An HTTP-only attachment may inspect the WebSocket upgrade GET, but post-upgrade messages pass with informational `binding_not_selected` coverage under either error mode. Binary messages also pass without middleware inspection and produce `unsupported_message_type` coverage for active WebSocket stages. Upstream-to-client messages remain uninspected. Policy validation rejects a fail-closed selector that can cover a `tls: skip` endpoint. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. See [Supervisor Middleware](/extensibility/supervisor-middleware) for registration, chain ordering, body limits, failure behavior, and operations. ## Baseline Filesystem Paths -When a sandbox runs in proxy mode (the default), OpenShell automatically adds baseline filesystem paths required for the sandbox child process to function: `/usr`, `/lib`, `/etc`, `/var/log` (read-only) and `/sandbox`, `/tmp` (read-write). Paths like `/app` are included in the baseline set but are only added if they exist in the container image. +When a sandbox runs in proxy mode (the default), OpenShell automatically adds baseline filesystem paths required for the sandbox child process to function: `/usr`, `/lib`, `/etc`, and `/var/log` (read-only), plus `/tmp` (read-write). When `filesystem.include_workdir` is `true`, OpenShell also adds the resolved working directory as read-write. Paths like `/app` are included in the baseline set but are only added if they exist in the container image. For GPU sandboxes, OpenShell also adds existing GPU device nodes as read-write paths. CUDA workloads require write access to procfs for thread metadata, so GPU baseline enrichment moves `/proc` from read-only to read-write when GPU devices are present. @@ -112,6 +113,34 @@ User-specified paths in your policy YAML are not pre-filtered. If you list a pat This distinction means baseline system paths degrade gracefully while user-specified paths surface configuration errors. +## Allow Native TCP Connections + +Use `protocol: tcp` when an application needs to resolve a policy-approved hostname and open a normal TCP connection without configuring an HTTP proxy. The endpoint remains L4-only, so OpenShell authorizes the hostname, port, and calling binary but does not inspect application payloads. + +```yaml showLineNumbers={false} +network_policies: + postgres: + name: postgres + endpoints: + - host: db.internal.example + port: 5432 + protocol: tcp + binaries: + - path: /usr/bin/psql +``` + +OpenShell answers DNS only for hostnames eligible under an active `protocol: tcp` endpoint. It validates upstream answers against destination and SSRF controls, returns a supervisor-owned synthetic address, and records the validated real addresses. When the application connects to the synthetic address, OpenShell recovers the hostname and port, evaluates the calling process against the current policy generation, and dials only an address pinned by that DNS result. + +Applications must honor the returned DNS TTL and resolve the hostname again before reconnecting after that TTL expires. A client that caches the synthetic address indefinitely can receive a connection failure after the mapping expires. Docker and Podman currently advertise only IPv4 egress for this feature, so OpenShell returns an empty successful answer for AAAA queries and lets dual-stack clients use the working A record. + +DNS resolution does not authorize a connection by itself. Unknown names, wrong ports, stale mappings, disallowed destination addresses, and binaries outside the matching policy fail closed. Applications cannot inherit access by connecting directly to a real IP returned by an upstream resolver. + +Do not combine `protocol: tcp` with L7-only fields such as `path`, `enforcement`, `access`, `rules`, `deny_rules`, request rewriting, or credential signing. Docker and Podman sandboxes support policy DNS and transparent TCP capture. Other compute drivers reject policies containing `protocol: tcp` until they provide the required runtime capability. + +Prefer exact hostnames for TCP endpoints. A wildcard authorizes DNS queries for every matching name, which means a compromised process can encode data into matching DNS labels even when the lookup does not return an address. OpenShell records failed eligible lookups for operators, but logging does not remove that exfiltration channel. + +The first TCP endpoint is a deliberate exception to ordinary dynamic network policy updates. A sandbox created without any `protocol: tcp` endpoint does not install the DNS and transparent-capture substrate. A hot reload that introduces the first TCP endpoint is rejected atomically and leaves the previous policy active; recreate the sandbox with a TCP endpoint to install the substrate. A sandbox that started with TCP support can remove and later re-add TCP endpoints through normal policy reloads. + ## Apply a Custom Policy Pass a policy YAML file when creating the sandbox: @@ -120,7 +149,9 @@ Pass a policy YAML file when creating the sandbox: openshell sandbox create --policy ./my-policy.yaml -- claude ``` -`openshell sandbox create` keeps the sandbox running after the initial command exits, which is useful when you plan to iterate on the policy. Add `--no-keep` if you want the sandbox deleted automatically instead. +The trailing command is the sandbox's canonical main process. If it exits, the +sandbox enters `Error`; use `sandbox exec` for one-shot commands that should not +define sandbox health. To avoid passing `--policy` every time, set a default policy with an environment variable: @@ -201,10 +232,17 @@ The following steps outline the hot-reload policy update workflow. openshell policy list ``` + Add `--output json` or `--output yaml` for automation. Structured policy + history contains the scope, sandbox name when applicable, version, full + hash, status, and available revision timestamps, load error, and provenance. + Use `openshell policy list --global --output json` for global history. + ### Validation failures OpenShell validates a complete candidate policy before activating any part of it. Endpoints may overlap when their connection and request-processing metadata agree. For example, two `api.example.com:443` REST entries can contribute different allow and deny rules when they use the same TLS, destination, credential, parser, and enforcement settings. A plain L4 endpoint may overlap an L7 endpoint because it authorizes the destination without contributing request-processing metadata. A more-specific path endpoint may override request-processing metadata from a broader endpoint, such as a `/graphql` GraphQL endpoint alongside a general REST endpoint for the same host. OpenShell rejects the candidate when overlapping exact or wildcard host selectors can both contribute equally specific endpoint configuration and disagree on those fields. +Internal policy-advisor provenance does not make otherwise compatible endpoints ambiguous. This lets an advisor proposal extend a provider-covered host without modifying the provider rule. TLS, destination IP constraints, credential handling, protocol, parser, and equally specific enforcement settings must still agree. + When the gateway knows the affected sandbox scope, it validates the complete effective candidate before persistence. This covers direct policy replacement, incremental merges and proposal approvals, provider attachment, and @@ -306,7 +344,7 @@ Each segment has a fixed meaning: | `host` | Yes | Destination hostname. | | `port` | Yes | Destination port, `1` through `65535`. | | `access` | No | Access preset for L7 endpoints: `read-only`, `read-write`, or `full`. Incremental updates expand presets into protocol-specific method/path rules for REST and WebSocket endpoints. | -| `protocol` | No | L7 inspection mode accepted by `openshell policy update`: `rest`, `websocket`, or `sql`. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | +| `protocol` | No | Endpoint mode accepted by `openshell policy update`: `tcp`, `rest`, `websocket`, or `sql`. Use `tcp` for native DNS and TCP without L7 inspection. `sql` is audit-only and not a recommended workflow today. Full policy YAML also supports `graphql`, `mcp`, and `json-rpc`. | | `enforcement` | No | Enforcement mode for inspected traffic: `enforce` or `audit`. | | `options` | No | Comma-separated endpoint options. Use `websocket-credential-rewrite` with `protocol: websocket` or REST compatibility endpoints that perform a WebSocket upgrade. Use `request-body-credential-rewrite` only with `protocol: rest`. | @@ -315,6 +353,8 @@ Examples: | Example | Meaning | |---|---| | `pypi.org:443` | Add a plain L4 endpoint. The proxy allows the TCP stream and does not inspect HTTP requests. | +| `telemetry.example.com:443::::allow-uninspected-credentials` | Explicitly allow a provider-credentialed L4 endpoint after accepting that OpenShell cannot inspect or rewrite its traffic. | +| `db.internal.example:5432::tcp` | Add an L4 endpoint for native DNS resolution and transparent TCP capture. The empty `access` segment is required before `tcp`. | | `api.github.com:443:read-only:rest:enforce` | Add a REST endpoint with the `read-only` preset expanded by the policy engine into GET, HEAD, and OPTIONS access. | | `api.example.com:443:read-write:rest:enforce:request-body-credential-rewrite` | Add a REST endpoint that rewrites credential placeholders in supported text request bodies. | | `realtime.example.com:443:read-write:websocket:enforce` | Add a WebSocket endpoint with the `read-write` preset expanded by the policy engine into the upgrade `GET` and client `WEBSOCKET_TEXT` access. | @@ -326,10 +366,19 @@ Use the `websocket-credential-rewrite` endpoint option with `protocol: websocket Use the `request-body-credential-rewrite` endpoint option with `protocol: rest` when an API expects OpenShell-managed credentials in UTF-8 JSON, form, or text request bodies. OpenShell buffers up to 256 KiB, rewrites recognized credential placeholders, updates `Content-Length`, and rejects unresolved placeholders instead of forwarding them. For chunked requests, the 256 KiB limit counts the complete wire representation, including framing, extensions, and trailers. The option is rejected for WebSocket, GraphQL, SQL, and plain L4 endpoints. +Use `allow-uninspected-credentials` only when a provider-credentialed endpoint must remain L4-only, use `tls: skip`, or carry uninspectable WebSocket traffic. Without this explicit opt-in, the gateway rejects credentialed L4-only and `tls: skip` endpoints. REST bodies without placeholders continue to work when body rewrite is disabled; a body containing an OpenShell credential placeholder fails closed. + Credential rewrite recognizes the canonical `openshell:resolve:env:KEY` placeholder form and whole-token provider-shaped aliases such as `provider-OPENSHELL-RESOLVE-ENV-API_TOKEN` when the referenced environment key exists in the configured provider credentials. +Static provider placeholders resolve only when the request host, port, and path +also match an endpoint in the provider profile. A sandbox policy allow does not +expand that binding. A mismatch returns HTTP 403 with +`credential_endpoint_mismatch`. Refer to [Static Credential Endpoint +Binding](/providers/profiles#understand-static-credential-endpoint-binding). + For example: +- `db.internal.example:5432::tcp` is valid. - `api.github.com:443:read-only:rest` is valid. - `realtime.example.com:443:read-write:websocket` is valid. - `api.github.com:443::rest` is invalid. It does not mean "allow all traffic." An L7 endpoint with `protocol` but no `access` or `rules` is rejected when the policy loads. @@ -500,7 +549,25 @@ The CLI validates the argument shapes before it sends the request. The gateway t - a port is outside `1` through `65535`. - `--add-allow` or `--add-deny` points at an endpoint that does not exist. - `--add-allow` or `--add-deny` targets an endpoint that is neither REST nor WebSocket. +- `--add-allow` or `--add-deny` targets a host and port that resolves to more than one endpoint. - `--add-deny` targets an endpoint that has no base allow set. +- an update names an existing rule and adds a binary to it without declaring every endpoint and port that rule already authorizes. +- an update names an existing rule and adds or changes an endpoint on it without declaring every binary that rule already authorizes. +- an update widens a rule to any binary without declaring every endpoint that rule already authorizes. +- an update changes an endpoint without declaring every port that endpoint carries. +- an update puts an MCP endpoint on the same host and port as a differently inspected endpoint, or gives one host and port two different MCP inspection contracts, including through separate rules or different paths. + +A rule authorizes every listed binary to reach every listed endpoint and port, so merging a binary and an endpoint into the same rule authorizes that pair too. The gateway rejects the whole batch rather than granting a pair the update did not ask for. The error names the binary scope and the ports involved, and lists the binaries you still need to declare. An empty binary list means any binary, so widening a rule to any binary is subject to the same requirement. + +You can declare the scope across several `--add-endpoint` arguments. The update is complete as long as every binary-to-port pair the merged rule ends up authorizing appears somewhere in the update. + +An endpoint's allow rules, deny rules, and allowed IPs apply to every port that endpoint carries, so an update that changes any of them has to name every one of those ports. Declaring `api.example.com:443` alone on an endpoint that also serves `8443` is rejected, because the change would reach `8443` as well. Declaring every existing binary does not lift this requirement; the two are separate axes of the same product. + +The sandbox picks the parser for a request by most-specific path, but it authorizes the request against every endpoint that matches it. A broad REST endpoint and a narrower GraphQL endpoint on one host and port share the same method-and-path rule vocabulary, so that combination stays supported. MCP does not: its rules address JSON-RPC methods and tool names, so a plain REST rule on an overlapping path could authorize a tool call the MCP endpoint denies. An MCP endpoint therefore cannot share a host and port with a differently inspected endpoint, and two MCP endpoints there must agree on strict-tool-name, method-profile, and body-limit settings, even under different paths or in separate rules. An update creating either situation is rejected. A policy that already contains one is left alone so unrelated updates still apply, but it should be repaired with full YAML replacement. + +To grant one binary access to only part of an existing rule's endpoints, send it under its own `--rule-name`. The gateway normally folds an update into an existing rule that shares an endpoint, but it keeps your rule name whenever folding would grant authorization you did not declare, so the narrow grant lands as its own rule authorizing exactly what you asked for. The update reports that it kept your rule name and names the rule it would otherwise have folded into. An MCP contract conflict is the exception: one host and port carry a single MCP contract regardless of which rule holds them, so a conflicting update is rejected rather than moved to a separate rule. + +Once a host and port appears in more than one rule, `--add-allow` and `--add-deny` can no longer target it. They select an endpoint by host and port alone, so they cannot say which rule's binary scope to widen, and the gateway rejects the update rather than guessing. The same applies when one rule carries two endpoints on that host and port under different paths. Use full YAML replacement to change L7 rules on an endpoint that appears more than once. ## Global Policy Override @@ -538,9 +605,18 @@ When triaging denied requests, check: - Destination host and port to confirm which endpoint is missing. - Calling binary path to confirm which `binaries` entry needs to be added or adjusted. - HTTP method and path for REST endpoints, or `GET` / `WEBSOCKET_TEXT` and the upgraded request path for WebSocket endpoints, to confirm which `rules` entry needs to be added or adjusted. +- `credential_endpoint_mismatch` in sandbox logs to confirm that policy admitted the request but the attached provider profile did not authorize its credential for that host, port, and path. +- `request_authority_mismatch` in the response or sandbox logs to confirm that the HTTP request authority differs from the authorized tunnel endpoint. For a CONNECT tunnel to `api.example.com:8443`, send `Host: api.example.com:8443`; omitting the non-default port makes the request authority use the transport default and OpenShell rejects it. Absolute-form request targets must use the same host and port. Then push the updated policy as described above. +Do not fix `credential_endpoint_mismatch` by widening sandbox policy. Export the +provider profile with `openshell provider profile export -o yaml`. +Update the custom provider profile only when the destination is an intended +credential recipient. Refer to [Static Credential Endpoint +Binding](/providers/profiles#understand-static-credential-endpoint-binding) +for the complete authorization model. + For small changes, prefer `openshell policy update` over rewriting the full YAML: ```shell @@ -569,7 +645,7 @@ Allow `pip install` and `uv pip install` to reach PyPI: - { path: /usr/local/bin/uv } ``` -Endpoints without `protocol` use TCP passthrough, where the proxy allows the stream without inspecting payloads. If the stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`. +Endpoints without `protocol` use explicit-proxy TCP passthrough, where OpenShell allows the stream without inspecting payloads. Use `protocol: tcp` when the application needs ordinary DNS resolution and native TCP connections through transparent capture. Provider-credentialed endpoints cannot use either L4 shape unless `allow_uninspected_credentials: true` records the exception. If an explicit-proxy stream is HTTP and TLS is auto-terminated, the proxy can still rewrite configured credential placeholders and closes keep-alive passthrough tunnels on policy reload before forwarding another request. WebSocket text-frame policy requires an explicit `protocol: websocket` endpoint. WebSocket payload credential rewrite can also be enabled on a `protocol: rest` compatibility endpoint with `websocket_credential_rewrite: true`. REST request body credential rewrite requires an inspected `protocol: rest` endpoint with `request_body_credential_rewrite: true`.
diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index c1059dee78..eeb13058e1 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -102,9 +102,23 @@ The loop has seven steps: 2. For inspected REST traffic, OpenShell returns a structured `403` body with fields such as `layer`, `host`, `port`, `binary`, `method`, `path`, `rule_missing`, `agent_guidance`, and `next_steps`. 3. The agent reads the policy advisor skill, inspects the current policy, and optionally reads recent denial log lines. 4. The agent submits one or more `addRule` proposals to `http://policy.local/v1/proposals`. -5. The gateway stores accepted proposals as pending draft chunks for the sandbox and runs the [policy prover](#what-auto-approval-checks) against the proposed delta. -6. Before auto-approval, the gateway reloads the current stored rule, recalculates its prover verdict, and regenerates its security notes. Under `auto` mode, it approves the proposal only when both the prover delta and security notes are empty. Any finding or security note keeps the proposal pending. Under `manual` mode, every accepted proposal lands in the draft inbox for a developer to approve or reject. -7. The agent waits on `/v1/proposals/{chunk_id}/wait` until a decision is available. Approved proposals hot-reload into the sandbox; rejected proposals return `rejection_reason` and `validation_result` so the agent can revise. +5. The gateway turns the proposal into the exact effective-policy candidate it would apply. It preserves any existing L7 or provider-owned endpoint contract, adds the proposed binary as a sandbox overlay, validates the full merge, and runs the [policy prover](#what-auto-approval-checks) against that candidate. +6. The gateway stores the candidate, its prover result, any application error, and a review token tied to the live policy, provider rules, and credential metadata. Provider rules remain immutable inputs. +7. Before approval, the gateway cheaply recomputes the candidate token from live inputs. An unchanged token reuses the stored prover result. A changed token leaves the proposal pending with a refreshed candidate and requires a fresh review. Under `auto` mode, an unchanged candidate is approved only when the prover delta and security notes are empty. Under `manual` mode, every valid proposal lands in the draft inbox. +8. The agent waits on `/v1/proposals/{chunk_id}/wait` until a decision is available. Approved proposals hot-reload into the sandbox; rejected proposals return `rejection_reason` and `validation_result` so the agent can revise. + +```mermaid +flowchart TD + A["Denied request creates a narrow proposal"] --> B["Gateway builds the exact effective-policy candidate"] + B --> C["Validate merge, L7 contract, providers, and credentials"] + C -->|Invalid| D["Keep pending and show application error"] + C -->|Valid| E["Run prover once and store candidate plus review token"] + E --> F["Reviewer approves using that token"] + F --> G["Recompute token from live inputs"] + G -->|Unchanged| H["Reuse stored prover result and apply candidate"] + G -->|Changed| I["Refresh candidate and token; require fresh review"] + I --> F +``` When a proposal is approved, `/wait` reports `policy_reloaded: true` only after the local sandbox policy covers the approved rule. At that point the agent can retry the original denied action once. If a proposal is rejected, `/wait` returns `rejection_reason` and `validation_result` so the agent can revise or stop. `validation_result` carries the categorical prover findings — `link_local_reach`, `l7_bypass_credentialed`, `credential_reach_expansion`, `capability_expansion` — so the agent can narrow the next attempt to the specific concern the prover flagged. @@ -117,6 +131,25 @@ OpenShell has two proposal paths: | Mechanistic mapper | Aggregated denial summaries from the sandbox. | Groups by host, port, and binary. If L7 request samples are available, it can draft REST method and path rules. Otherwise it drafts an L4 endpoint. | | Agent-authored proposal | The in-sandbox agent, using `policy.local`. | Usually a REST `addRule` with exact host, port, binary, method, and path from the structured denial. It can also propose L4 rules for opaque protocols. | +### How proposal provenance works + +OpenShell tracks whether an endpoint and binary came from policy advisor. This is internal provenance; it is not a policy YAML field that authors set. Think of each marker as answering “who introduced this identity?” rather than “what traffic does this allow?” + +| Marker | `false` | `true` | When both declarations meet | +|---|---|---|---| +| Endpoint provenance | A user or provider explicitly declared the endpoint. | Policy advisor proposed the endpoint. | The values do not conflict by themselves. The explicit declaration wins if the endpoints merge. | +| Binary provenance | A user or provider explicitly declared the binary path. | Policy advisor proposed the binary path. | The explicit declaration wins if the same path merges. | + +For example, a GitHub provider can explicitly declare `api.github.com:443` for read operations. An agent can then propose `PUT /repos/NVIDIA/OpenShell/contents/docs/**` for the same endpoint. The provider endpoint has provenance `false`; the proposal endpoint has provenance `true`. OpenShell allows that overlap, keeps the provider rule immutable, and stores an approved write rule in the sandbox policy layer. + +Provenance does not hide a real endpoint conflict. The same two declarations still fail validation if they disagree on connection or request-processing behavior that must have one value, such as TLS mode, `allowed_ips`, an equally specific L7 protocol or parser contract, credential binding, or enforcement mode. Authorization fields such as compatible allow and deny rules can combine. + +Exact-host SSRF trust requires an exact endpoint and the matching binary identity to be explicit in the same rule. An advisor-only endpoint or binary does not create that stronger trust. For example: + +- A provider rule that explicitly declares both `/usr/bin/gh` and `api.github.com:443` already establishes exact-host trust for that pair. A later advisor proposal does not create or broaden that trust. +- If the provider declares `api.github.com:443` for `/usr/bin/gh`, but the advisor proposes the endpoint for `/usr/bin/curl`, `curl` does not inherit the provider's binary identity. Its proposed rule remains subject to the normal SSRF checks. +- If an advisor proposes `internal-api.example:443` and it resolves to a private address, approval alone is not enough. A developer must explicitly authorize the intended address range with `allowed_ips`. + For REST APIs, prefer L7 rules over broad L4 access. A good proposal allows one method and the smallest safe path: ```json @@ -158,7 +191,7 @@ For REST APIs, prefer L7 rules over broad L4 access. A good proposal allows one The current `policy.local` JSON shape covers L4 endpoints and REST method or path rules. Use [Customize Sandbox Policies](/sandboxes/policies) or [Policy Schema Reference](/reference/policy-schema) for policy fields that are not part of the agent-authored proposal surface, such as WebSocket credential rewrite, GraphQL operation matching, endpoint path scoping, and provider-owned policy bundles. -Policy advisor proposals do not add `allowed_ips` automatically. If an advisor-proposed hostname resolves to an internal or private address, OpenShell's SSRF protections still block the connection until a developer explicitly adds the required `allowed_ips` entry. Exact hostname trust for user-declared policy endpoints does not apply to advisor-generated proposal binaries. +Policy advisor proposals do not add `allowed_ips` automatically. If an advisor-proposed hostname resolves to an internal or private address, OpenShell's SSRF protections still block the connection until a developer explicitly adds the required `allowed_ips` entry. Private RFC 1918, CGNAT, IPv6 ULA, and other special-use destinations classified as internal produce advisory security notes when they appear as literal endpoint IPs or in `allowed_ips`. CIDR intersections are included, and hostless `allowed_ips` rules receive an additional warning because they can match any hostname resolving into the configured range. @@ -179,9 +212,9 @@ The policy prover runs against mechanistic and agent-authored proposals alike an Findings are categorical. There is no severity tier. The reviewer reads the category and the structured evidence to decide. -Before auto-approval, the gateway reloads the draft chunk, recomputes its prover verdict, and regenerates security notes from its current rule. These checks apply after edits and deduplicated resubmissions: an edited stored rule controls the decision even when the duplicate incoming proposal has an empty prover delta. The recalculated verdict is used for this decision but is not written back, so a later `validation_result` read can still show the submit-time verdict. Failures leave the chunk pending. Security notes flag concerns such as internal or private destinations and `allowed_ips`, wildcard hosts, hostless `allowed_ips`, ephemeral ports, and well-known database or service ports. Draft reads and bulk approval also regenerate notes from the stored rule. Any prover finding or security note keeps the chunk pending. +Before approval, the gateway rebuilds the candidate token from the live base policy, immutable provider rules, and non-secret credential metadata. When that token is unchanged, it reuses the persisted prover result instead of rerunning the prover. When it changes, the gateway evaluates and persists the refreshed candidate, leaves the chunk pending, and requires the reviewer to inspect and approve the new token. Edits and deduplicated resubmissions follow the same path. Merge, policy-shape, provider-composition, credential, or prover failures are shown as application errors and cannot be approved. Security notes flag concerns such as internal or private destinations and `allowed_ips`, wildcard hosts, hostless `allowed_ips`, ephemeral ports, and well-known database or service ports. Any prover finding or security note keeps the chunk pending in auto mode. -The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Providers v2](/sandboxes/providers-v2) are part of the effective policy the prover reasons over. +The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Profiles](/providers/profiles) are part of the effective policy the prover reasons over. ## Review Proposals @@ -193,7 +226,7 @@ openshell rule get --status pending Under `auto` mode, proposals with a prover finding or any recalculated security note remain pending for human review. Proposals that pass both checks are visible under `--status approved` with the auto-approval audit fields described in [Approval Modes](#approval-modes). Under `manual` mode, every accepted proposal shows up as pending regardless of the prover verdict or security notes. -The output shows the chunk ID, status, rationale, binary, and endpoint summary. For L7 proposals, the endpoint summary includes the protocol, method, and path: +The output shows the chunk ID, status, rationale, binary, endpoint summary, prover result, application error (if any), and candidate hash. For L7 proposals, the endpoint summary includes the protocol, method, and path: ```text Endpoints: api.github.com:443 [L7 rest, allow PUT /repos/NVIDIA/OpenShell/contents/docs/**] @@ -205,6 +238,8 @@ Approve only when the structured rule matches the access you intend to grant: openshell rule approve --chunk-id ``` +The CLI fetches the current review token and submits it with the approval. If policy, provider, or credential inputs changed after the proposal was displayed, the gateway leaves it pending and asks you to review the refreshed candidate. Run `rule get` again before retrying. Bulk approval binds each selected chunk to its own review token in the same way. + Reject with guidance when the rule is too broad or points at the wrong target: ```shell @@ -215,6 +250,8 @@ openshell rule reject \ The rejection reason is returned to the agent through `policy.local`. The agent can use it to draft a narrower proposal. +Reviewers see the same guidance in the terminal UI. Run `openshell term`, open the sandbox's draft inbox, and select a rejected chunk to open its detail popup. The stored reason appears on a `Guidance:` line, and the list row shows a shortened copy of it. Rejection reasons have no length limit, so long guidance wraps and the popup body scrolls with `j`/`k`, `PageUp`/`PageDown`, and `g`/`G`; the approve and close controls stay pinned below the body, and the bottom border shows the scroll position. + ## Agent API `policy.local` is available only inside the sandbox and uses plain HTTP: diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 0ac0d5528f..1c541f8f8e 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -96,8 +96,8 @@ The `protocol` field on an endpoint controls whether the proxy inspects individu | Aspect | Detail | |---|---| -| Default | Endpoints without a `protocol` field use L4-only enforcement: the proxy checks host, port, and binary, then relays the TCP stream without inspecting payloads. | -| What you can change | Add `protocol: rest` to enable per-request HTTP method/path inspection, `protocol: websocket` to inspect RFC 6455 upgrade handshakes and client text messages, or `protocol: graphql` to inspect GraphQL-over-HTTP operation type, operation name, and root fields. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket messages. Pair inspected protocols with `rules` or access presets (`full`, `read-only`, `read-write`). REST endpoints that need credential placeholders in supported text request bodies can set `request_body_credential_rewrite: true`. | +| Default | Endpoints without a `protocol` field use L4-only enforcement: the proxy checks host, port, and binary, then relays the TCP stream without inspecting payloads. Provider-credentialed endpoints reject this mode unless an operator explicitly opts in. | +| What you can change | Add `protocol: rest` to enable per-request HTTP method/path inspection, `protocol: websocket` to inspect RFC 6455 upgrade handshakes and client text messages, or `protocol: graphql` to inspect GraphQL-over-HTTP operation type, operation name, and root fields. WebSocket endpoints can also use GraphQL operation rules for GraphQL-over-WebSocket messages. Pair inspected protocols with `rules` or access presets (`full`, `read-only`, `read-write`). REST endpoints that need credential placeholders in supported text request bodies can set `request_body_credential_rewrite: true`. Set `allow_uninspected_credentials: true` only as an explicit exception for credentialed traffic that cannot use an inspected path. | | Risk if relaxed | L4-only endpoints allow the agent to send any data through the tunnel after the initial connection is permitted. The proxy cannot see HTTP methods, paths, or GraphQL operations. Adding `access: full` with L7 inspection enables observability but permits all inspected actions. | | Recommendation | Use `protocol: rest` with specific `rules` for APIs where intent is encoded in method and path. Add `request_body_credential_rewrite: true` only for REST APIs that require OpenShell-managed credentials in UTF-8 JSON, form, or text request bodies. Use `protocol: graphql` for GraphQL-over-HTTP APIs where destructive operations are body-encoded. Use `protocol: websocket` for RFC 6455 endpoints, with explicit `GET` and `WEBSOCKET_TEXT` rules for raw text protocols or explicit GraphQL operation rules for GraphQL-over-WebSocket. Prefer `access: read-only` or explicit allowlists, and deny hash-only persisted queries unless you maintain a trusted registry. Omit `protocol` for non-HTTP protocols. For WebSocket endpoints that must carry placeholder credentials in client text frames, add `websocket_credential_rewrite: true`. | @@ -123,7 +123,7 @@ This enables credential injection and L7 inspection without explicit configurati | Default | Auto-detect and terminate. OpenShell generates the sandbox CA at startup and injects it into the process trust stores (`NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`). | | What you can change | Set `tls: skip` on an endpoint to disable TLS detection and termination for that endpoint. Use this for client-certificate mTLS to upstream or non-standard binary protocols. | | Risk if relaxed | `tls: skip` disables placeholder credential rewriting, dynamic token grant injection, and L7 inspection for that endpoint. The proxy relays encrypted traffic without seeing the contents. | -| Recommendation | Use auto-detect (the default) for most endpoints. Use `tls: skip` only when the upstream requires the client's own TLS certificate (mTLS) or uses a non-HTTP protocol. | +| Recommendation | Use auto-detect (the default) for most endpoints. Use `tls: skip` only when the upstream requires the client's own TLS certificate (mTLS) or uses a non-HTTP protocol. A provider-credentialed endpoint also requires the explicit `allow_uninspected_credentials: true` exception. | ### SSRF Protection @@ -174,7 +174,7 @@ The policy separates filesystem paths into read-only and read-write groups. | Aspect | Detail | |---|---| -| Default | System paths (`/usr`, `/lib`, `/etc`, `/var/log`) are read-only. Working paths (`/sandbox`, `/tmp`) are read-write. `/app` is conditionally included if it exists. | +| Default | System paths (`/usr`, `/lib`, `/etc`, `/var/log`) are read-only. The resolved working directory and `/tmp` are read-write. `/app` is conditionally included if it exists. | | What you can change | Add or remove paths in `filesystem_policy.read_only` and `filesystem_policy.read_write`. | | Risk if relaxed | Making system paths writable lets the agent replace binaries, modify TLS trust stores, or change DNS resolution. Validation rejects broad read-write paths (like `/`). | | Recommendation | Keep system paths read-only. If the agent needs additional writable space, add a specific subdirectory. | @@ -202,8 +202,8 @@ The sandbox process runs as a non-root user after explicit privilege dropping. | Aspect | Detail | |---|---| | Default | The compute driver selects a non-root identity. Docker and Podman use the image's OCI `USER` as a per-field fallback. The supervisor calls `setuid()`/`setgid()` with post-condition verification, disables core dumps with `RLIMIT_CORE=0`, and on Linux sets `PR_SET_DUMPABLE=0`. | -| What you can change | Set either or both `run_as_user` and `run_as_group` fields in the `process` section. Each explicit field takes precedence and must be `sandbox` or a numeric UID/GID value in the allowed sandbox range. Docker and Podman may use named identities or non-root system IDs only through OCI `USER` fallback. Root identities are always rejected. | -| Risk if relaxed | Running as a higher-privilege user increases the impact of container escape vulnerabilities. | +| What you can change | Set either or both `run_as_user` and `run_as_group` fields in the `process` section. Each explicit field takes precedence and must be `sandbox` or a numeric UID/GID from `1` through `4294967294`. Docker and Podman may use named identities through OCI `USER` fallback. Root and the invalid identity sentinel are always rejected. | +| Risk if relaxed | A numeric identity inherits every permission granted to the same UID/GID on image files, mounted volumes, or devices. Low IDs often collide with system accounts and groups. | | Recommendation | Use a dedicated non-root image identity or explicit numeric policy identity. Do not attempt to set root. | ### Seccomp Filters diff --git a/docs/security/verifying-images.mdx b/docs/security/verifying-images.mdx new file mode 100644 index 0000000000..4b9704d3b5 --- /dev/null +++ b/docs/security/verifying-images.mdx @@ -0,0 +1,36 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Verify the Contents of Published OpenShell Images" +sidebar-title: "Verify Image Contents" +slug: "security/verify-image-contents" +description: "Read the SBOM attestation attached to published gateway and supervisor images to audit what each image contains." +keywords: "Generative AI, Cybersecurity, Supply Chain, SBOM, Container Images" +position: 2 +--- + +Published gateway and supervisor images carry one SPDX SBOM per platform as OCI attestations. + +## Inspect an Image + +Read a platform's document without pulling the image: + +```shell +docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest --format '{{ json (index .SBOM "linux/amd64").SPDX }}' +``` + +List the packages instead of the full document: + +```shell +docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest --format '{{ range (index .SBOM "linux/amd64").SPDX.packages }}{{ .name }}@{{ .versionInfo }}{{ println }}{{ end }}' +``` + +The same commands work for `ghcr.io/nvidia/openshell/supervisor`. + +## Coverage + +Every SBOM lists the base-image packages. Release Dev and Release Tag images also list the Rust crates compiled into their OpenShell binary. + + +OpenShell also publishes minimal SLSA provenance. It records how BuildKit produced the image, including its source revision, build platform, and base-image materials, without the extra build parameters included by full provenance. + diff --git a/e2e/docker/Dockerfile.external-kubernetes-gateway b/e2e/docker/Dockerfile.external-kubernetes-gateway new file mode 100644 index 0000000000..5d650bae89 --- /dev/null +++ b/e2e/docker/Dockerfile.external-kubernetes-gateway @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1.4 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG GATEWAY_BASE_IMAGE=gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 +FROM ${GATEWAY_BASE_IMAGE} + +ARG TARGETARCH +ARG SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:latest +COPY deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-gateway /usr/local/bin/openshell-gateway +COPY deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-driver-kubernetes /usr/local/bin/openshell-driver-kubernetes + +ENV OPENSHELL_DRIVERS=kubernetes \ + OPENSHELL_COMPUTE_DRIVER_SOCKET=/var/run/openshell-compute/driver/driver.sock \ + OPENSHELL_GATEWAY_ID=openshell \ + OPENSHELL_SANDBOX_NAMESPACE=openshell \ + OPENSHELL_K8S_SANDBOX_SERVICE_ACCOUNT=openshell-sandbox \ + OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest \ + OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=IfNotPresent \ + OPENSHELL_GRPC_ENDPOINT=http://openshell.openshell.svc.cluster.local:8080 \ + OPENSHELL_SUPERVISOR_IMAGE=${SUPERVISOR_IMAGE} \ + OPENSHELL_SUPERVISOR_IMAGE_PULL_POLICY=IfNotPresent \ + OPENSHELL_SUPERVISOR_SIDELOAD_METHOD=init-container \ + OPENSHELL_K8S_TOPOLOGY=combined + +USER 1000:1000 +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/openshell-gateway"] +CMD ["--bind-address", "0.0.0.0", "--port", "8080"] diff --git a/e2e/helm-plugins/openshell-external-compute-driver/kustomization.yaml b/e2e/helm-plugins/openshell-external-compute-driver/kustomization.yaml new file mode 100644 index 0000000000..3c01ab6963 --- /dev/null +++ b/e2e/helm-plugins/openshell-external-compute-driver/kustomization.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - rendered.yaml +patches: + - path: workload-patch.yaml + target: + group: apps + version: v1 + kind: StatefulSet + name: openshell +replacements: + - source: + group: apps + version: v1 + kind: StatefulSet + name: openshell + fieldPath: spec.template.spec.containers.[name=openshell-gateway].image + targets: + - select: + group: apps + version: v1 + kind: StatefulSet + name: openshell + fieldPaths: + - spec.template.spec.containers.[name=kubernetes-compute-driver].image diff --git a/e2e/helm-plugins/openshell-external-compute-driver/plugin.yaml b/e2e/helm-plugins/openshell-external-compute-driver/plugin.yaml new file mode 100644 index 0000000000..2a58ba4f06 --- /dev/null +++ b/e2e/helm-plugins/openshell-external-compute-driver/plugin.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +type: postrenderer/v1 +name: openshell-external-compute-driver +version: 0.1.0 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: ${HELM_PLUGIN_DIR}/post-renderer.sh diff --git a/e2e/helm-plugins/openshell-external-compute-driver/post-renderer.sh b/e2e/helm-plugins/openshell-external-compute-driver/post-renderer.sh new file mode 100755 index 0000000000..4d9d2eeff2 --- /dev/null +++ b/e2e/helm-plugins/openshell-external-compute-driver/post-renderer.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Helm post-renderer for the external Kubernetes compute-driver smoke test. +# It keeps the test-only sidecar and Unix socket plumbing out of the chart. + +set -euo pipefail + +plugin_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/openshell-external-compute-driver.XXXXXX")" +trap 'rm -rf "${work_dir}"' EXIT + +cp "${plugin_dir}/kustomization.yaml" "${work_dir}/kustomization.yaml" +cp "${plugin_dir}/workload-patch.yaml" "${work_dir}/workload-patch.yaml" +tee "${work_dir}/rendered.yaml" >/dev/null + +kubectl kustomize "${work_dir}" diff --git a/e2e/helm-plugins/openshell-external-compute-driver/workload-patch.yaml b/e2e/helm-plugins/openshell-external-compute-driver/workload-patch.yaml new file mode 100644 index 0000000000..2b041e8647 --- /dev/null +++ b/e2e/helm-plugins/openshell-external-compute-driver/workload-patch.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: openshell +spec: + template: + spec: + containers: + - name: openshell-gateway + volumeMounts: + - name: compute-driver-socket + mountPath: /var/run/openshell-compute + - name: kubernetes-compute-driver + image: replaced-by-kustomize + imagePullPolicy: IfNotPresent + command: + - /usr/local/bin/openshell-driver-kubernetes + args: + - --bind-socket + - /var/run/openshell-compute/driver/driver.sock + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - name: compute-driver-socket + mountPath: /var/run/openshell-compute + resources: {} + volumes: + - name: compute-driver-socket + emptyDir: {} diff --git a/e2e/mcp-conformance.sh b/e2e/mcp-conformance.sh index 87b1f22e9a..1bbe5951ff 100755 --- a/e2e/mcp-conformance.sh +++ b/e2e/mcp-conformance.sh @@ -338,7 +338,8 @@ create_client_sandbox() { --from "${CLIENT_IMAGE}" \ --policy "${policy_file}" \ --no-tty \ - -- true; then + --detach \ + -- sleep infinity; then rm -f "${policy_file}" return 1 fi diff --git a/e2e/no-compute-driver-gateway.sh b/e2e/no-compute-driver-gateway.sh new file mode 100755 index 0000000000..baa4a7d193 --- /dev/null +++ b/e2e/no-compute-driver-gateway.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}" + +echo "Building gateway without compiled compute drivers..." +cargo build -p openshell-gateway --bin openshell-gateway \ + --no-default-features --features telemetry +cargo check -p openshell-core --no-default-features --all-targets + +dependency_tree="$(cargo tree -p openshell-gateway \ + --no-default-features --features telemetry --edges normal)" +server_dependency_tree="$(cargo tree -p openshell-server --edges normal)" +for driver in \ + openshell-driver-docker \ + openshell-driver-kubernetes \ + openshell-driver-podman \ + openshell-driver-vm; do + if grep -q "${driver} v" <<<"${dependency_tree}"; then + echo "ERROR: driver-free gateway dependency graph contains ${driver}" >&2 + exit 1 + fi + if grep -q "${driver} v" <<<"${server_dependency_tree}"; then + echo "ERROR: openshell-server dependency graph contains ${driver}" >&2 + exit 1 + fi +done + +if rg -n \ + 'ComputeDriverKind|openshell_driver_(docker|podman|kubernetes)([^_[:alnum:]]|$)|ComputeRuntime::new_(docker|podman|kubernetes)|VmComputeConfig|compute::vm|driver_config::builtin|libkrun|gvproxy|qemu' \ + crates/openshell-core crates/openshell-server; then + echo "ERROR: backend-specific compute-driver knowledge leaked into core/server" >&2 + exit 1 +fi + +"${ROOT}/target/debug/openshell-gateway" --version +echo "Driver-free gateway build passed." diff --git a/e2e/policy-advisor/README.md b/e2e/policy-advisor/README.md index 2c2c96f195..71c3f9d1dd 100644 --- a/e2e/policy-advisor/README.md +++ b/e2e/policy-advisor/README.md @@ -67,3 +67,11 @@ Or manually against a running gateway with `agent_policy_proposals_enabled=true` ```bash OPENSHELL_BIN=target/debug/openshell bash e2e/policy-advisor/mechanistic-smoke.sh ``` + +The #2821 regression additionally verifies that a denial on an existing +inspected endpoint becomes a binary expansion, auto-approves, hot-reloads, and +does not downgrade the endpoint to L4: + +```bash +mise run e2e:mechanistic-existing-endpoint +``` diff --git a/e2e/policy-advisor/existing-endpoint-auto-approve.sh b/e2e/policy-advisor/existing-endpoint-auto-approve.sh new file mode 100755 index 0000000000..c80c6bbdc4 --- /dev/null +++ b/e2e/policy-advisor/existing-endpoint-auto-approve.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Regression for #2821: a curl denial on cargo's inspected endpoint must +# become a compatible binary expansion, auto-approve, hot-reload, and retain +# the REST/read-only endpoint contract. + +set -euo pipefail +export NO_COLOR=1 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +OPENSHELL_BIN="${OPENSHELL_BIN:-${REPO_ROOT}/target/debug/openshell}" +RUN_ID="${RUN_ID:-$(date +%H%M%S)}" +SANDBOX="${SANDBOX:-advisor-2821-${RUN_ID}}" +FLUSH_WAIT="${FLUSH_WAIT:-45}" +TMP_DIR="$(mktemp -d)" + +strip_ansi() { + sed $'s/\033\\[[0-9;]*m//g' +} + +cleanup() { + "$OPENSHELL_BIN" sandbox delete "$SANDBOX" >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +cat > "${TMP_DIR}/policy.yaml" <<'EOF' +version: 1 +network_policies: + cargo_registry: + name: cargo-registry + endpoints: + - host: index.crates.io + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - path: /usr/bin/cargo +EOF + +"$OPENSHELL_BIN" sandbox create \ + --name "$SANDBOX" \ + --policy "${TMP_DIR}/policy.yaml" \ + --approval-mode auto \ + --no-auto-providers \ + --no-tty \ + --detach \ + -- sh -c "exec sleep infinity" >/dev/null + +set +e +DENY_OUTPUT="$($OPENSHELL_BIN sandbox exec --name "$SANDBOX" -- \ + /usr/bin/curl -fsS --max-time 10 https://index.crates.io/config.json 2>&1)" +DENY_STATUS=$? +set -e +if [[ "$DENY_STATUS" -eq 0 ]]; then + echo "expected the first curl request to be denied" >&2 + exit 1 +fi +printf '%s\n' "$DENY_OUTPUT" + +RULE_OUTPUT="" +for _attempt in $(seq 1 "$((FLUSH_WAIT / 5))"); do + RULE_OUTPUT="$($OPENSHELL_BIN rule get "$SANDBOX" 2>&1 | strip_ansi)" + grep -q "Status: approved" <<<"$RULE_OUTPUT" && break + sleep 5 +done + +printf '%s\n' "$RULE_OUTPUT" +grep -q "Status: approved" <<<"$RULE_OUTPUT" +grep -q "Rule: cargo_registry" <<<"$RULE_OUTPUT" +grep -q "Prover: prover: no new findings" <<<"$RULE_OUTPUT" +if grep -q "Application:" <<<"$RULE_OUTPUT"; then + echo "auto-approved chunk unexpectedly retained an application error" >&2 + exit 1 +fi + +POLICY_OUTPUT="$($OPENSHELL_BIN policy get "$SANDBOX" --full 2>&1 | strip_ansi)" +grep -q "protocol: rest" <<<"$POLICY_OUTPUT" +grep -q "access: read-only" <<<"$POLICY_OUTPUT" +grep -q "/usr/bin/cargo" <<<"$POLICY_OUTPUT" +grep -q "/usr/bin/curl" <<<"$POLICY_OUTPUT" + +for _attempt in $(seq 1 15); do + if "$OPENSHELL_BIN" sandbox exec --name "$SANDBOX" -- \ + /usr/bin/curl -fsS --max-time 15 https://index.crates.io/config.json \ + >/dev/null 2>&1; then + echo "#2821 existing-endpoint auto-approval regression passed" + exit 0 + fi + sleep 2 +done + +echo "approved policy did not hot-reload for curl" >&2 +exit 1 diff --git a/e2e/policy-advisor/mechanistic-smoke.sh b/e2e/policy-advisor/mechanistic-smoke.sh index d4f3601286..4ba780856f 100755 --- a/e2e/policy-advisor/mechanistic-smoke.sh +++ b/e2e/policy-advisor/mechanistic-smoke.sh @@ -76,9 +76,10 @@ create_sandbox() { "$OPENSHELL_BIN" sandbox delete "$SANDBOX" >/dev/null 2>&1 || true "$OPENSHELL_BIN" sandbox create \ --name "$SANDBOX" \ + --detach \ --no-auto-providers \ --no-tty \ - -- bash -lc "echo sandbox ready" \ + -- sh -c "exec sleep infinity" \ | sed 's/^/ /' "$OPENSHELL_BIN" sandbox ssh-config "$SANDBOX" > "$SSH_CONFIG" @@ -101,14 +102,20 @@ trigger_l4_deny() { } assert_pending_chunk() { - step "Waiting ${FLUSH_WAIT}s then checking for pending chunk" - sleep "$FLUSH_WAIT" - local output - output="$("$OPENSHELL_BIN" rule get "$SANDBOX" --status pending 2>&1)" + step "Waiting up to ${FLUSH_WAIT}s for pending chunk" + local output="" + local _i + for _i in $(seq 1 "$FLUSH_WAIT"); do + output="$("$OPENSHELL_BIN" rule get "$SANDBOX" --status pending 2>&1)" + if printf '%s\n' "$output" | grep -qi "blocked.invalid"; then + printf '%s\n' "$output" | sed 's/^/ /' + ok "pending mechanistic chunk present for blocked.invalid" + return + fi + sleep 1 + done printf '%s\n' "$output" | sed 's/^/ /' - printf '%s\n' "$output" | grep -qi "blocked.invalid" \ - || fail "no pending chunk for blocked.invalid" - ok "pending mechanistic chunk present for blocked.invalid" + fail "no pending chunk for blocked.invalid after ${FLUSH_WAIT}s" } main() { diff --git a/e2e/policy-advisor/test.sh b/e2e/policy-advisor/test.sh index 431079020c..98854b52c9 100755 --- a/e2e/policy-advisor/test.sh +++ b/e2e/policy-advisor/test.sh @@ -230,9 +230,7 @@ create_sandbox() { --policy "$POLICY_FILE" \ --upload "${RUNNER_SOURCE}:/sandbox/policy-validation-runner.sh" \ --no-git-ignore \ - --no-auto-providers \ - --no-tty \ - -- bash -lc "chmod +x /sandbox/policy-validation-runner.sh && echo sandbox ready" + --no-auto-providers } connect_ssh() { @@ -245,6 +243,8 @@ connect_ssh() { local i for i in $(seq 1 "$retries"); do if ssh -F "$SSH_CONFIG" "$SSH_HOST" true >/dev/null 2>&1; then + ssh -F "$SSH_CONFIG" "$SSH_HOST" chmod +x /sandbox/policy-validation-runner.sh \ + || fail "could not make uploaded policy runner executable" return fi sleep 2 diff --git a/e2e/policy-advisor/wait-smoke.sh b/e2e/policy-advisor/wait-smoke.sh index a1f2bcf730..006295a470 100755 --- a/e2e/policy-advisor/wait-smoke.sh +++ b/e2e/policy-advisor/wait-smoke.sh @@ -129,8 +129,6 @@ create_sandbox() { --upload "${RUNNER_SOURCE}:/sandbox/runner.sh" \ --no-git-ignore \ --no-auto-providers \ - --no-tty \ - -- bash -lc "chmod +x /sandbox/runner.sh && echo sandbox ready" \ | sed 's/^/ /' "$OPENSHELL_BIN" sandbox ssh-config "$SANDBOX" > "$SSH_CONFIG" @@ -140,6 +138,8 @@ create_sandbox() { local _i for _i in $(seq 1 30); do if ssh -F "$SSH_CONFIG" "$SSH_HOST" true >/dev/null 2>&1; then + ssh -F "$SSH_CONFIG" "$SSH_HOST" chmod +x /sandbox/runner.sh \ + || fail "could not make uploaded runner executable" ok "SSH up" return fi diff --git a/e2e/python/oidc/oidc_auth_test.py b/e2e/python/oidc/oidc_auth_test.py index bbea1aac3d..39cd076af0 100644 --- a/e2e/python/oidc/oidc_auth_test.py +++ b/e2e/python/oidc/oidc_auth_test.py @@ -15,17 +15,23 @@ import contextlib import os +import urllib.parse +from pathlib import Path import grpc import pytest +from openshell import ClientCredentialsAuth, SandboxClient, TlsConfig from openshell._proto import datamodel_pb2, openshell_pb2, openshell_pb2_grpc from .helpers import ( + KEYCLOAK_REALM, + _gateway_endpoint, + _mtls_dir, extract_sub, - get_ci_token, get_token, grpc_channel, + keycloak_url, stub_with_token, ) @@ -48,7 +54,7 @@ def test_admin_can_create_provider(self) -> None: provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name="e2e-oidc-admin-test"), type="claude", - credentials={"API_KEY": "test-value"}, + credentials={"ANTHROPIC_API_KEY": "test-value"}, ) ) try: @@ -72,7 +78,7 @@ def test_user_cannot_create_provider(self) -> None: provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name="e2e-oidc-user-blocked"), type="claude", - credentials={"API_KEY": "test-value"}, + credentials={"ANTHROPIC_API_KEY": "test-value"}, ) ) with pytest.raises(grpc.RpcError) as exc_info: @@ -180,9 +186,28 @@ class TestClientCredentials: def test_ci_token_can_list_sandboxes(self) -> None: admin_token = get_token("admin@test", "admin", scopes="openid openshell:all") admin_stub, admin_md = stub_with_token(admin_token) - ci_token = get_ci_token() + auth = ClientCredentialsAuth( + issuer=f"{keycloak_url()}/realms/{KEYCLOAK_REALM}", + client_id="openshell-ci", + client_secret="ci-test-secret", + ) + ci_token = auth() ci_sub = extract_sub(ci_token) - ci_stub, ci_md = stub_with_token(ci_token) + gateway_endpoint, is_tls = _gateway_endpoint() + parsed = urllib.parse.urlparse(gateway_endpoint) + target = f"{parsed.hostname}:{parsed.port or (443 if is_tls else 80)}" + tls = None + if is_tls: + if ca_path := os.environ.get("OPENSHELL_E2E_GATEWAY_CA_CERT"): + tls = TlsConfig(ca_path=Path(ca_path)) + else: + mtls = _mtls_dir() + tls = TlsConfig( + ca_path=mtls / "ca.crt", + cert_path=mtls / "tls.crt", + key_path=mtls / "tls.key", + ) + ci_client = SandboxClient(target, tls=tls, client_credentials=auth) with contextlib.suppress(grpc.RpcError): admin_stub.AddWorkspaceMember( @@ -194,8 +219,9 @@ def test_ci_token_can_list_sandboxes(self) -> None: metadata=admin_md, ) try: - ci_stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=ci_md) + ci_client.list(workspace="default") finally: + ci_client.close() with contextlib.suppress(grpc.RpcError): admin_stub.RemoveWorkspaceMember( openshell_pb2.RemoveWorkspaceMemberRequest( diff --git a/e2e/python/oidc/workspace_authz_test.py b/e2e/python/oidc/workspace_authz_test.py index 8336b79078..29bc03bbb5 100644 --- a/e2e/python/oidc/workspace_authz_test.py +++ b/e2e/python/oidc/workspace_authz_test.py @@ -248,7 +248,7 @@ def _workspace_rpcs() -> list[tuple[str, Callable]]: name="authz-test", workspace=WS ), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=m, @@ -277,7 +277,7 @@ def _workspace_rpcs() -> list[tuple[str, Callable]]: name="nonexistent", workspace=WS ), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=m, @@ -666,7 +666,7 @@ def seed_provider(self, admin_ctx: Any, workspace: str) -> Any: name=prov_name, workspace=workspace ), type="claude", - credentials={"API_KEY": "test"}, + credentials={"ANTHROPIC_API_KEY": "test"}, ), ), metadata=metadata, @@ -977,7 +977,7 @@ def test_user_member_admin_operations_denied( name="user-blocked", workspace=WS ), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=user_md, @@ -1046,7 +1046,7 @@ def test_workspace_admin_can_create_provider( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=prov_name, workspace=WS), type="claude", - credentials={"K": "v"}, + credentials={"ANTHROPIC_API_KEY": "v"}, ), ), metadata=user_md, diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index 82e32a9b34..d2ce47e2e0 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -6,6 +6,7 @@ import json from typing import TYPE_CHECKING +import grpc import pytest from openshell._proto import datamodel_pb2, sandbox_pb2 @@ -319,7 +320,11 @@ def log_message(self, *args): {"connect_status": connect_resp.strip(), "http_status": 0} ) - request = f"{method} {path} HTTP/1.1\r\nHost: {target_host}\r\nConnection: close\r\n\r\n" + request = ( + f"{method} {path} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n\r\n" + ) conn.sendall(request.encode()) data = b"" @@ -1956,8 +1961,8 @@ def test_overlapping_policies_with_conflicting_destination_metadata_are_rejected """OVL-1: Conflicting metadata on the same host:port fails closed. One endpoint permits any resolved address while the other constrains - ``allowed_ips``. The complete candidate is ambiguous and must not activate - either entry. + ``allowed_ips``. The complete candidate is ambiguous and must be rejected + before the sandbox is provisioned. """ policy = _base_policy( network_policies={ @@ -1985,16 +1990,16 @@ def test_overlapping_policies_with_conflicting_destination_metadata_are_rejected }, ) spec = datamodel_pb2.SandboxSpec(policy=policy) - with sandbox(spec=spec, delete_on_exit=True) as sb: - result = sb.exec_python( - _forward_proxy_with_server(), - args=(_PROXY_HOST, _PROXY_PORT, _SANDBOX_IP, _FORWARD_PROXY_PORT), - ) - assert result.exit_code == 0, result.stderr - assert "403" in result.stdout, ( - "Conflicting overlapping policies should fail closed; " - f"expected 403, got: {result.stdout}" - ) + with ( + pytest.raises(grpc.RpcError) as exc_info, + sandbox(spec=spec, delete_on_exit=True), + ): + pytest.fail("ambiguous policy unexpectedly created a sandbox") + + assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION + details = exc_info.value.details() or "" + assert "network endpoint ambiguity validation failed" in details + assert "allowed_ips" in details def test_overlapping_policies_l7_connect_does_not_crash( diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 8c077712e0..a924168a1b 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -39,7 +39,7 @@ def _is_placeholder_for_env_key(value: str, key: str) -> bool: token = value.removeprefix(prefix) if token == value: return False - return token.startswith("v") and token.endswith(f"_{key}") + return token.startswith(("v", "s")) and token.endswith(f"_{key}") def _default_policy() -> sandbox_pb2.SandboxPolicy: @@ -99,68 +99,6 @@ def _delete_provider(stub: object, name: str) -> None: raise -@pytest.fixture -def providers_v2_enabled( - sandbox_client: SandboxClient, - _gateway_config_guard: None, -) -> Iterator[None]: - """Enable the gateway-global ``providers_v2_enabled`` opt-in for one test. - - Composing a provider's network policy onto a sandbox is gated behind this - setting, which defaults off; the built-in github profile's git-transport - rules only reach the sandbox with it enabled. - - The setting is gateway-global. Exclusivity against other xdist workers is - provided by the ``exclusive_gateway_config`` marker plus the autouse - ``_gateway_config_guard`` guard (see conftest): no concurrent worker is - mid-test while this fixture mutates and restores the setting, so none can - observe the transient value. Depending on the guard here also orders the - exclusive lock acquisition before the mutation. - - ``GetGatewayConfig`` returns known keys even when unset, with an empty - ``SettingValue`` (no populated oneof), so the setting is treated as present - only when its value oneof is set; otherwise restore is a delete. ``global`` - is a Python keyword, so it is passed through a dict expansion. - """ - stub = sandbox_client._stub - key = "providers_v2_enabled" - config = stub.GetGatewayConfig(sandbox_pb2.GetGatewayConfigRequest()) - prior_value = sandbox_pb2.SettingValue() - had_prior = ( - key in config.settings - and config.settings[key].WhichOneof("value") is not None - ) - if had_prior: - prior_value.CopyFrom(config.settings[key]) - - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - setting_value=sandbox_pb2.SettingValue(bool_value=True), - **{"global": True}, - ) - ) - try: - yield - finally: - if had_prior: - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - setting_value=prior_value, - **{"global": True}, - ) - ) - else: - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - delete_setting=True, - **{"global": True}, - ) - ) - - # =========================================================================== # Tests: placeholder visibility # =========================================================================== @@ -195,38 +133,97 @@ def read_env_var() -> str: assert value != "sk-e2e-test-key-12345" -def test_generic_provider_credentials_available_as_env_vars( +def test_profileless_provider_creation_is_rejected( + sandbox_client: SandboxClient, +) -> None: + """New providers must reference a built-in or imported profile.""" + with pytest.raises(grpc.RpcError) as exc_info: + sandbox_client._stub.CreateProvider( + openshell_pb2.CreateProviderRequest( + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name="e2e-test-profileless-provider" + ), + type="generic", + credentials={"CUSTOM_SERVICE_TOKEN": "token-generic-123"}, + ) + ) + ) + assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert "provider profile 'generic' was not found" in exc_info.value.details() + + +def test_endpointless_profile_credentials_fail_closed_without_policy_binding( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """Generic provider env vars are placeholders, not raw secrets.""" + """Endpointless profile credentials are withheld without an explicit binding.""" with provider( sandbox_client._stub, - name="e2e-test-generic-provider-env", - provider_type="generic", - credentials={ - "CUSTOM_SERVICE_TOKEN": "token-generic-123", - "CUSTOM_SERVICE_URL": "https://internal.example.test/api", - }, + name="e2e-test-google-cloud-without-policy-binding", + provider_type="google-cloud", + credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-e2e-token"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( policy=_default_policy(), providers=[provider_name], ) - def read_generic_env_vars() -> str: + def read_gcp_token() -> str: + import os + + return os.environ.get("GCP_ADC_ACCESS_TOKEN", "NOT_SET") + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python(read_gcp_token) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "NOT_SET" + + +def test_endpointless_profile_credentials_use_explicit_policy_binding( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """An endpointless profile emits credentials only with an explicit binding.""" + with provider( + sandbox_client._stub, + name="e2e-test-google-cloud-policy-binding", + provider_type="google-cloud", + credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-e2e-token"}, + ) as provider_name: + policy = _default_policy() + policy.network_policies["gcp_storage"].CopyFrom( + sandbox_pb2.NetworkPolicyRule( + name="gcp_storage", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host="storage.googleapis.com", + port=443, + protocol="rest", + access="full", + credential_binding=sandbox_pb2.NetworkCredentialBinding( + provider=provider_name + ), + ) + ], + ) + ) + spec = datamodel_pb2.SandboxSpec( + policy=policy, + providers=[provider_name], + ) + + def read_gcp_token() -> str: import os - token = os.environ.get("CUSTOM_SERVICE_TOKEN", "NOT_SET") - url = os.environ.get("CUSTOM_SERVICE_URL", "NOT_SET") - return f"{token}|{url}" + return os.environ.get("GCP_ADC_ACCESS_TOKEN", "NOT_SET") with sandbox(spec=spec, delete_on_exit=True) as sb: - result = sb.exec_python(read_generic_env_vars) + result = sb.exec_python(read_gcp_token) assert result.exit_code == 0, result.stderr - token, url = result.stdout.strip().split("|") - assert _is_placeholder_for_env_key(token, "CUSTOM_SERVICE_TOKEN") - assert _is_placeholder_for_env_key(url, "CUSTOM_SERVICE_URL") + assert _is_placeholder_for_env_key( + result.stdout.strip(), "GCP_ADC_ACCESS_TOKEN" + ) def test_nvidia_provider_injects_nvidia_api_key_env_var( @@ -253,9 +250,7 @@ def read_nvidia_key() -> str: with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python(read_nvidia_key) assert result.exit_code == 0, result.stderr - assert _is_placeholder_for_env_key( - result.stdout.strip(), "NVIDIA_API_KEY" - ) + assert _is_placeholder_for_env_key(result.stdout.strip(), "NVIDIA_API_KEY") def test_attach_detach_updates_credentials_for_later_exec_launches( @@ -269,15 +264,15 @@ def test_attach_detach_updates_credentials_for_later_exec_launches( with provider( stub, name=provider_name, - provider_type="generic", - credentials={"CUSTOM_ATTACH_TOKEN": "token-attach-detach"}, + provider_type="nvidia", + credentials={"NVIDIA_API_KEY": "token-attach-detach"}, ): spec = datamodel_pb2.SandboxSpec(policy=_default_policy(), providers=[]) def read_attach_token() -> str: import os - return os.environ.get("CUSTOM_ATTACH_TOKEN", "NOT_SET") + return os.environ.get("NVIDIA_API_KEY", "NOT_SET") def exec_token(sb: Sandbox) -> str: result = sb.exec_python(read_attach_token) @@ -292,7 +287,7 @@ def wait_for_token(sb: Sandbox, expected: str) -> None: if expected == "NOT_SET": matched = last == expected else: - matched = _is_placeholder_for_env_key(last, "CUSTOM_ATTACH_TOKEN") + matched = _is_placeholder_for_env_key(last, "NVIDIA_API_KEY") if matched: return time.sleep(2) @@ -310,7 +305,7 @@ def wait_for_token(sb: Sandbox, expected: str) -> None: ) wait_for_token( sb, - "openshell:resolve:env:CUSTOM_ATTACH_TOKEN", + "openshell:resolve:env:NVIDIA_API_KEY", ) stub.DetachSandboxProvider( @@ -397,8 +392,12 @@ def test_update_provider_preserves_unset_credentials_and_config( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"KEY_A": "val-a", "KEY_B": "val-b"}, + type="codex", + credentials={ + "CODEX_AUTH_ACCESS_TOKEN": "val-a", + "CODEX_AUTH_REFRESH_TOKEN": "val-b", + "CODEX_AUTH_ACCOUNT_ID": "account-id", + }, config={"BASE_URL": "https://example.com"}, ) ) @@ -409,7 +408,7 @@ def test_update_provider_preserves_unset_credentials_and_config( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), type="", - credentials={"KEY_A": "rotated-a"}, + credentials={"CODEX_AUTH_ACCESS_TOKEN": "rotated-a"}, ) ) ) @@ -442,8 +441,8 @@ def test_update_provider_empty_maps_preserves_all( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"TOKEN": "secret"}, + type="openai", + credentials={"OPENAI_API_KEY": "secret"}, config={"URL": "https://api.example.com"}, ) ) @@ -484,8 +483,8 @@ def test_update_provider_merges_config_preserves_credentials( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"API_KEY": "original-key"}, + type="openai", + credentials={"OPENAI_API_KEY": "original-key"}, config={"ENDPOINT": "https://old.example.com"}, ) ) @@ -527,8 +526,8 @@ def test_update_provider_rejects_type_change( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"KEY": "val"}, + type="openai", + credentials={"OPENAI_API_KEY": "val"}, ) ) ) @@ -553,8 +552,6 @@ def test_update_provider_rejects_type_change( # =========================================================================== -@pytest.mark.exclusive_gateway_config -@pytest.mark.usefixtures("providers_v2_enabled") def test_github_provider_allows_https_git_clone( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, @@ -567,9 +564,7 @@ def test_github_provider_allows_https_git_clone( the sandbox, exercising provider attachment, effective-policy composition, TLS interception, and real git behavior end to end. git delegates HTTPS to a ``git-remote-https`` helper whose ancestor is ``/usr/bin/git``, so the - profile's git binary covers it via ancestor matching. The - ``providers_v2_enabled`` fixture turns on the gateway-global gate that - composes the provider's network policy. + profile's git binary covers it via ancestor matching. """ with provider( sandbox_client._stub, diff --git a/e2e/run.sh b/e2e/run.sh index 0505730f05..875186394c 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -250,7 +250,7 @@ guest_gateway_bin= if [ "${mode}" = host ]; then echo "==> Building native host openshell-gateway" mise x -- cargo build "${cargo_jobs[@]}" \ - -p openshell-server \ + -p openshell-gateway \ --bin openshell-gateway \ --features bundled-z3 host_gateway_bin="${target_dir}/debug/openshell-gateway" @@ -268,7 +268,7 @@ else mise x -- cargo zigbuild "${cargo_jobs[@]}" \ --release \ --target "${linux_gateway_zig_target}" \ - -p openshell-server \ + -p openshell-gateway \ --bin openshell-gateway \ --features bundled-z3 ) diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index 5a8028779a..bbccb7496a 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -8,12 +8,72 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -78,18 +138,40 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -109,6 +191,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -121,20 +209,20 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "equivalent" @@ -149,7 +237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -158,6 +246,18 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -239,6 +339,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -262,6 +375,25 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -276,9 +408,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -329,6 +461,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -354,6 +487,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -517,6 +663,32 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "libc" version = "0.2.189" @@ -560,12 +732,24 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.2" @@ -574,7 +758,53 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -595,6 +825,8 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "jsonwebtoken", + "nix", "prost", "rand", "serde", @@ -605,6 +837,10 @@ dependencies = [ "sha2", "tempfile", "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tower", "url", ] @@ -631,12 +867,42 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -652,6 +918,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -752,6 +1024,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -762,9 +1048,15 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -905,6 +1197,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -915,6 +1213,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -934,7 +1244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -965,6 +1275,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -986,7 +1302,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1009,6 +1325,36 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1033,18 +1379,29 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", ] [[package]] @@ -1061,6 +1418,71 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -1074,9 +1496,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1104,6 +1538,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1152,6 +1592,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1180,6 +1665,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1189,6 +1683,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index c8ef57f693..18556d0f7b 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -16,6 +16,7 @@ license = "Apache-2.0" publish = false [features] +# Selects the common E2E suite. e2e = [] # Selects tests that rely on `host.openshell.internal` (the sandbox's stable # alias to the host running test fixtures). docker, podman, and vm wire the @@ -28,9 +29,13 @@ e2e-docker = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-gpu = ["e2e"] e2e-docker-gpu = ["e2e-docker", "e2e-gpu"] e2e-kubernetes = ["e2e"] +e2e-kubernetes-credential-drivers = ["e2e-kubernetes"] +e2e-kubernetes-workspace-managed = ["e2e-kubernetes"] +e2e-kubernetes-workspace-operator = ["e2e-kubernetes"] e2e-podman = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-podman-gpu = ["e2e-podman", "e2e-gpu"] e2e-oidc-pkce = [] +e2e-provider-refresh-keycloak = [] e2e-vm = ["e2e", "e2e-host-gateway"] [[test]] @@ -38,6 +43,16 @@ name = "oidc_pkce" path = "tests/oidc_pkce.rs" required-features = ["e2e-oidc-pkce"] +[[test]] +name = "provider_refresh_keycloak" +path = "tests/provider_refresh_keycloak.rs" +required-features = ["e2e-provider-refresh-keycloak"] + +[[test]] +name = "vm_overlay" +path = "tests/vm_overlay.rs" +required-features = ["e2e-vm"] + [[test]] name = "custom_image" path = "tests/custom_image.rs" @@ -54,8 +69,8 @@ path = "tests/driver_config_volume.rs" required-features = ["e2e-local-container-driver"] [[test]] -name = "gateway_resume" -path = "tests/gateway_resume.rs" +name = "gateway_start" +path = "tests/gateway_start.rs" required-features = ["e2e-docker"] [[test]] @@ -64,8 +79,8 @@ path = "tests/local_driver_token_restart.rs" required-features = ["e2e"] [[test]] -name = "podman_gateway_resume" -path = "tests/podman_gateway_resume.rs" +name = "podman_gateway_start" +path = "tests/podman_gateway_start.rs" required-features = ["e2e-podman"] [[test]] @@ -79,20 +94,55 @@ path = "tests/podman_oci_identity.rs" required-features = ["e2e-podman"] [[test]] -name = "vm_gateway_resume" -path = "tests/vm_gateway_resume.rs" +name = "podman_userns" +path = "tests/podman_userns.rs" +required-features = ["e2e-podman"] + +[[test]] +name = "provider_refresh_handles" +path = "tests/provider_refresh_handles.rs" +required-features = ["e2e-podman"] + +[[test]] +name = "vm_gateway_start" +path = "tests/vm_gateway_start.rs" +required-features = ["e2e-vm"] + +[[test]] +name = "vm_corporate_proxy" +path = "tests/vm_corporate_proxy.rs" required-features = ["e2e-vm"] +[[test]] +name = "provider_token_exchange" +path = "tests/provider_token_exchange.rs" +required-features = ["e2e-podman"] + [[test]] name = "readyz_health" path = "tests/readyz_health.rs" required-features = ["e2e-kubernetes"] +[[test]] +name = "kubernetes_corporate_proxy" +path = "tests/kubernetes_corporate_proxy.rs" +required-features = ["e2e-kubernetes"] + +[[test]] +name = "credential_drivers" +path = "tests/credential_drivers.rs" +required-features = ["e2e-kubernetes-credential-drivers"] + [[test]] name = "websocket_conformance" path = "tests/websocket_conformance.rs" required-features = ["e2e-host-gateway"] +[[test]] +name = "credential_gating" +path = "tests/credential_gating.rs" +required-features = ["e2e-host-gateway"] + [[test]] name = "user_namespaces" path = "tests/user_namespaces.rs" @@ -123,11 +173,26 @@ name = "workspace_lifecycle" path = "tests/workspace_lifecycle.rs" required-features = ["e2e"] +[[test]] +name = "sandbox_templates" +path = "tests/sandbox_templates.rs" +required-features = ["e2e"] + [[test]] name = "proxy_egress_pipeline" path = "tests/proxy_egress_pipeline.rs" required-features = ["e2e-host-gateway"] +[[test]] +name = "workspace_namespace_managed" +path = "tests/workspace_namespace_managed.rs" +required-features = ["e2e-kubernetes-workspace-managed"] + +[[test]] +name = "workspace_namespace_operator" +path = "tests/workspace_namespace_operator.rs" +required-features = ["e2e-kubernetes-workspace-operator"] + [[test]] name = "gpu" path = "tests/gpu.rs" @@ -141,8 +206,10 @@ futures-util = "0.3" http-body-util = "0.1" hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } +jsonwebtoken = "9" prost = "0.14" tokio = { version = "1.43", features = ["full"] } +tokio-stream = { version = "0.1", features = ["net"] } tempfile = "3" sha1 = "0.10" sha2 = "0.10" @@ -151,7 +218,11 @@ rand = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = "0.0.12" +tonic = { version = "0.14", features = ["transport"] } +tonic-prost = "0.14" +tower = "0.5" url = "2" +nix = { version = "0.29", features = ["user"] } [dev-dependencies] serial_test = "3" diff --git a/e2e/rust/e2e-docker.sh b/e2e/rust/e2e-docker.sh index 6c28868083..ba97b85a5b 100755 --- a/e2e/rust/e2e-docker.sh +++ b/e2e/rust/e2e-docker.sh @@ -2,23 +2,47 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Run a Rust e2e test against a standalone gateway running the bundled Docker -# compute driver. Set OPENSHELL_GATEWAY_ENDPOINT=http://host:port to reuse an -# existing plaintext gateway instead of starting an ephemeral one. +# Run standalone CLI conformance, 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. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -E2E_TEST="${OPENSHELL_E2E_DOCKER_TEST:-smoke}" -E2E_FEATURES="${OPENSHELL_E2E_DOCKER_FEATURES:-e2e,e2e-docker}" +E2E_TEST="${OPENSHELL_E2E_DOCKER_TEST:-}" +E2E_FEATURES="${OPENSHELL_E2E_DOCKER_FEATURES-e2e-docker}" DEFAULT_WORKLOAD_MANIFEST="${ROOT}/e2e/gpu/images/.build/workloads.yaml" +RUN_WITH_GATEWAY_COMMAND="__openshell_run_docker_e2e" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" + +run_e2e() { + e2e_run_openshell_conformance "Docker" + + if [ -z "${E2E_FEATURES}" ]; then + return 0 + fi + + local cargo_args=( + cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" + --features "${E2E_FEATURES}" + ) + if [ -n "${E2E_TEST}" ]; then + cargo_args+=(--test "${E2E_TEST}") + fi + cargo_args+=(-- --nocapture) + "${cargo_args[@]}" +} if [ "${E2E_TEST}" = "gpu" ] && [ -z "${OPENSHELL_E2E_WORKLOAD_MANIFEST:-}" ] && [ ! -f "${DEFAULT_WORKLOAD_MANIFEST}" ]; then echo "note: running GPU e2e without a workload manifest; workload validation will log an explicit skip. Build one with 'mise run e2e:workloads:build' or set OPENSHELL_E2E_WORKLOAD_MANIFEST." fi +if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then + run_e2e + exit 0 +fi + exec "${ROOT}/e2e/with-docker-gateway.sh" \ - cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" \ - --features "${E2E_FEATURES}" \ - --test "${E2E_TEST}" \ - -- --nocapture + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/e2e-kubernetes.sh b/e2e/rust/e2e-kubernetes.sh index 20343f7231..c54057bb94 100755 --- a/e2e/rust/e2e-kubernetes.sh +++ b/e2e/rust/e2e-kubernetes.sh @@ -6,7 +6,7 @@ # via Helm. Set OPENSHELL_E2E_KUBE_CONTEXT to target an existing cluster; # otherwise an ephemeral k3d cluster is created and torn down by # with-kube-gateway.sh. Set OPENSHELL_E2E_KUBE_TEST to scope to a single -# integration test (e.g. smoke) for local debugging. +# integration test for local debugging. # # Features: the default set includes `e2e-host-gateway` so tests that rely on # the sandbox-side `host.openshell.internal` alias compile and run. The @@ -18,14 +18,18 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RUN_WITH_GATEWAY_COMMAND="__openshell_run_kubernetes_e2e" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" -E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES:-e2e,e2e-host-gateway,e2e-kubernetes}" +E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES-e2e,e2e-host-gateway,e2e-kubernetes}" # Docker and Podman build their local gateway and CLI together in the shared # gateway wrapper. Kubernetes consumes published gateway images, so only its # local CLI needs to be built when CI has not supplied a prebuilt one. if [ -z "${OPENSHELL_BIN:-}" ]; then cargo build -p openshell-cli + export OPENSHELL_BIN="${ROOT}/target/debug/openshell" fi test_filter=() @@ -33,9 +37,48 @@ if [ -n "${OPENSHELL_E2E_KUBE_TEST:-}" ]; then test_filter+=(--test "${OPENSHELL_E2E_KUBE_TEST}") fi -exec "${ROOT}/e2e/with-kube-gateway.sh" \ +is_operator_workspace_mode() { + [[ ",${E2E_FEATURES}," == *",e2e-kubernetes-workspace-operator,"* ]] +} + +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)." + return 0 + fi + + e2e_run_openshell_conformance "Kubernetes" +} + +run_suite() { + "${ROOT}/e2e/with-kube-gateway.sh" \ + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" +} + +run_e2e() { + run_conformance + if [ -z "${E2E_FEATURES}" ]; then + return 0 + fi + cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" \ --features "${E2E_FEATURES}" \ --no-fail-fast \ ${test_filter[@]+"${test_filter[@]}"} \ -- --nocapture +} + +if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then + run_e2e + exit 0 +fi + +if [ "${OPENSHELL_E2E_CREDENTIAL_DRIVERS:-0}" = "1" ] \ + && [ -z "${OPENSHELL_E2E_CREDENTIAL_DRIVER:-}" ]; then + OPENSHELL_E2E_CREDENTIAL_DRIVER=kubernetes-secrets run_suite + OPENSHELL_E2E_CREDENTIAL_DRIVER=vault run_suite + exit 0 +fi + +exec "${ROOT}/e2e/with-kube-gateway.sh" \ + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/e2e-openshift.sh b/e2e/rust/e2e-openshift.sh index 3abd818550..639e6323a4 100755 --- a/e2e/rust/e2e-openshift.sh +++ b/e2e/rust/e2e-openshift.sh @@ -15,6 +15,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=e2e/support/gateway-common.sh +source "${ROOT}/e2e/support/gateway-common.sh" + CHART_PATH="${CHART_PATH:-./deploy/helm/openshell}" NAMESPACE="openshell" RELEASE="openshell" @@ -119,6 +122,7 @@ oc adm policy add-scc-to-user privileged -z "${RELEASE}-sandbox" -n "$NAMESPACE" OPENSHIFT_FLAGS=( --set server.disableTls=true + --set "server.telemetryEnabled=${OPENSHELL_TELEMETRY_ENABLED}" --set podSecurityContext.fsGroup=null --set securityContext.runAsUser=null --set image.tag="$IMAGE_TAG" diff --git a/e2e/rust/e2e-podman-rootless.sh b/e2e/rust/e2e-podman-rootless.sh deleted file mode 100755 index d7fb5acd3c..0000000000 --- a/e2e/rust/e2e-podman-rootless.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Run the Podman e2e suite and verify rootless mode. -# -# Identical to e2e-podman.sh but fails fast if Podman is not running -# rootless. Use this to explicitly validate the rootless networking -# path (pasta, host-gateway, bind address). - -set -euo pipefail - -rootless="$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null || true)" -if [ "${rootless}" != "true" ]; then - echo "ERROR: podman is not running rootless; expected true, got '${rootless:-}'" >&2 - exit 2 -fi - -exec "$(dirname "${BASH_SOURCE[0]}")/e2e-podman.sh" diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index 16796c0562..736519458e 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -10,21 +10,43 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" E2E_TEST="${OPENSHELL_E2E_PODMAN_TEST:-}" -E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES:-e2e-podman}" +E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES-e2e-podman}" DEFAULT_WORKLOAD_MANIFEST="${ROOT}/e2e/gpu/images/.build/workloads.yaml" +RUN_WITH_GATEWAY_COMMAND="__openshell_run_podman_e2e" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" if [ "${E2E_TEST}" = "gpu" ] && [ -z "${OPENSHELL_E2E_WORKLOAD_MANIFEST:-}" ] && [ ! -f "${DEFAULT_WORKLOAD_MANIFEST}" ]; then echo "note: running Podman GPU e2e without a workload manifest; workload validation will log an explicit skip. Build one with 'CONTAINER_ENGINE=podman mise run e2e:workloads:build' or set OPENSHELL_E2E_WORKLOAD_MANIFEST." fi -TEST_ARGS=( - cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" - --features "${E2E_FEATURES}" -) -if [ -n "${E2E_TEST}" ]; then - TEST_ARGS+=(--test "${E2E_TEST}") +if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then + e2e_run_openshell_conformance "Podman" + if [ -z "${E2E_FEATURES}" ]; then + exit 0 + fi + + TEST_ARGS=( + cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" + --features "${E2E_FEATURES}" + ) + if [ -n "${E2E_TEST}" ]; then + TEST_ARGS+=(--test "${E2E_TEST}") + fi + TEST_ARGS+=(-- --nocapture) + "${TEST_ARGS[@]}" + exit 0 +fi + +# An empty selector runs the full Podman suite, including provider_token_exchange. +if [ -z "${E2E_TEST}" ] || [ "${E2E_TEST}" = "provider_token_exchange" ]; then + export OPENSHELL_E2E_SPIFFE_FIXTURE="${OPENSHELL_E2E_SPIFFE_FIXTURE:-1}" +fi + +if [ -n "${OPENSHELL_GATEWAY_ENDPOINT:-}" ] && [ -z "${OPENSHELL_BIN:-}" ]; then + cargo build -p openshell-cli + export OPENSHELL_BIN="${ROOT}/target/debug/openshell" fi -TEST_ARGS+=(-- --nocapture) exec "${ROOT}/e2e/with-podman-gateway.sh" \ - "${TEST_ARGS[@]}" + bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 43b573f867..96da2a879f 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -46,13 +46,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "${ROOT}/e2e/support/gateway-common.sh" +# shellcheck source=e2e/support/conformance.sh +source "${ROOT}/e2e/support/conformance.sh" COMPRESSED_DIR="${ROOT}/target/vm-runtime-compressed" GATEWAY_BIN="${OPENSHELL_GATEWAY_BIN:-${ROOT}/target/debug/openshell-gateway}" DRIVER_BIN="${OPENSHELL_VM_DRIVER_BIN:-${ROOT}/target/debug/openshell-driver-vm}" CLI_BIN="${OPENSHELL_BIN:-${ROOT}/target/debug/openshell}" E2E_TEST_OVERRIDE="${OPENSHELL_E2E_VM_TEST:-}" -E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES:-e2e-vm}" +E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES-e2e-vm}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" # The VM driver places `compute-driver.sock` under `[openshell.drivers.vm].state_dir`. # AF_UNIX SUN_LEN is 104 bytes on macOS (108 on Linux), so paths anchored @@ -97,7 +100,14 @@ fi build_packages=() if [ -z "${OPENSHELL_GATEWAY_BIN:-}" ]; then - build_packages+=(-p openshell-server) + if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then + echo "==> Building driver-free openshell-gateway" + cargo build \ + -p openshell-gateway --bin openshell-gateway \ + --no-default-features --features telemetry + else + build_packages+=(-p openshell-gateway) + fi else echo "==> Using prebuilt openshell-gateway at ${GATEWAY_BIN}" fi @@ -165,6 +175,9 @@ GATEWAY_DB="${RUN_STATE_DIR}/gateway.db" JWT_DIR="${RUN_STATE_DIR}/jwt" PKI_DIR="${RUN_STATE_DIR}/pki" GATEWAY_NAME="openshell-e2e-vm-${HOST_PORT}" +DRIVER_PID="" +DRIVER_LOG="${RUN_STATE_DIR}/vm-driver.log" +DRIVER_SOCKET="${RUN_STATE_DIR}/compute-driver.sock" # ── Cleanup (trap) ─────────────────────────────────────────────────── @@ -188,6 +201,7 @@ cleanup() { kill -KILL "${gateway_pid}" 2>/dev/null || true wait "${gateway_pid}" 2>/dev/null || true fi + e2e_stop_process "${DRIVER_PID}" "external VM compute driver" # On failure, keep the VM console log for debugging. We deliberately # print it instead of leaving it on disk because the state dir gets @@ -196,6 +210,11 @@ cleanup() { echo "=== gateway log (preserved for debugging) ===" cat "${GATEWAY_LOG}" 2>/dev/null || true echo "=== end gateway log ===" + if [ -f "${DRIVER_LOG}" ]; then + echo "=== external VM compute driver log ===" + cat "${DRIVER_LOG}" 2>/dev/null || true + echo "=== end external VM compute driver log ===" + fi local console while IFS= read -r -d '' console; do @@ -261,6 +280,11 @@ gateway_id = "${GATEWAY_NAME}" ttl_secs = 0 [openshell.drivers.vm] +EOF +if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then + printf 'socket_path = "%s"\n' "${DRIVER_SOCKET}" >>"${GATEWAY_CONFIG}" +else + cat >>"${GATEWAY_CONFIG}" <"${DRIVER_LOG}" 2>&1 & + DRIVER_PID=$! + e2e_wait_for_socket \ + "${DRIVER_SOCKET}" "${DRIVER_PID}" "external VM compute driver" 60 +fi GATEWAY_ARGS=( --config "${GATEWAY_CONFIG}" @@ -332,7 +373,6 @@ fi # The CLI uses the raw endpoint but still resolves matching metadata so it # can find the mTLS client bundle. -export OPENSHELL_E2E_EXPECT_VM_OVERLAY=1 export OPENSHELL_E2E_DRIVER="vm" export OPENSHELL_E2E_VM_STATE_DIR="${RUN_STATE_DIR}" e2e_export_gateway_restart_metadata \ @@ -347,6 +387,8 @@ e2e_export_gateway_restart_metadata \ # preparation; allow 180s for slower CI runners. export OPENSHELL_PROVISION_TIMEOUT="${SANDBOX_PROVISION_TIMEOUT}" +e2e_run_openshell_conformance "VM" + run_e2e_test() { local test_target="$1" shift @@ -364,7 +406,8 @@ run_e2e_test() { if [ -n "${E2E_TEST_OVERRIDE}" ]; then run_e2e_test "${E2E_TEST_OVERRIDE}" else - run_e2e_test smoke run_e2e_test host_gateway_alias - run_e2e_test vm_gateway_resume + run_e2e_test vm_overlay + run_e2e_test vm_gateway_start + run_e2e_test vm_corporate_proxy fi diff --git a/e2e/rust/src/harness/cli.rs b/e2e/rust/src/harness/cli.rs index 53392d752c..1c63ea392b 100644 --- a/e2e/rust/src/harness/cli.rs +++ b/e2e/rust/src/harness/cli.rs @@ -4,6 +4,7 @@ //! Shared CLI helpers for e2e tests that need to invoke `openshell` commands //! and poll for readiness. +use std::future::Future; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -12,6 +13,24 @@ use tokio::time::sleep; use super::binary::openshell_cmd; use super::output::strip_ansi; +async fn poll_with_diagnostics(timeout: Duration, mut attempt: F) -> Result<(), String> +where + F: FnMut() -> Fut, + Fut: Future, +{ + let start = Instant::now(); + loop { + let (ready, last_output) = attempt().await; + if ready { + return Ok(()); + } + if start.elapsed() > timeout { + return Err(last_output); + } + sleep(Duration::from_secs(2)).await; + } +} + pub async fn run_cli(args: &[&str]) -> (String, i32) { let mut cmd = openshell_cmd(); cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -25,30 +44,23 @@ pub async fn run_cli(args: &[&str]) -> (String, i32) { } pub async fn wait_for_healthy(timeout: Duration) -> Result<(), String> { - let start = Instant::now(); - let mut last_output: String; - - loop { + poll_with_diagnostics(timeout, || async { let (output, code) = run_cli(&["status"]).await; let clean = strip_ansi(&output); let lower = clean.to_lowercase(); - if code == 0 + let ready = code == 0 && (lower.contains("healthy") || lower.contains("running") - || lower.contains("connected")) - { - return Ok(()); - } - last_output = clean; - - if start.elapsed() > timeout { - return Err(format!( - "gateway did not become healthy within {}s. Last output:\n{last_output}", - timeout.as_secs() - )); - } - sleep(Duration::from_secs(2)).await; - } + || lower.contains("connected")); + (ready, clean) + }) + .await + .map_err(|last_output| { + format!( + "gateway did not become healthy within {}s. Last output:\n{last_output}", + timeout.as_secs() + ) + }) } pub async fn sandbox_names() -> Result, String> { @@ -66,42 +78,66 @@ pub async fn sandbox_names() -> Result, String> { .collect()) } +pub async fn wait_for_sandbox_phase( + sandbox_name: &str, + expected_phase: &str, + timeout: Duration, +) -> Result<(), String> { + poll_with_diagnostics(timeout, || async { + let (output, code) = run_cli(&["sandbox", "get", sandbox_name, "--output", "json"]).await; + let clean = strip_ansi(&output); + let phase_matches = serde_json::from_str::(&clean) + .ok() + .and_then(|value| { + value + .get("phase") + .and_then(|phase| phase.as_str()) + .map(str::to_owned) + }) + .is_some_and(|phase| phase == expected_phase); + (code == 0 && phase_matches, clean) + }) + .await + .map_err(|last_output| { + format!( + "sandbox '{sandbox_name}' did not reach phase '{expected_phase}' within {}s. Last output:\n{last_output}", + timeout.as_secs() + ) + }) +} + pub async fn wait_for_sandbox_exec_contains( sandbox_name: &str, command: &[&str], expected: &str, timeout: Duration, ) -> Result<(), String> { - let start = Instant::now(); - let mut last_output: String; - - loop { + poll_with_diagnostics(timeout, || async { let mut cmd = openshell_cmd(); cmd.args(["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"]) .args(command) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - match cmd.output().await { + let (ready, last_output) = match cmd.output().await { Ok(output) => { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - last_output = strip_ansi(&format!("{stdout}{stderr}")); - if output.status.success() && last_output.contains(expected) { - return Ok(()); - } - } - Err(err) => { - last_output = format!("failed to spawn openshell sandbox exec: {err}"); + let clean = strip_ansi(&format!("{stdout}{stderr}")); + (output.status.success() && clean.contains(expected), clean) } - } - - if start.elapsed() > timeout { - return Err(format!( - "sandbox '{sandbox_name}' exec did not produce '{expected}' within {}s. Last output:\n{last_output}", - timeout.as_secs() - )); - } - sleep(Duration::from_secs(2)).await; - } + Err(err) => ( + false, + format!("failed to spawn openshell sandbox exec: {err}"), + ), + }; + (ready, last_output) + }) + .await + .map_err(|last_output| { + format!( + "sandbox '{sandbox_name}' exec did not produce '{expected}' within {}s. Last output:\n{last_output}", + timeout.as_secs() + ) + }) } diff --git a/e2e/rust/src/harness/container.rs b/e2e/rust/src/harness/container.rs index 764ee6d0f6..034cfca78e 100644 --- a/e2e/rust/src/harness/container.rs +++ b/e2e/rust/src/harness/container.rs @@ -197,32 +197,160 @@ pub struct SupportContainer { engine: ContainerEngine, } +/// A TCP fixture published on the test host for Kubernetes sandbox e2e tests. +/// +/// Kubernetes sandboxes reach it through the chart-provided +/// `host.openshell.internal` alias. Unlike [`SupportContainer`], this does not +/// require the Docker e2e network used by local-container driver tests. +pub struct HostSupportContainer { + pub port: u16, + container_id: String, + engine: ContainerEngine, +} + +impl HostSupportContainer { + /// Start a Python fixture and publish `container_port` on a free host port. + pub async fn start_python(script: &str, container_port: u16) -> Result { + Self::start_python_on_host_port(script, container_port, find_free_port()).await + } + + /// Start a Python fixture on a caller-selected host port. + /// + /// Use this when the fixture endpoint must be known before the Helm chart + /// starts the gateway, such as the configured corporate forward proxy. + pub async fn start_python_on_host_port( + script: &str, + container_port: u16, + port: u16, + ) -> Result { + let engine = ContainerEngine::from_env()?; + let output = engine + .command() + .args([ + "run", + "--detach", + "--entrypoint", + "python3", + "-p", + &format!("{port}:{container_port}"), + DEFAULT_TEST_SERVER_IMAGE, + "-c", + script, + ]) + .output() + .map_err(|err| format!("start {} host fixture: {err}", engine.name()))?; + if !output.status.success() { + return Err(format!( + "{} run failed (exit {:?}):\n{}", + engine.name(), + output.status.code(), + String::from_utf8_lossy(&output.stderr) + )); + } + let fixture = Self { + port, + container_id: String::from_utf8_lossy(&output.stdout).trim().to_string(), + engine, + }; + fixture.wait_until_listening(container_port).await?; + Ok(fixture) + } + + async fn wait_until_listening(&self, container_port: u16) -> Result<(), String> { + let deadline = timeout(Duration::from_secs(60), async { + let mut tick = interval(Duration::from_millis(500)); + loop { + tick.tick().await; + let output = self + .engine + .command() + .args(["exec", &self.container_id, "python3", "-c", &format!("import socket; socket.create_connection(('127.0.0.1', {container_port}), timeout=1).close()")]) + .output() + .ok(); + if output.is_some_and(|output| output.status.success()) { + return; + } + } + }) + .await; + deadline.map_err(|_| { + format!( + "host fixture did not listen within 60s. Logs:\n{}", + self.logs().unwrap_or_else(|err| err) + ) + }) + } + + pub fn logs(&self) -> Result { + let output = self + .engine + .command() + .args(["logs", &self.container_id]) + .output() + .map_err(|err| format!("read {} fixture logs: {err}", self.engine.name()))?; + Ok(format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + } +} + +impl Drop for HostSupportContainer { + fn drop(&mut self) { + let _ = self + .engine + .command() + .args(["rm", "-f", &self.container_id]) + .output(); + } +} + impl SupportContainer { /// Start a `python3 -c